audit-values.js 78 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581
  1. #!/usr/bin/env node
  2. /**
  3. * audit-values.js — 界面取值审计
  4. *
  5. * 回答一个很具体的问题:**攒下来的老配置,能不能让界面显示成 undefined / 空框**。
  6. *
  7. * 三个场景:
  8. * A. 全新配置 —— 首次安装
  9. * B. 脏数据 —— 设备上残留的旧版本 / 手改过的 / 缺字段的配置(真实用户场景)
  10. * C. 字段表完整性 —— 每个声明字段的默认值都必须合法,且能在界面上取到
  11. *
  12. * 为什么必须测 B:设备端没有测试框架,而「用户升级后发现某个框变空了」
  13. * 这类问题恰恰只在有历史数据时出现 —— 全新安装永远是好的。
  14. *
  15. * 用法:node tools/audit-values.js
  16. */
  17. 'use strict';
  18. const path = require('path');
  19. const stub = require('./android-stub');
  20. const env = stub.install();
  21. globalThis.__FEIZHOU_TEST__ = true;
  22. require(path.join(__dirname, '..', 'dist', 'tiktok-app.js'));
  23. const api = globalThis.__FEIZHOU__;
  24. /* 改版(v6)后有两层:
  25. * · 一级 tab:任务 / 记录 / 我的
  26. * · 二级任务详情:ID提取 / UID私信 / 养号 / 视频功能
  27. * 两层都要按同一套标准扫(undefined / 取值 / 持久化),
  28. * 所以 PAGES 是两者的并集,PAGE_TITLE 负责给报告里的名字。 */
  29. const TAB_PAGES = ['tasks', 'records', 'mine'];
  30. const DETAIL_PAGES = ['id', 'dm', 'nurture', 'video'];
  31. const PAGES = TAB_PAGES.concat(DETAIL_PAGES);
  32. const PAGE_TITLE = {
  33. tasks: '任务列表', records: '运行记录', mine: '我的',
  34. id: 'ID提取', dm: 'UID私信', nurture: '养号', video: '视频功能'
  35. };
  36. const BAD = /undefined|null|NaN/;
  37. let issueCount = 0;
  38. function fail(message) {
  39. issueCount += 1;
  40. console.log(' ✗ ' + message);
  41. }
  42. function pass(message) {
  43. console.log(' ✓ ' + message);
  44. }
  45. function check(name, ok, detail) {
  46. if (ok) { pass(name); } else { fail(name + (detail ? ' → ' + detail : '')); }
  47. }
  48. /** 递归找满足条件的控件。 */
  49. function findAll(view, predicate, out, depth) {
  50. if (!view || depth > 14) { return out || []; }
  51. const result = out || [];
  52. if (predicate(view)) { result.push(view); }
  53. const n = typeof view.getChildCount === 'function' ? view.getChildCount() : 0;
  54. for (let i = 0; i < n; i++) { findAll(view.getChildAt(i), predicate, result, depth + 1); }
  55. return result;
  56. }
  57. /* 识别输入框:桩里只有 MockEditText 带 setInputType */
  58. const isInput = (v) => typeof v.setInputType === 'function';
  59. const textIs = (t) => (v) => typeof v.getText === 'function' && String(v.getText()) === t;
  60. /**
  61. * 点弹窗里的「确定」。
  62. *
  63. * ⚠️ 弹窗按钮是「容器 FrameLayout(挂监听)+ 子 TextView(显示文字)」两层结构,
  64. * 直接对 TextView 调 performClick() 不会触发任何东西。
  65. * 所以要优先点带监听的父节点 —— 这个坑已经在 smoke.js 里踩过一次。
  66. */
  67. function clickConfirm(overlay) {
  68. const buttons = findAll(overlay, textIs('确定'), [], 0);
  69. if (buttons.length > 0) {
  70. const node = buttons[0];
  71. if (node.parent && node.parent.listeners && node.parent.listeners.click) {
  72. node.parent.performClick();
  73. } else {
  74. node.performClick();
  75. }
  76. }
  77. return buttons.length;
  78. }
  79. /* ------------------------------------------------------------ 渲染扫描 */
  80. /** 把一棵视图树的文字收集成数组(不走 collect 的下标轨迹,只看文案)。 */
  81. function collectText(view) {
  82. const items = [];
  83. collect(view, items, '', 0);
  84. return items.map((x) => x.text);
  85. }
  86. function collect(view, out, trail, depth) {
  87. if (!view || depth > 12) { return; }
  88. if (typeof view.getText === 'function') {
  89. out.push({
  90. trail: trail,
  91. text: String(view.getText()),
  92. hint: view.hint === undefined || view.hint === null ? '' : String(view.hint)
  93. });
  94. }
  95. const count = typeof view.getChildCount === 'function' ? view.getChildCount() : 0;
  96. for (let i = 0; i < count; i++) {
  97. collect(view.getChildAt(i), out, trail + '.' + i, depth + 1);
  98. }
  99. }
  100. /** 把 5 个页签全渲染一遍,扫出 undefined / null / NaN。 */
  101. function scanPages() {
  102. PAGES.forEach((key) => {
  103. let items = [];
  104. try {
  105. items = [];
  106. collect(renderPage(key), items, key, 0);
  107. } catch (error) {
  108. fail(`[${PAGE_TITLE[key]}] 渲染抛异常:${error.message}`);
  109. return;
  110. }
  111. const bad = items.filter((x) => BAD.test(x.text) || BAD.test(x.hint));
  112. if (bad.length === 0) {
  113. pass(`[${PAGE_TITLE[key]}] ${items.length} 个控件,无 undefined / null / NaN`);
  114. } else {
  115. bad.forEach((x) => {
  116. fail(`[${PAGE_TITLE[key]}] 非法值 ${x.trail} 正文="${x.text}" 占位="${x.hint}"`);
  117. });
  118. }
  119. });
  120. }
  121. /** 取某个控件的文字,用于断言「这一格到底显示了什么」。
  122. *
  123. * 走 renderPage 而不是直接取 Pages[key]:记录页有「列表 / 空态」两种形态,
  124. * 分流逻辑在路由层(App.TAB_PAGES),绕开它就会拿到与真实入口不一致的那一个。 */
  125. function textsOf(pageKey) {
  126. const items = [];
  127. collect(renderPage(pageKey), items, pageKey, 0);
  128. return items.map((x) => x.text);
  129. }
  130. /** 登录页也按同一套标准扫:它是独立一屏,不在 PAGES 里。 */
  131. function scanLogin(label) {
  132. let items = [];
  133. try {
  134. items = [];
  135. collect(api.Pages.login(), items, 'login', 0);
  136. } catch (error) {
  137. fail(`[登录页·${label}] 渲染抛异常:${error.message}`);
  138. return;
  139. }
  140. const bad = items.filter((x) => BAD.test(x.text) || BAD.test(x.hint));
  141. if (bad.length === 0) {
  142. pass(`[登录页·${label}] ${items.length} 个控件,无 undefined / null / NaN`);
  143. } else {
  144. bad.forEach((x) => {
  145. fail(`[登录页·${label}] 非法值 ${x.trail} 正文="${x.text}" 占位="${x.hint}"`);
  146. });
  147. }
  148. }
  149. /** 断言某个页面的控件文字里包含某段内容。 */
  150. function pageShows(pageKey, needle, why) {
  151. const all = textsOf(pageKey).join(' | ');
  152. if (all.indexOf(needle) >= 0) {
  153. pass(`${PAGE_TITLE[pageKey]} 显示「${needle}」(${why})`);
  154. } else {
  155. fail(`${PAGE_TITLE[pageKey]} 未显示「${needle}」(${why})`);
  156. }
  157. }
  158. /* ============================================================ 场景 A */
  159. console.log('=== 场景 A:全新配置 ===');
  160. console.log('');
  161. api.Store.init();
  162. scanPages();
  163. scanLogin('idle');
  164. /* ============================================================ 场景 B */
  165. console.log('');
  166. console.log('=== 场景 B:脏数据(模拟设备上残留的旧配置)===');
  167. console.log('');
  168. /* 故意造一份「什么都不对」的配置:缺字段、类型错、枚举非法、范围越界、多余字段,
  169. * 且 version=1 —— 也就是**老用户设备上真实的样子**。
  170. *
  171. * 关键点:v1 的 keyword / region / 各类文件路径默认值就是空串,
  172. * 所以老存档里存的是 "";而 "" 是合法字符串,光靠类型归一化补不上默认值,
  173. * 必须走版本迁移 —— 这正是用户在真机上看到「框是空的」的原因。 */
  174. const dirty = {
  175. version: 1,
  176. activeTab: '已废弃的页签',
  177. tabs: {
  178. id: {
  179. scripts: {
  180. s1: { checked: 'yes', enterPage: null, textIdPath: '' },
  181. /* v6 时代脚本三的「进入页面」可以是「评论页」,v7 值域收窄后不成立 */
  182. s3: { enterPage: 'comment' }
  183. }
  184. },
  185. dm: {
  186. followLimit: '两百', dmLimit: 40, aiGender: '', restMin: 900, restMax: 100,
  187. /* v2 用一个开关同时管关注与私信,v3 拆成两个 —— 这里验迁移 */
  188. doFollowDm: false,
  189. dmTextPath: '', commentTextPath: ''
  190. },
  191. video: {
  192. keyword: '', region: 42, stopCount: 7, intervalMin: -5,
  193. actions: { follow: 'yes' }, dmTextPath: ''
  194. }
  195. },
  196. license: { status: 'unknown' },
  197. 已废弃的字段: { x: 1 }
  198. };
  199. env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(dirty) };
  200. const loaded = api.Store.init();
  201. console.log('归一化 + 迁移结果:');
  202. const EXPECT = [
  203. ['activeTab', 'tasks', '非法 tab 回退到「任务」'],
  204. ['tabs.id.scripts.s1.checked', true, '字符串真值转布尔'],
  205. /* v7 迁移:s3 的「进入页面」值域收窄成 expandList(只有粉丝/关注),
  206. * 旧值「评论页」在"展开一个列表"的语义下不成立 → 落到关注列表。
  207. * 这条同时承担了"非法枚举回退默认"的覆盖。 */
  208. ['tabs.id.scripts.s3.enterPage', 'follow', 'v7:旧「评论页」→ 关注列表'],
  209. ['tabs.id.scripts.s1.enterPage', 'fans', 'null 回退默认'],
  210. ['tabs.id.scripts.s1.textIdPath', api.Store.TEXT_DIR + '/uid_list.txt', 'v1 空串经迁移补默认'],
  211. /* 旧上限是「非数字」→ 迁移读不出数就不动它,直接用默认区间 */
  212. ['tabs.dm.followMin', 100, '旧关注上限非数字 → 不迁移,用默认区间下限'],
  213. ['tabs.dm.followMax', 200, '旧关注上限非数字 → 不迁移,用默认区间上限'],
  214. /* 旧上限是数字 → min 给 0(不设下界),max 沿用,行为不变 */
  215. ['tabs.dm.dmMin', 0, 'v4 私信上限 → 区间下界 0'],
  216. ['tabs.dm.dmMax', 40, 'v4 私信上限 → 区间上界沿用旧值'],
  217. ['tabs.dm.aiGender', 'male', '空串回退默认'],
  218. ['tabs.dm.doFollow', false, 'v2 合并开关迁移到「进行关注」'],
  219. ['tabs.dm.doDm', false, 'v2 合并开关迁移到「进行私信」'],
  220. ['tabs.dm.restMax', 900, 'min>max 自动抬齐'],
  221. ['tabs.dm.dmTextPath', api.Store.TEXT_DIR + '/dm_text.txt', 'v1 空串经迁移补默认'],
  222. ['tabs.video.keyword', 'fashion', 'v1 空串经迁移补业务默认值'],
  223. ['tabs.video.region', 'US', '类型不符回退默认值'],
  224. /* 旧「停止个数」是精确量 → 区间两端都取旧值(与稿子「20 ~ 20」同一表达) */
  225. ['tabs.video.stopMin', 7, 'v4 停止个数(精确量)→ 区间两端都取旧值'],
  226. ['tabs.video.stopMax', 7, 'v4 停止个数(精确量)→ 区间两端都取旧值'],
  227. ['tabs.video.intervalMin', 0, '负数夹到下限'],
  228. ['tabs.video.actions.follow', true, '字符串真值转布尔'],
  229. ['tabs.video.dmTextPath', api.Store.TEXT_DIR + '/dm_text.txt', 'v1 空串经迁移补默认'],
  230. ['license.status', 'none', '非法状态回退']
  231. ];
  232. EXPECT.forEach(([p, want, why]) => {
  233. const got = api.Store.get(p);
  234. if (got === want) {
  235. pass(`${p} = ${JSON.stringify(got)} (${why})`);
  236. } else {
  237. fail(`${p} = ${JSON.stringify(got)},期望 ${JSON.stringify(want)} (${why})`);
  238. }
  239. });
  240. /* 表外字段必须被丢弃,否则脏数据会一路传到界面 */
  241. if (api.Store.get('已废弃的字段') === undefined && loaded.tabs.id.scripts.s1.旧字段 === undefined) {
  242. pass('表外字段已被丢弃,不会穿透到界面');
  243. } else {
  244. fail('表外字段没有被丢弃');
  245. }
  246. console.log('');
  247. console.log('脏数据下的渲染:');
  248. scanPages();
  249. console.log('');
  250. console.log('关键格子的实际显示:');
  251. pageShows('video', 'fashion', '关键词有默认值');
  252. pageShows('video', 'US', '主播区域有默认值');
  253. pageShows('video', '停止个数', '停止个数是区间行');
  254. pageShows('video', '7', '旧 stopCount=7 迁移后在界面上显示为 7 ~ 7');
  255. pageShows('id', '本地去重', '去重开关已收到功能设置(全页只出现一次)');
  256. pageShows('id', '从哪个页面取', '「从哪个页面取」取代了原来的「进入页面」');
  257. pageShows('id', '粉丝页', '默认从粉丝页取');
  258. pageShows('dm', '关注+私信去重', '去重方式有默认值');
  259. /* 关注与私信各自独立成项;数量是「一行一个区间」 */
  260. pageShows('dm', '进行关注', '「进行关注」独立一行');
  261. pageShows('dm', '进行私信', '「进行私信」独立一行');
  262. pageShows('dm', '关注数量', '「关注数量」独立一行');
  263. pageShows('dm', '私信数量', '「私信数量」独立一行');
  264. pageShows('dm', '休息时间(秒)', '「休息时间(秒)」独立一行');
  265. pageShows('video', '停止个数', '停止个数独立一行');
  266. pageShows('video', '间隔时间(秒)', '间隔时间(秒)独立一行');
  267. pageShows('mine', '未登录', '卡密状态有默认值');
  268. /* 养号参数:两个区间 + 三个概率档位,都不能出现空框 */
  269. pageShows('nurture', '浏览时长(分钟)', '浏览时长是区间行');
  270. pageShows('nurture', '30%', '点赞概率有默认档位');
  271. pageShows('nurture', '10%', '评论概率有默认档位');
  272. pageShows('nurture', '15%', '关注概率有默认档位');
  273. pageShows('nurture', '单次操作', '单次操作是区间行');
  274. /* 新增的 ID提取功能设置 */
  275. pageShows('id', 'JP#TW#US', '提取区域填过的值仍然保留(即使当前不生效)');
  276. pageShows('id', '暂不支持', '没有承载者的项被明确标注,而不是给个能开的空开关');
  277. pageShows('id', '通杀', '语言区域有默认档位');
  278. pageShows('id', '500000', '数量门槛有默认上限');
  279. /* 四个数量门槛是「一行一个区间」,不是上下限各占一行 */
  280. pageShows('id', '关注量', '关注量有独立一行');
  281. pageShows('id', '~', '数量门槛显示成「最小值 ~ 最大值」');
  282. /* —— ID提取 · 四张脚本卡的展开内容 ——
  283. *
  284. * 早先只有脚本一有展开内容,脚本二/三/四点开是**空的** —— 用户会以为折叠箭头坏了。
  285. * 逐张**单独**展开来扫,避免"打开了 A 的卡却把 B 的内容算成它的"这种自欺。
  286. * 每一项都对着设计稿的节点核过(16:533 / 16:589)。 */
  287. console.log('');
  288. console.log('ID提取 · 四张脚本卡的展开内容:');
  289. /* v7 之后每张卡的关键控件(文案已按清语义后的版本更新)。
  290. * 注意 s4 的「关键词」不能拿来当断言词 —— 卡名「关键词视频提 ID」里就含它,
  291. * 会变成"永远通过"的假断言。 */
  292. const ID_CARD_WORDS = [
  293. { key: 's1', label: '脚本一 · 粉丝页提 ID',
  294. words: ['从哪个页面取', '主播账号', '搜索用户提 ID', '使用文本 ID'] },
  295. { key: 's2', label: '脚本二 · 评论页提 ID',
  296. words: ['视频从哪来', '再取评论者的粉丝 ID'] },
  297. { key: 's3', label: '脚本三 · 文本 UID 裂变提 ID',
  298. words: ['展开哪个列表', '裂变文本'] },
  299. { key: 's4', label: '脚本四 · 关键词视频提 ID',
  300. words: ['作者的粉丝 ID', '评论区里的 ID', '评论者的粉丝 ID'] }
  301. ];
  302. function expandOnlyId(key) {
  303. ['s1', 's2', 's3', 's4'].forEach((k) => {
  304. api.Store.set('tabs.id.scripts.' + k + '.expanded', k === key);
  305. });
  306. }
  307. ID_CARD_WORDS.forEach((card) => {
  308. expandOnlyId(card.key);
  309. const joined = textsOf('id').join(' | ');
  310. const missing = card.words.filter((w) => joined.indexOf(w) < 0);
  311. if (missing.length === 0) {
  312. pass(`${card.label} 展开后 ${card.words.length} 项设置齐全`);
  313. } else {
  314. fail(`${card.label} 展开后缺少:${missing.join('、')}`);
  315. }
  316. });
  317. /* 每张卡的门槛:展开的那张有内容,其余三张不该把内容露出来 */
  318. check('同一时刻只渲染展开那张卡的设置区',
  319. (() => {
  320. expandOnlyId('s2');
  321. const only2 = textsOf('id').join(' | ');
  322. expandOnlyId('s4');
  323. const only4 = textsOf('id').join(' | ');
  324. return only2.indexOf('视频从哪来') >= 0 && only4.indexOf('视频从哪来') < 0;
  325. })());
  326. /* —— 开关与它右侧控件的联动:「关掉后右侧不该还留着」——
  327. * 写起来最容易漏的一处:只 addView、不处理关闭分支,用户会以为没关掉。
  328. * v7 之后脚本一里带右侧控件的是「搜索用户提 ID」(右侧是关键词输入框)。
  329. *
  330. * ⚠️ 判据用 `W.pendingFields`(本页注册过的输入控件)而不是界面文本:
  331. * 行内输入框的 placeholder 是**画出来的**,不是 TextView 的 text,
  332. * 按文本找永远找不到 —— 那样会写出"永远失败"或"永远通过"的假断言。 */
  333. expandOnlyId('s1');
  334. api.W.beginPage();
  335. api.Store.set('tabs.id.scripts.s1.customPage', true);
  336. api.Pages.id(() => { });
  337. check('开着「搜索用户提 ID」时关键词输入框被创建',
  338. api.W.pendingFields['tabs.id.scripts.s1.customPageKeyword'] !== undefined);
  339. api.W.beginPage();
  340. api.Store.set('tabs.id.scripts.s1.customPage', false);
  341. api.Pages.id(() => { });
  342. check('关掉后关键词输入框不再被创建',
  343. api.W.pendingFields['tabs.id.scripts.s1.customPageKeyword'] === undefined);
  344. /* 脚本三的「展开哪个列表」默认是**关注列表**,与脚本一的「粉丝页」不同(设计稿的取值)。
  345. * 这类"同一类控件在不同卡片上默认值不同"最容易写成共用一个默认值。 */
  346. expandOnlyId('s3');
  347. check('脚本三的「展开哪个列表」默认「关注列表」',
  348. textsOf('id').join(' | ').indexOf('关注列表') >= 0,
  349. textsOf('id').join(' | ').slice(0, 200));
  350. expandOnlyId('s1');
  351. check('脚本一的「从哪个页面取」默认「粉丝页」',
  352. textsOf('id').join(' | ').indexOf('粉丝页') >= 0);
  353. /* 脚本四的关键词有可直接跑的业务默认值(不是空框) */
  354. expandOnlyId('s4');
  355. check('脚本四的「关键词」有默认值',
  356. textsOf('id').join(' | ').indexOf(api.Store.getText('tabs.id.scripts.s4.keyword')) >= 0,
  357. api.Store.getText('tabs.id.scripts.s4.keyword'));
  358. /* —— 滚动指引的位置(2026-10-13 用户指定)——
  359. *
  360. * 它必须落在「脚本四」之后、「功能设置」标题之前:它指向的就是紧接着下面
  361. * 那一屏内容,两条断言缺一不可 —— 只断言"在功能设置之前"的话,
  362. * 把它挪到最顶上也能通过,但那已经不是"脚本四后面"了。
  363. *
  364. * 比对用的是**渲染顺序下标**(DFS 顺序 = 屏幕上从上到下),
  365. * 且「功能设置」取的是**标题元素本身**而不是 indexOf 子串 ——
  366. * 指引文案里也含「功能设置」四个字,用子串会指到指引自己身上。 */
  367. check('滚动指引位于脚本四之后、功能设置标题之前',
  368. (() => {
  369. const items = [];
  370. collect(api.Pages.id(() => { }), items, 'id', 0);
  371. const texts = items.map((x) => x.text);
  372. const hintAt = texts.indexOf('↓ 下方还有「功能设置」,向下滚动查看');
  373. const card4At = texts.indexOf('脚本四 · 关键词视频提 ID');
  374. const titleAt = texts.indexOf('功能设置');
  375. return hintAt > card4At && hintAt < titleAt;
  376. })(),
  377. '顺序应为:脚本四 → 滚动指引 → 功能设置标题');
  378. /* ============================================================ v6 改版 */
  379. /* 2026-10-13 规格书:底栏 5 页签 → 3 tab,四个任务降级为任务卡 + 二级页。
  380. * 这一节专门盯改版新增的三样东西:任务卡、运行记录、v5→v6 迁移。 */
  381. console.log('');
  382. console.log('v6 改版(任务卡 / 运行记录 / 迁移):');
  383. /* v7 清语义:脚本一原来的两项变成「删一个、改一个」——
  384. * · 「自定关注或粉丝页面提 ID」与「从哪个页面取」说的是同一件事 → 删掉;
  385. * · 「自定页面提 ID」勾上却没说搜什么 → 补关键词并改名「搜索用户提 ID」。 */
  386. expandOnlyId('s1');
  387. const s1Texts = textsOf('id').join(' | ');
  388. check('v7 删掉了与「从哪个页面取」重复的「自定关注或粉丝页面提 ID」',
  389. s1Texts.indexOf('自定关注或粉丝页面提 ID') < 0);
  390. check('「自定页面提 ID」改名为「搜索用户提 ID」', s1Texts.indexOf('搜索用户提 ID') >= 0);
  391. check('「搜索用户提 ID」默认关(要让用户主动开)',
  392. api.Store.defaultOf('tabs.id.scripts.s1.customPage') === false);
  393. check('它的关键词留空(没配就是没配,不塞一个假默认值)',
  394. api.Store.defaultOf('tabs.id.scripts.s1.customPageKeyword') === '');
  395. expandOnlyId('s1');
  396. /* —— 标题区只能出现一次 ——
  397. * 真机踩过:页面函数里画了一次、路由层又加了一次,界面上就是两个「飞舟脚本」。
  398. * 这类重复在桩测里不会抛错,只能靠数次数。 */
  399. /* 数标题次数需要真的搭一次界面骨架(App.contentHost 是挂载时才有的) */
  400. if (!api.App.contentHost) {
  401. api.App.mount(api.App.build());
  402. api.Dialog.mount(api.App.overlayHost);
  403. api.App.screen = 'app'; /* 不走登录页分支,否则 contentHost 永远没机会建 */
  404. api.App.renderScreen();
  405. }
  406. ['tasks', 'records', 'mine'].forEach((tabKey) => {
  407. api.App.detail = '';
  408. api.App.recordId = '';
  409. api.App.activeTab = tabKey;
  410. api.App.renderContent();
  411. const heads = collectText(api.App.contentHost).filter((t) => t === '飞舟脚本').length;
  412. check(`[${PAGE_TITLE[tabKey]}] 页面标题只渲染一次`, heads === 1, `出现 ${heads} 次`);
  413. });
  414. /* —— 任务卡:四张,且每张都有图标、颜色、状态徽标 —— */
  415. const taskCards = api.Pages.TASKS;
  416. check('任务卡数量为 4', taskCards.length === 4, `期望 4,实际 ${taskCards.length}`);
  417. check('每张任务卡都有名字/图标/颜色',
  418. taskCards.every((t) => t.name && t.icon && t.colorHex),
  419. JSON.stringify(taskCards.map((t) => t.key)));
  420. check('任务卡的图标都是几何块字符(不是 emoji 码位)',
  421. taskCards.every((t) => t.icon.charCodeAt(0) >= 0x2500 && t.icon.charCodeAt(0) <= 0x25FF),
  422. taskCards.map((t) => t.icon).join(','));
  423. /* 状态徽标三态:未配置(没选脚本)/ 空闲(选了没跑)/ 运行中 */
  424. api.Store.set('tabs.id.scripts.s1.checked', false);
  425. check('没选脚本时任务卡是「未配置」', api.Pages.taskState('id').badge === '未配置', `期望 '未配置',实际 ${api.Pages.taskState('id').badge}`);
  426. api.Store.set('tabs.id.scripts.s1.checked', true);
  427. check('选了脚本没跑时是「空闲」', api.Pages.taskState('id').badge === '空闲', `期望 '空闲',实际 ${api.Pages.taskState('id').badge}`);
  428. const wasRunning = api.Session.running;
  429. api.Session.start('id', 's1', '脚本一 · 粉丝页提 ID', 'run-audit');
  430. check('正在跑的那个任务是「运行中」', api.Pages.taskState('id').badge === '运行中', `期望 '运行中',实际 ${api.Pages.taskState('id').badge}`);
  431. check('别的任务不受影响(仍然空闲)', api.Pages.taskState('dm').badge === '空闲', `期望 '空闲',实际 ${api.Pages.taskState('dm').badge}`);
  432. api.Session.stop();
  433. if (wasRunning) { api.Session.start('id', 's1', '脚本一 · 粉丝页提 ID', 'run-audit'); }
  434. /* 任务卡的「当前:xxx」跟着选中脚本走 —— 这条守着「两张表拼起来」那段逻辑 */
  435. check('任务卡的当前脚本行来自选中脚本',
  436. api.Pages.taskScriptLine('id') === '当前:脚本一 · 粉丝页提 ID',
  437. api.Pages.taskScriptLine('id'));
  438. api.Store.set('tabs.id.scripts.s1.checked', false);
  439. check('一个都没选时如实说「未选择脚本」', api.Pages.taskScriptLine('id') === '当前:未选择脚本', `期望 '当前:未选择脚本',实际 ${api.Pages.taskScriptLine('id')}`);
  440. api.Store.set('tabs.id.scripts.s1.checked', true);
  441. /* —— 任务列表页渲染出来的是四张卡,且没有 undefined —— */
  442. const tasksTexts = textsOf('tasks').join(' | ');
  443. check('任务列表渲染出四张任务卡',
  444. ['ID提取', 'UID私信', '养号', '视频功能'].every((n) => tasksTexts.indexOf(n) >= 0),
  445. tasksTexts.slice(0, 160));
  446. check('任务列表写明「全局单跑」这条规则', tasksTexts.indexOf('全局单跑') >= 0);
  447. check('没有记录时任务卡说「还未运行过」', tasksTexts.indexOf('还未运行过') >= 0);
  448. /* —— 运行记录:空态与列表是**同一页的两种状态** —— */
  449. api.Records.clear();
  450. const emptyTexts = textsOf('records').join(' | ');
  451. check('没有记录时渲染空态', emptyTexts.indexOf('还没有运行记录') >= 0, emptyTexts.slice(0, 160));
  452. api.Records.add({
  453. taskKey: 'id', taskName: 'ID提取', scriptKey: 's1', scriptName: '脚本一 · 粉丝页提 ID',
  454. result: 'success', startedAt: Date.now(), endedAt: Date.now(), durationMs: 750000,
  455. resultText: '提取 128 个 ID', resultFile: 'uid_20261013.txt',
  456. logs: [{ t: Date.now(), level: 'INFO', text: '[启动脚本] 启动 · 粉丝页提 ID' }]
  457. });
  458. const listTexts = textsOf('records').join(' | ');
  459. check('有记录时渲染列表而不是空态', listTexts.indexOf('还没有运行记录') < 0);
  460. check('记录页写明条数', listTexts.indexOf('共 1 条') >= 0, listTexts.slice(0, 160));
  461. check('记录条目显示「任务 · 脚本」与结果标签',
  462. listTexts.indexOf('ID提取 · 脚本一') >= 0 && listTexts.indexOf('已完成') >= 0);
  463. /* D3 运行详情:摘要卡 + 日志卡(日志是**真实回传**的那一条) */
  464. const oneRecord = api.Records.list()[0];
  465. const detailTexts = collectText(api.Pages.recordDetail(oneRecord)).join(' | ');
  466. check('运行详情显示结果文件与文件名', detailTexts.indexOf('uid_20261013.txt') >= 0, detailTexts.slice(0, 200));
  467. check('运行详情显示真实日志行', detailTexts.indexOf('[启动脚本] 启动 · 粉丝页提 ID') >= 0);
  468. check('运行详情有「执行日志」标题与「复制」', detailTexts.indexOf('执行日志') >= 0 && detailTexts.indexOf('复制') >= 0);
  469. /* 记录被清理掉(id 不存在)时不能白屏 */
  470. const goneTexts = collectText(api.Pages.recordDetail(null)).join(' | ');
  471. check('记录已不在本地时显示说明而不是白屏', goneTexts.indexOf('这条记录已不在本地') >= 0);
  472. /* 记录的持久化:模拟重启后仍在(storages 里真的写进去了) */
  473. const recordsRaw = env.memoryStorage[api.Records.STORAGE_NAME][api.Records.STORAGE_KEY];
  474. check('运行记录已落盘', typeof recordsRaw === 'string' && recordsRaw.indexOf('uid_20261013.txt') >= 0);
  475. api.Records.clear();
  476. check('清空后记录为空', api.Records.list().length === 0, `期望 0,实际 ${api.Records.list().length}`);
  477. /* 脏记录(缺 id / 不是对象)必须被丢弃,否则 D1 上会出现点不开的行 */
  478. env.memoryStorage[api.Records.STORAGE_NAME][api.Records.STORAGE_KEY] =
  479. JSON.stringify([{ taskKey: 'id', taskName: 'X' }, null, { id: 'ok', taskKey: 'video' }, 'junk']);
  480. check('脏记录被丢弃,只剩合法条目', api.Records.list().length === 1, `期望 1,实际 ${api.Records.list().length}`);
  481. api.Records.clear();
  482. /* —— v5 → v6 迁移:老用户的 activeTab 必须被映射,配置一条不丢 —— */
  483. const v5 = api.Store.validate({
  484. version: 5,
  485. activeTab: 'nurture',
  486. tabs: { nurture: { browseMin: 5, browseMax: 9 } }
  487. });
  488. check('v5 的页签 nurture → tasks', v5.config.activeTab === 'tasks', `期望 'tasks',实际 ${v5.config.activeTab}`);
  489. check('v5 的老页签被映射成二级页(直接停在他原来那个任务上)', v5.config.activeDetail === 'nurture', `期望 'nurture',实际 ${v5.config.activeDetail}`);
  490. check('迁移会说明它做了什么',
  491. v5.problems.some((p) => p.indexOf('activeTab') >= 0 && p.indexOf('tasks') >= 0),
  492. v5.problems.join(' | '));
  493. check('老页签的业务配置一条没丢', v5.config.tabs.nurture.browseMin === 5, `期望 5,实际 ${v5.config.tabs.nurture.browseMin}`);
  494. const v5mine = api.Store.validate({ version: 5, activeTab: 'mine' });
  495. check('v5 的「我的」保持不变', v5mine.config.activeTab === 'mine', `期望 'mine',实际 ${v5mine.config.activeTab}`);
  496. check('「我的」不需要映射二级页', v5mine.config.activeDetail === '', `期望 '',实际 ${v5mine.config.activeDetail}`);
  497. const v5bad = api.Store.validate({ version: 5, activeTab: '已删除的页签' });
  498. check('不认识的老页签回退到「任务」', v5bad.config.activeTab === 'tasks', `期望 'tasks',实际 ${v5bad.config.activeTab}`);
  499. check('不认识的老页签不带二级页', v5bad.config.activeDetail === '', `期望 '',实际 ${v5bad.config.activeDetail}`);
  500. /* —— v6 → v7:ID提取清语义的三条迁移 ——
  501. * 单独造一份 v6 存档来测,因为场景 B 的脏数据里已经不再带 `dedupeEnabled`
  502. * (那是被删掉的字段),只有这里能验证"旧值真的被继承,而不是丢掉、也不是静默改"。 */
  503. const v6id = api.Store.validate({
  504. version: 6,
  505. tabs: {
  506. id: {
  507. scripts: {
  508. s1: { dedupeEnabled: false, dedupe: 'text' },
  509. s3: { enterPage: 'comment' }
  510. }
  511. }
  512. }
  513. });
  514. check('v7:「主播去重」旧值 false 已继承到「本地去重」',
  515. v6id.config.tabs.id.features.dedupeLocal === false,
  516. `实际 ${v6id.config.tabs.id.features.dedupeLocal}`);
  517. check('v7:旧去重模式「UID 模式」→「跨轮去重」打开',
  518. v6id.config.tabs.id.features.dedupeHistory === true,
  519. `实际 ${v6id.config.tabs.id.features.dedupeHistory}`);
  520. check('v7:脚本三旧「评论页」→「关注列表」',
  521. v6id.config.tabs.id.scripts.s3.enterPage === 'follow',
  522. `实际 ${v6id.config.tabs.id.scripts.s3.enterPage}`);
  523. check('v7 的每一条迁移都写进了 problems(不静默改用户配置)',
  524. v6id.problems.filter((p) => p.indexOf('本地去重') >= 0).length === 1 &&
  525. v6id.problems.filter((p) => p.indexOf('跨轮去重') >= 0).length === 1 &&
  526. v6id.problems.filter((p) => p.indexOf('关注列表') >= 0).length === 1,
  527. v6id.problems.join(' | '));
  528. /* 复位成默认展开态(只有脚本一展开) */
  529. expandOnlyId('s1');
  530. /* ============================================================ 悬浮球 */
  531. /* 规格书 §10 / H1–H7。设备端能建窗,桩里建不了 —— 所以这一节验的是
  532. * **可离线判定的那部分**:显示条件、位置记忆与夹紧、开关双向、以及"建不出来也不许崩"。 */
  533. console.log('');
  534. console.log('悬浮球(§10):');
  535. check('悬浮球默认显示(未隐藏)', api.Store.defaultOf('ball.hidden') === false);
  536. check('悬浮球默认贴左边', api.Store.defaultOf('ball.side') === 'left');
  537. check('悬浮球默认垂直居中(比例 500)', api.Store.defaultOf('ball.yRatio') === 500);
  538. /* 显示条件:卡密有效 + 悬浮窗权限 + 用户没隐藏,三者缺一不可 */
  539. api.Store.set('license.status', 'active');
  540. api.Store.set('ball.hidden', false);
  541. globalThis.floaty.checkPermission = () => true;
  542. check('三条件齐备时才显示悬浮球', api.FB.canShow() === true);
  543. globalThis.floaty.checkPermission = () => false;
  544. check('缺悬浮窗权限 → 不显示', api.FB.canShow() === false);
  545. globalThis.floaty.checkPermission = () => true;
  546. api.Store.set('ball.hidden', true);
  547. check('用户主动隐藏 → 不显示', api.FB.canShow() === false);
  548. api.Store.set('license.status', 'none');
  549. api.Store.set('ball.hidden', false);
  550. check('卡密无效 → 不显示(登录页不该有球)', api.FB.canShow() === false);
  551. api.Store.set('license.status', 'active');
  552. /* 「隐藏」是双向的:关掉能建、打开能收(没有恢复入口的话用户就找不回来了) */
  553. api.FB.setHidden(true);
  554. check('关掉开关 → 落盘 hidden=true', api.Store.getBool('ball.hidden') === true);
  555. api.FB.setHidden(false);
  556. check('打开开关 → 落盘 hidden=false', api.Store.getBool('ball.hidden') === false);
  557. /* 位置记忆:存比例,换机型也不会把球丢到屏幕外 */
  558. api.Store.set('ball.side', 'right');
  559. api.Store.set('ball.yRatio', 250);
  560. api.FB.restoreAnchor();
  561. check('右侧 + 25% 位置能正确还原',
  562. api.FB.side === 'right' && api.FB.anchorCy === Math.round(api.Device.screenHeight() * 0.25),
  563. api.FB.side + ' / ' + api.FB.anchorCy);
  564. check('还原后球在屏幕内(X 靠右边缘)',
  565. api.FB.anchorX > 0 && api.FB.anchorX < api.Device.screenWidth());
  566. /* 越界/脏数据必须被夹回来:手改配置或换分辨率后,球不能跑到屏幕外 */
  567. api.Store.set('ball.yRatio', 990);
  568. api.FB.restoreAnchor();
  569. check('极端比例也不会把球顶出屏幕',
  570. api.FB.anchorCy > 0 && api.FB.anchorCy <= api.Device.screenHeight(), String(api.FB.anchorCy));
  571. api.FB.anchorX = -500;
  572. api.FB.clampAnchor();
  573. check('坐标被夹到屏幕内', api.FB.anchorX >= 0, String(api.FB.anchorX));
  574. api.FB.destroy();
  575. check('没建过窗时 destroy() 也不报错(幂等)', api.FB.window === null);
  576. /* 建窗失败必须被吞掉 —— 这条守的是「猜一个不存在的 API → 整个界面起不来」那类事故。
  577. * 悬浮球现在走**裸 WindowManager**(不是 floaty),所以这里显式让 addView 抛错来造这个场景。
  578. * 被测的不变量是「异常不许冒到调用方、也不许假装成功」。 */
  579. const realGetSystemService = globalThis.context.getSystemService;
  580. globalThis.context.getSystemService = (name) => {
  581. const svc = realGetSystemService(name);
  582. if (name === 'window' && svc) {
  583. return { addView: () => { throw new Error('模拟:设备不支持悬浮窗'); },
  584. updateViewLayout: svc.updateViewLayout, removeView: svc.removeView };
  585. }
  586. return svc;
  587. };
  588. globalThis.floaty.checkPermission = () => false;
  589. api.Store.set('ball.yRatio', 500);
  590. api.Store.set('ball.side', 'left');
  591. /* 我的页必须有悬浮球的恢复入口,且权限缺失时要说清原因 */
  592. const mineTexts = textsOf('mine').join(' | ');
  593. check('我的页有「悬浮球」开关', mineTexts.indexOf('悬浮球') >= 0, mineTexts.slice(0, 160));
  594. /* —— 无记录 / 无日志时界面绝不显示 undefined —— */
  595. const bareTexts = collectText(api.Pages.recordDetail({ id: 'x', taskKey: 'id', result: 'stopped' })).join(' | ');
  596. check('记录字段缺失时渲染不出现 undefined',
  597. bareTexts.indexOf('undefined') < 0 && bareTexts.indexOf('null') < 0, bareTexts.slice(0, 200));
  598. expandOnlyId('s1');
  599. /* 区间行的结构断言:每个区间行必须是「标签 + 两个数字胶囊 + 一个 ~」 */
  600. console.log('');
  601. console.log('区间行结构:');
  602. function rangeRowShape(pageKey, label) {
  603. /* 找到带该标签的那一行,数一数它下面有几个纯数字胶囊 */
  604. const rows = [];
  605. (function walk(view) {
  606. if (!view) { return; }
  607. const t = typeof view.getText === 'function' ? String(view.getText()) : '';
  608. if (t === label) { rows.push(view.parent); }
  609. const n = typeof view.getChildCount === 'function' ? view.getChildCount() : 0;
  610. for (let i = 0; i < n; i++) { walk(view.getChildAt(i)); }
  611. })(renderPage(pageKey));
  612. if (rows.length === 0) { fail(`[${pageKey}] 找不到「${label}」这一行`); return; }
  613. const nums = [];
  614. for (let i = 0; i < rows[0].getChildCount(); i++) {
  615. const c = rows[0].getChildAt(i);
  616. /* 同一行里除了两个数字胶囊,还有标签、~ 分隔符、弹性占位 View(没有 getText) */
  617. const t = typeof c.getText === 'function' ? String(c.getText()) : '';
  618. if (/^\d+$/.test(t)) { nums.push(t); }
  619. }
  620. check(`「${label}」是一行两项(实际数字胶囊 ${nums.length} 个)`, nums.length === 2,
  621. nums.join(' / '));
  622. }
  623. rangeRowShape('dm', '关注数量');
  624. rangeRowShape('dm', '私信数量');
  625. rangeRowShape('dm', '休息时间(秒)');
  626. rangeRowShape('nurture', '浏览时长(分钟)');
  627. rangeRowShape('nurture', '单次操作');
  628. rangeRowShape('video', '停止个数');
  629. rangeRowShape('video', '间隔时间(秒)');
  630. rangeRowShape('id', '关注量');
  631. /* 反向断言:不应该再有任何「一个控件承载两个值」的写法。
  632. * 这类回归很容易发生 —— 加字段时顺手塞进已有的胶囊里就退回去了。 */
  633. console.log('');
  634. console.log('反向断言(不应再出现合体控件):');
  635. const COMBINED = [
  636. ['dm', '200 / 30', '关注/私信数量合体'],
  637. ['dm', '100 ~ 500', '休息时间合体'],
  638. ['video', '20 ~ 20', '间隔时间合体'],
  639. ['nurture', '10 ~ 30', '单次操作合体'],
  640. /* 这些是**上一版的旧标签**,改成区间后不该再出现 */
  641. ['dm', '关注上限', '旧标签「关注上限」应已改为「关注数量」'],
  642. ['dm', '私信上限', '旧标签「私信上限」应已改为「私信数量」'],
  643. ['dm', '休息间隔', '旧标签「休息间隔」应已改为「休息时间(秒)」'],
  644. ['nurture', '单次操作上限', '旧标签「单次操作上限」应已改为「单次操作」'],
  645. /* ID提取的数量门槛走的是「区间一行」形态,所以**不该**再出现上下限两行 */
  646. ['id', '关注量下限', '数量门槛又被拆成上下限两行'],
  647. ['id', '粉丝量上限', '数量门槛又被拆成上下限两行']
  648. ];
  649. COMBINED.forEach(([page, needle, why]) => {
  650. const all = textsOf(page).join(' | ');
  651. check(`不再出现「${needle}」(${why})`, all.indexOf(needle) < 0);
  652. });
  653. /* ============================================================ 场景 C */
  654. console.log('');
  655. console.log('=== 场景 C:字段表完整性 ===');
  656. console.log('');
  657. const defaults = api.Store.defaults();
  658. /* ① 每个字段的默认值必须与声明的类型相符 */
  659. let typeErrors = 0;
  660. api.Store.FIELDS.forEach((f) => {
  661. const value = f.path.split('.').reduce((node, seg) => (node === undefined ? undefined : node[seg]), defaults);
  662. let ok = true;
  663. if (f.type === 'bool') { ok = typeof value === 'boolean'; }
  664. else if (f.type === 'int') { ok = typeof value === 'number' && !isNaN(value) && value >= f.min && value <= f.max; }
  665. else if (f.type === 'enum') {
  666. const keys = f.values || Object.keys(api.Store.enum[f.group] || {});
  667. ok = keys.indexOf(value) >= 0;
  668. } else { ok = typeof value === 'string'; }
  669. if (!ok) {
  670. typeErrors += 1;
  671. fail(`字段 ${f.path} 的默认值 ${JSON.stringify(value)} 与类型 ${f.type} 不符`);
  672. }
  673. });
  674. if (typeErrors === 0) { pass(`${api.Store.FIELDS.length} 个字段的默认值全部与声明类型相符`); }
  675. /* ② 文本类型的字段默认值不应为空 —— 空串就是「没有默认值」。
  676. * 例外是显式标了 optional 的字段:它们的空串是真实且合理的状态
  677. * (例如「云端去重的表名」,功能默认关着,没填就是没填)。 */
  678. const emptyDefaults = api.Store.FIELDS
  679. .filter((f) => f.type === 'text' && !f.optional &&
  680. f.path.indexOf('license.') !== 0 && f.def === '')
  681. .map((f) => f.path);
  682. if (emptyDefaults.length === 0) {
  683. pass('除卡密与显式 optional 的字段外,所有文本字段都有非空默认值');
  684. } else {
  685. fail('以下文本字段默认值为空:' + emptyDefaults.join(', '));
  686. }
  687. /* ③ 默认配置必须能原样通过校验(幂等)——否则每次启动都会「修正」一遍 */
  688. const roundTrip = api.Store.validate(defaults);
  689. if (roundTrip.problems.length === 0) {
  690. pass('默认配置通过校验且不产生任何修正(幂等)');
  691. } else {
  692. fail('默认配置自身就会触发修正:' + roundTrip.problems.join(' | '));
  693. }
  694. /* ④ 序列化往返后仍相等 —— 防止存进去再读出来变了样 */
  695. const revived = api.Store.validate(JSON.parse(JSON.stringify(defaults)));
  696. if (JSON.stringify(revived.config) === JSON.stringify(defaults)) {
  697. pass('配置序列化往返后完全一致');
  698. } else {
  699. fail('配置序列化往返后发生变化');
  700. }
  701. /* ============================================================ 场景 D */
  702. console.log('');
  703. console.log('=== 场景 D:输入与弹窗交互 ===');
  704. console.log('');
  705. /* 从干净配置起步,避免前面的脏数据影响断言 */
  706. env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };
  707. api.Store.init();
  708. let sheet = null;
  709. /**
  710. * 模拟用户在该输入框里逐字输入一个新值:
  711. * 先连续退格清空(走 TextWatcher),再逐字敲进去。
  712. * 桩里的 `type` / `eraseAll` 与输入法走同一条通路,所以测的是真实交互路径 ——
  713. * 不是「直接 setText 一下」那种绕开监听器的假测法。
  714. */
  715. function typeInto(control, value) {
  716. control.root.eraseAll();
  717. control.root.type(String(value));
  718. }
  719. /** 在输入框里敲入值后失焦(= 用户改完点走)。 */
  720. function fillAndConfirmInline(control, value) {
  721. control.root.gainFocus();
  722. typeInto(control, value);
  723. control.root.loseFocus();
  724. }
  725. /* —— 直接输入:所有输入框都不再弹窗 —— */
  726. console.log('直接输入(不弹窗):');
  727. const numField = api.W.numberField('tabs.video.stopMax', '停止个数最大值');
  728. check('数字框是常驻可编辑输入框', typeof numField.root.setInputType === 'function');
  729. numField.root.performClick();
  730. check('点输入框不再弹出任何弹窗', api.Dialog.active === null);
  731. numField.root.gainFocus();
  732. typeInto(numField, '8');
  733. check('打字过程中就已写入配置', api.Store.getNumber('tabs.video.stopMax') === 8,
  734. `实际 ${api.Store.getNumber('tabs.video.stopMax')}`);
  735. check('输入全过程都没有弹窗', api.Dialog.active === null);
  736. numField.root.loseFocus();
  737. check('失焦后栏位显示的就是配置里的值', String(numField.root.getText()) === '8',
  738. `实际 "${numField.root.getText()}"`);
  739. /* —— 越界值在失焦时被夹紧并写回栏位(用户能看见被夹了) —— */
  740. console.log('');
  741. console.log('越界与清空:');
  742. const clampField = api.W.numberField('tabs.video.stopMax', '');
  743. clampField.root.gainFocus();
  744. typeInto(clampField, '99999');
  745. check('越界值写入时被字段表夹到上限', api.Store.getNumber('tabs.video.stopMax') === 500,
  746. `实际 ${api.Store.getNumber('tabs.video.stopMax')}`);
  747. clampField.root.loseFocus();
  748. check('失焦后栏位回写成夹紧后的值(不是用户敲的 99999)',
  749. String(clampField.root.getText()) === '500',
  750. `实际 "${clampField.root.getText()}"`);
  751. api.Store.set('tabs.video.stopMax', 42);
  752. const clearField = api.W.numberField('tabs.video.stopMax', '');
  753. clearField.root.gainFocus();
  754. clearField.root.eraseAll();
  755. check('清空是个中间态,不把配置冲成 0', api.Store.getNumber('tabs.video.stopMax') === 42,
  756. `实际 ${api.Store.getNumber('tabs.video.stopMax')}`);
  757. clearField.root.loseFocus();
  758. check('失焦后栏位恢复成配置里的原值', String(clearField.root.getText()) === '42',
  759. `实际 "${clearField.root.getText()}"`);
  760. /* —— 关注 / 私信 是两个独立开关(原先一个开关管两件事)—— */
  761. console.log('');
  762. console.log('关注与私信是否真正独立:');
  763. api.Store.set('tabs.dm.doFollow', true);
  764. api.Store.set('tabs.dm.doDm', true);
  765. const followToggle = api.W.toggle(api.Store.getBool('tabs.dm.doFollow'), function (on) {
  766. api.Store.set('tabs.dm.doFollow', on);
  767. });
  768. followToggle.root.performClick();
  769. check('单独关掉「进行关注」', api.Store.getBool('tabs.dm.doFollow') === false,
  770. `实际 ${api.Store.getBool('tabs.dm.doFollow')}`);
  771. check('「进行私信」不受影响,仍为开', api.Store.getBool('tabs.dm.doDm') === true,
  772. `实际 ${api.Store.getBool('tabs.dm.doDm')}`);
  773. const dmToggle = api.W.toggle(api.Store.getBool('tabs.dm.doDm'), function (on) {
  774. api.Store.set('tabs.dm.doDm', on);
  775. });
  776. dmToggle.root.performClick();
  777. check('再单独关掉「进行私信」', api.Store.getBool('tabs.dm.doDm') === false);
  778. /* —— 关注数量与私信数量是两组独立区间,互不影响 —— */
  779. console.log('');
  780. console.log('两组数量区间各自独立:');
  781. api.Store.set('tabs.dm.followMin', 100);
  782. api.Store.set('tabs.dm.followMax', 200);
  783. api.Store.set('tabs.dm.dmMin', 20);
  784. api.Store.set('tabs.dm.dmMax', 30);
  785. fillAndConfirmInline(api.W.numberField('tabs.dm.followMax', '关注数量最大值'), '1234');
  786. check('关注数量上限写入', api.Store.getNumber('tabs.dm.followMax') === 1234,
  787. `实际 ${api.Store.getNumber('tabs.dm.followMax')}`);
  788. check('私信数量不受影响',
  789. api.Store.getNumber('tabs.dm.dmMin') === 20 && api.Store.getNumber('tabs.dm.dmMax') === 30,
  790. `实际 ${api.Store.getNumber('tabs.dm.dmMin')} ~ ${api.Store.getNumber('tabs.dm.dmMax')}`);
  791. fillAndConfirmInline(api.W.numberField('tabs.dm.dmMax', '私信数量最大值'), '56');
  792. check('私信数量上限写入且关注数量不变',
  793. api.Store.getNumber('tabs.dm.dmMax') === 56 &&
  794. api.Store.getNumber('tabs.dm.followMax') === 1234,
  795. `实际 关注 ${api.Store.getNumber('tabs.dm.followMax')} / 私信 ${api.Store.getNumber('tabs.dm.dmMax')}`);
  796. /* —— 区间联动:下界超过上界时自动抬齐 —— */
  797. console.log('');
  798. console.log('区间联动:');
  799. api.Store.set('tabs.dm.restMin', 100);
  800. api.Store.set('tabs.dm.restMax', 500);
  801. fillAndConfirmInline(api.W.numberField('tabs.dm.restMin', '休息时间(秒)最小值'), '2000');
  802. check('下界写入', api.Store.getNumber('tabs.dm.restMin') === 2000,
  803. `实际 ${api.Store.getNumber('tabs.dm.restMin')}`);
  804. check('下界超过上界时,上界被自动抬齐',
  805. api.Store.getNumber('tabs.dm.restMax') === 2000,
  806. `实际 ${api.Store.getNumber('tabs.dm.restMin')} ~ ${api.Store.getNumber('tabs.dm.restMax')}`);
  807. /* —— 区间行:一行一个区间,两个数各自在栏位里直接改 —— */
  808. console.log('');
  809. console.log('区间行(最小值 ~ 最大值):');
  810. api.Store.set('tabs.dm.followMin', 100);
  811. api.Store.set('tabs.dm.followMax', 200);
  812. const range = api.W.rangeRow('tabs.dm.followMin', 'tabs.dm.followMax', '关注数量', '~');
  813. const rangeTexts = [];
  814. collect(range.root, rangeTexts, 'range', 0);
  815. const rangeJoined = rangeTexts.map((x) => x.text).join(' | ');
  816. check('区间行同时显示最小值与最大值',
  817. rangeJoined.indexOf('100') >= 0 && rangeJoined.indexOf('200') >= 0, rangeJoined);
  818. check('区间行有 ~ 分隔符', rangeJoined.indexOf('~') >= 0);
  819. check('区间行是一行(标签里没有「下限/上限」)',
  820. rangeJoined.indexOf('下限') < 0 && rangeJoined.indexOf('上限') < 0, rangeJoined);
  821. const numFields = findAll(range.root, isInput, [], 0);
  822. check('区间行里正好两个可独立编辑的输入框', numFields.length === 2, `实际 ${numFields.length}`);
  823. check('区间行里没有「点了会弹窗」的胶囊',
  824. findAll(range.root, (v) => !!(v.listeners && v.listeners.click), [], 0).length === 0);
  825. typeInto({ root: numFields[0] }, '50');
  826. check('改最小值只动最小值',
  827. api.Store.getNumber('tabs.dm.followMin') === 50,
  828. `实际 ${api.Store.getNumber('tabs.dm.followMin')}`);
  829. check('改最小值时最大值不受影响',
  830. api.Store.getNumber('tabs.dm.followMax') === 200,
  831. `实际 ${api.Store.getNumber('tabs.dm.followMax')}`);
  832. typeInto({ root: numFields[1] }, '150');
  833. check('改最大值只动最大值', api.Store.getNumber('tabs.dm.followMax') === 150,
  834. `实际 ${api.Store.getNumber('tabs.dm.followMax')}`);
  835. check('改最大值时最小值不受影响', api.Store.getNumber('tabs.dm.followMin') === 50,
  836. `实际 ${api.Store.getNumber('tabs.dm.followMin')}`);
  837. /* 联动:最小值超过最大值 → 最大值被抬齐,且**兄弟那一格显示要跟着变**(不重建整页) */
  838. typeInto({ root: numFields[0] }, '999');
  839. check('最小值超过最大值时自动抬齐',
  840. api.Store.getNumber('tabs.dm.followMax') === 999,
  841. `实际 ${api.Store.getNumber('tabs.dm.followMax')}`);
  842. check('兄弟输入框的文字就地刷新(不重建整页、滚动位置不丢)',
  843. String(numFields[1].getText()) === '999',
  844. `实际 "${numFields[1].getText()}"`);
  845. check('正在输入的那一格不被回写抢走输入', String(numFields[0].getText()) === '999',
  846. `实际 "${numFields[0].getText()}"`);
  847. numFields[0].loseFocus();
  848. numFields[1].loseFocus();
  849. /* —— 非法输入:不改配置、不弹窗 —— */
  850. console.log('');
  851. console.log('非法输入:');
  852. const beforeMin = api.Store.getNumber('tabs.dm.restMin');
  853. const badField = api.W.numberField('tabs.dm.restMin', '休息时间(秒)最小值');
  854. badField.root.gainFocus();
  855. /* 数字键盘敲不出字母,但粘贴、输入法联想、外接键盘都可能把非数字送进来。
  856. * 这里模拟的是「原有文本后面被追加了非数字」,而不是先清空 —— 先清空再输
  857. * 是另一条路径(见下面的清空重填),混在一起测会掩盖各自的判定。 */
  858. badField.root.type('abc');
  859. check('非法输入不弹窗、不改配置', api.Store.getNumber('tabs.dm.restMin') === beforeMin,
  860. `实际 ${api.Store.getNumber('tabs.dm.restMin')}(应为 ${beforeMin})`);
  861. check('非法输入也没有替用户清空栏位', String(badField.root.getText()).length > 0);
  862. badField.root.loseFocus();
  863. check('失焦后恢复成配置里的值', String(badField.root.getText()) === String(beforeMin),
  864. `实际 "${badField.root.getText()}"`);
  865. /* —— 清空后重填:中途不会把配置留在半截数字上 —— */
  866. console.log('');
  867. console.log('清空重填:');
  868. api.Store.set('tabs.dm.restMin', 100);
  869. const retypeField = api.W.numberField('tabs.dm.restMin', '休息时间(秒)最小值');
  870. retypeField.root.gainFocus();
  871. retypeField.root.eraseAll();
  872. retypeField.root.type('250');
  873. check('清空重填后落在新值上', api.Store.getNumber('tabs.dm.restMin') === 250,
  874. `实际 ${api.Store.getNumber('tabs.dm.restMin')}`);
  875. retypeField.root.loseFocus();
  876. check('栏位与配置一致', String(retypeField.root.getText()) === '250',
  877. `实际 "${retypeField.root.getText()}"`);
  878. /* —— 没失焦就走了:编辑不能丢 —— */
  879. console.log('');
  880. console.log('未失焦就离开输入框:');
  881. api.Store.set('tabs.dm.restMin', 10); /* 先给下界留出空间,否则提交时会被联动抬齐 */
  882. api.Store.set('tabs.dm.restMax', 500);
  883. api.W.beginPage(); /* ≙ 本页开始渲染(真机上由 renderContent 调用) */
  884. const bsField = api.W.numberField('tabs.dm.restMax', '');
  885. bsField.root.gainFocus();
  886. bsField.root.backspace(); /* 500 → 50 */
  887. check('退格期间不立刻落盘(避免 500→50→5 这类半截值被写进配置)',
  888. api.Store.getNumber('tabs.dm.restMax') === 500,
  889. `实际 ${api.Store.getNumber('tabs.dm.restMax')}`);
  890. check('但栏位文字已经是用户改后的 50', String(bsField.root.getText()) === '50',
  891. `实际 "${bsField.root.getText()}"`);
  892. api.W.commitPendingInputs(); /* ≙ 用户没失焦就点了别处(切页签 / 开始运行) */
  893. check('离开前整体提交,这次退格不会丢',
  894. api.Store.getNumber('tabs.dm.restMax') === 50,
  895. `实际 ${api.Store.getNumber('tabs.dm.restMax')}`);
  896. /* —— 单选列表 —— */
  897. console.log('');
  898. console.log('单选列表:');
  899. /* 用 v7 新增的 expandList(只有粉丝/关注两值)验单选窗:
  900. * 默认是「关注列表」,这里点「粉丝列表」写成 fans,能真实反映写入。 */
  901. const dropdown = api.W.dropdown('expandList', 'tabs.id.scripts.s3.enterPage');
  902. dropdown.root.performClick();
  903. sheet = api.Dialog.active;
  904. const allOptions = findAll(sheet.root, textIs('关注列表'), [], 0);
  905. const targetOption = findAll(sheet.root, textIs('粉丝列表'), [], 0);
  906. check('选择窗列出了全部候选', allOptions.length === 1 && targetOption.length === 1);
  907. if (targetOption.length === 1) { targetOption[0].performClick(); }
  908. check('选中项写回配置', api.Store.get('tabs.id.scripts.s3.enterPage') === 'fans',
  909. `实际 "${api.Store.get('tabs.id.scripts.s3.enterPage')}"`);
  910. check('选择后弹窗关闭', api.Dialog.active === null);
  911. /* 复原,免得影响后面的用例 */
  912. api.Store.set('tabs.id.scripts.s3.enterPage', 'follow');
  913. /* —— 文本输入:同样直接在栏位里改 —— */
  914. console.log('');
  915. console.log('文本输入:');
  916. const textField = api.W.textField('tabs.video.keyword', '搜索');
  917. textField.root.performClick();
  918. check('点文本框不弹窗', api.Dialog.active === null);
  919. textField.root.gainFocus();
  920. textField.root.eraseAll();
  921. textField.root.type('travel');
  922. check('文本打字即写入配置', api.Store.getText('tabs.video.keyword') === 'travel',
  923. `实际 "${api.Store.getText('tabs.video.keyword')}"`);
  924. check('文本输入全程无弹窗', api.Dialog.active === null);
  925. textField.root.loseFocus();
  926. check('失焦后栏位与配置一致', String(textField.root.getText()) === 'travel',
  927. `实际 "${textField.root.getText()}"`);
  928. /* 文本字段允许空串(合法的「不填」),所以清空就是清空,不该回填默认值 */
  929. textField.root.gainFocus();
  930. textField.root.eraseAll();
  931. textField.root.loseFocus();
  932. check('文本清空后确实为空(文本字段允许留空)',
  933. api.Store.getText('tabs.video.keyword') === '',
  934. `实际 "${api.Store.getText('tabs.video.keyword')}"`);
  935. /* —— 弹窗唯一性:同时只允许一个 —— */
  936. console.log('');
  937. console.log('弹窗唯一性:');
  938. api.Dialog.alert('测试', '第一个');
  939. const first = api.Dialog.active;
  940. api.Dialog.alert('测试', '第二个');
  941. check('打开新弹窗会自动关掉上一个', api.Dialog.active !== first);
  942. if (api.Dialog.active) { api.Dialog.active.close(); }
  943. check('关闭后没有残留弹窗', api.Dialog.active === null);
  944. /* ============================================================ 场景 E */
  945. console.log('');
  946. console.log('=== 场景 E:登录页三态与运行状态条 ===');
  947. console.log('');
  948. const VIEW = api.Pages.LICENSE_VIEW;
  949. function loginTexts() {
  950. const items = [];
  951. collect(api.Pages.login(), items, 'login', 0);
  952. return items.map((x) => x.text).join(' | ');
  953. }
  954. VIEW.state = 'idle'; VIEW.message = '';
  955. api.Store.set('license.draftCode', '');
  956. check('idle 态提示「卡密为一次性激活凭证…」',
  957. loginTexts().indexOf('卡密为一次性激活凭证,请勿泄露给他人') >= 0);
  958. check('idle 态按钮是「登录激活」', loginTexts().indexOf('登录激活') >= 0);
  959. /* 卡密现在**存在配置里**(`license.draftCode`),不再是内存里的 LICENSE_VIEW.code ——
  960. * 校验中重绘一次界面,输入框要能重新填出刚才那串,否则用户会以为白打了。 */
  961. VIEW.state = 'verifying';
  962. api.Store.set('license.draftCode', 'TK9F-2A7C-8E10-4B55');
  963. const verifying = loginTexts();
  964. check('verifying 态按钮变「登录中…」', verifying.indexOf('登录中…') >= 0);
  965. check('verifying 态提示变「正在校验卡密,请稍候…」',
  966. verifying.indexOf('正在校验卡密,请稍候…') >= 0);
  967. check('verifying 态保留已输入的卡密(重绘后输入框仍是那串)',
  968. verifying.indexOf('TK9F-2A7C-8E10-4B55') >= 0);
  969. VIEW.state = 'failed'; VIEW.message = '卡密无效或已过期,请检查后重试';
  970. const failed = loginTexts();
  971. check('failed 态显示失败原因', failed.indexOf('卡密无效或已过期,请检查后重试') >= 0);
  972. check('failed 态仍可再次提交(按钮回到「登录激活」)', failed.indexOf('登录激活') >= 0);
  973. scanLogin('failed');
  974. api.Pages.LICENSE_RESET();
  975. check('复位后回到 idle,且草稿保留(下次进来不用重打)',
  976. VIEW.state === 'idle' && VIEW.message === '' &&
  977. api.Store.getText('license.draftCode') === 'TK9F-2A7C-8E10-4B55',
  978. `state=${VIEW.state} draft="${api.Store.getText('license.draftCode')}"`);
  979. api.Store.set('license.draftCode', '');
  980. /* 运行状态条:文案与计时格式 */
  981. const bar = api.W.runBar('脚本一 · 运行中', '00:00:00');
  982. const barTexts = [];
  983. collect(bar.root, barTexts, 'bar', 0);
  984. const barJoined = barTexts.map((x) => x.text).join(' | ');
  985. check('运行状态条含脚本名与时长',
  986. barJoined.indexOf('脚本一 · 运行中') >= 0 && barJoined.indexOf('00:00:00') >= 0);
  987. bar.setElapsed('05:06:07');
  988. const barTexts2 = [];
  989. collect(bar.root, barTexts2, 'bar', 0);
  990. check('状态条可就地刷新时长(不重建整页)',
  991. barTexts2.map((x) => x.text).join(' | ').indexOf('05:06:07') >= 0);
  992. /* 时长格式化:超过 24 小时不回绕,否则长跑会显示成"刚开始" */
  993. check('时长格式化为 01:02:05',
  994. api.Session.formatElapsed(3725000) === '01:02:05',
  995. api.Session.formatElapsed(3725000));
  996. check('时长超过 24 小时不回绕',
  997. api.Session.formatElapsed(90061000) === '25:01:01',
  998. api.Session.formatElapsed(90061000));
  999. check('未运行时长为 0', api.Session.elapsedMs() === 0);
  1000. /* ============================================================ 场景 F */
  1001. console.log('');
  1002. console.log('=== 场景 F:所有可输入 / 可选择的控件都要落盘,且重启后自动读回 ===');
  1003. console.log('');
  1004. env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };
  1005. api.Store.init();
  1006. /* —— F1. 界面上绑定的每个路径,都必须在字段表里 ——
  1007. *
  1008. * 这是最隐蔽的一类「改了但存不住」:路径拼错(例如 `tabs.video.stopCount` 写成
  1009. * `stopCounts`)时 `Store.set` 照样写进内存,但下次启动 `validate()` 是按**字段表**
  1010. * 重建配置的 —— 表外的键会被直接丢掉,用户的改动就凭空消失,且全程没有任何报错。
  1011. *
  1012. * 怎么拿到「界面上绑定了哪些路径」:所有取值函数(getNumber/getText/getBool/getEnum)
  1013. * 最终都走 `Store.get`,而每个值控件在渲染时都会读一次自己的路径。
  1014. * 所以渲染一遍 5 个页面、记录所有读过的路径即可,不必去翻代码。 */
  1015. const readPaths = {};
  1016. const rawGet = api.Store.get;
  1017. api.Store.get = function (p, fb) { readPaths[p] = true; return rawGet.call(api.Store, p, fb); };
  1018. const boundPaths = {};
  1019. function sweepPages() {
  1020. PAGES.forEach((key) => {
  1021. api.W.beginPage();
  1022. const page = api.Pages[key](() => { });
  1023. if (!page) { fail(`渲染 ${PAGE_TITLE[key]} 时返回了空视图`); return; }
  1024. /* 绑定配置的输入框是「写入端」,即使渲染时没读也一定要算进来 */
  1025. Object.keys(api.W.pendingFields).forEach((p) => { boundPaths[p] = true; });
  1026. });
  1027. /* 登录页也是界面的一部分(未激活时它就是整个界面),同样要扫 */
  1028. api.W.beginPage();
  1029. api.Pages.login();
  1030. Object.keys(api.W.pendingFields).forEach((p) => { boundPaths[p] = true; });
  1031. }
  1032. sweepPages();
  1033. /* ★ 有些控件是**条件渲染**的(勾上「云端去重」才出现表名输入框、
  1034. * 勾上「AI 头像识别」才出现 AI 性别下拉)。只按默认值扫一遍会把它们当成
  1035. * 「没有界面的字段」,所以把所有布尔开关都打开再扫一遍。
  1036. * 这样扫出来的路径集合才是「界面上真正可能出现的全部路径」。 */
  1037. const boolFields = api.Store.FIELDS.filter((f) => f.type === 'bool').map((f) => f.path);
  1038. const boolBackup = {};
  1039. boolFields.forEach((p) => { boolBackup[p] = api.Store.get(p, false); api.Store.set(p, true); });
  1040. sweepPages();
  1041. /* ★ 还有一类条件渲染是**枚举驱动**的:脚本二选「关键词搜索」才出现关键词输入框。
  1042. * 布尔那一轮扫不到它(它不是一个开关),于是会被误判成「没有界面的字段」。
  1043. * 枚举每组只有 2~4 个取值,逐个取值各扫一遍,代价可控。
  1044. *
  1045. * ⚠️ 必须放在**布尔全开还没恢复**的时候扫:卡片的展开本身是个 bool,
  1046. * 先恢复的话四张卡都折回去了,卡内的条件控件一个都扫不到(踩过)。 */
  1047. const enumFields = api.Store.FIELDS.filter((f) => f.type === 'enum' && f.group);
  1048. enumFields.forEach((f) => {
  1049. const backup = api.Store.get(f.path);
  1050. const keys = Object.keys(api.Store.enum[f.group] || {});
  1051. keys.forEach((k) => {
  1052. api.Store.set(f.path, k);
  1053. sweepPages();
  1054. });
  1055. api.Store.set(f.path, backup);
  1056. });
  1057. boolFields.forEach((p) => { api.Store.set(p, boolBackup[p]); });
  1058. Object.keys(readPaths).forEach((p) => { boundPaths[p] = true; });
  1059. const allPathList = Object.keys(boundPaths);
  1060. const notInTable = allPathList.filter((p) => !api.Store.fieldOf(p));
  1061. check(`界面绑定了 ${allPathList.length} 个配置路径,且**全部**在字段表里`,
  1062. notInTable.length === 0,
  1063. '表外路径(重启会被丢弃):' + notInTable.join(', '));
  1064. /* 数量下限:扫出来的路径太少说明这个覆盖检查本身失效了 */
  1065. check('覆盖检查确实扫到了足够多的路径(≥ 40)', allPathList.length >= 40,
  1066. `只扫到 ${allPathList.length} 个`);
  1067. /* ID提取四张脚本卡的字段必须**都有**界面控件。
  1068. * 它们是**条件渲染**的(要展开那张卡才出现),所以这条同时也是在验证
  1069. * 上面「把所有 bool 打开再扫一遍」真的覆盖到了展开态 ——
  1070. * 只要有一张卡的字段没被扫到,就说明它展开后其实什么都没渲染。 */
  1071. const idCardPaths = api.Store.FIELDS
  1072. .filter((f) => /^tabs\.id\.scripts\.s[1-4]\./.test(f.path))
  1073. .map((f) => f.path);
  1074. const idCardMissing = idCardPaths.filter((p) => !boundPaths[p]);
  1075. check(`ID提取四张脚本卡的 ${idCardPaths.length} 个字段都有界面控件`,
  1076. idCardMissing.length === 0,
  1077. '没有控件的字段:' + idCardMissing.join(', '));
  1078. /* 只读路径不该出现:能读就意味着能写 */
  1079. const uiWritable = allPathList.filter((p) => {
  1080. const f = api.Store.fieldOf(p);
  1081. return f && f.type !== undefined;
  1082. });
  1083. check('扫到的路径都是字段表里声明过的字段', uiWritable.length === allPathList.length,
  1084. `${allPathList.length - uiWritable.length} 个无声明`);
  1085. /* —— F2. 逐个控件驱动:操作 → 落盘 → 模拟重启 → 读回 ——
  1086. *
  1087. * 「模拟重启」= 把内存里的 state 丢掉、重新从 storages 读一遍。
  1088. * 这是真机上「杀进程再打开」的等价物,也是这个场景唯一有意义的验证方式 ——
  1089. * 只在内存里对得上、重启就变回默认值,那种「保存」等于没保存。 */
  1090. /**
  1091. * 渲染某个页面。
  1092. *
  1093. * 两层都从这里进:一级 tab 走 TAB_PAGES(任务/记录/我的),
  1094. * 二级任务详情走 DETAIL_PAGES(ID提取/UID私信/养号/视频功能)。
  1095. * 每次渲染前 `W.beginPage()`:它清空「待提交输入框」登记表,
  1096. * 于是 renderPage 之后 `W.pendingFields` 必然是**本页**的控件(场景 F 靠它列控件)。
  1097. */
  1098. function renderPage(key) {
  1099. api.W.beginPage();
  1100. if (DETAIL_PAGES.indexOf(key) >= 0) { return api.App.DETAIL_PAGES[key](() => { }); }
  1101. return api.App.TAB_PAGES[key]();
  1102. }
  1103. function restartApp() {
  1104. api.Store.state = null;
  1105. api.Store.init();
  1106. }
  1107. /** 按文字找控件:文字可能在子节点上,监听挂在父级容器上,所以往上找带监听的节点。 */
  1108. function clickByLabel(root, text) {
  1109. const hits = findAll(root, textIs(text), [], 0);
  1110. for (let i = 0; i < hits.length; i++) {
  1111. let cur = hits[i];
  1112. for (let hop = 0; hop < 4 && cur; hop++) {
  1113. if (cur.listeners && cur.listeners.click) { cur.performClick(); return true; }
  1114. cur = cur.parent;
  1115. }
  1116. }
  1117. return false;
  1118. }
  1119. /** 某行「标签」右侧的那个控件(开关/下拉都在标签所在行的末位)。 */
  1120. function controlRightOf(root, label) {
  1121. const hits = findAll(root, textIs(label), [], 0);
  1122. if (hits.length === 0 || !hits[0].parent) { return null; }
  1123. const row = hits[0].parent;
  1124. return row.getChildAt(row.getChildCount() - 1);
  1125. }
  1126. /** 勾选框在「标签所在行」的第一个子节点上(结构:勾选框 + 间距 + 标签)。 */
  1127. function boxLeftOf(root, label) {
  1128. const hits = findAll(root, textIs(label), [], 0);
  1129. if (hits.length === 0) { return null; }
  1130. let cur = hits[0];
  1131. for (let hop = 0; hop < 4 && cur; hop++) {
  1132. if (cur.getChildCount && cur.getChildCount() > 1) { return cur.getChildAt(0); }
  1133. cur = cur.parent;
  1134. }
  1135. return null;
  1136. }
  1137. console.log('');
  1138. console.log('勾选框(ID提取 · 过滤私密账户):');
  1139. let page = renderPage('id');
  1140. const box = boxLeftOf(page, '过滤私密账户');
  1141. check('找得到「过滤私密账户」的勾选框', !!box);
  1142. if (box) {
  1143. const before = api.Store.getBool('tabs.id.features.filterPrivate');
  1144. box.parent.performClick();
  1145. const after = api.Store.getBool('tabs.id.features.filterPrivate');
  1146. check('点一下 → 配置立即变化', after !== before, `${before} → ${after}`);
  1147. restartApp();
  1148. check('模拟重启后仍是新值', api.Store.getBool('tabs.id.features.filterPrivate') === after);
  1149. const box2 = boxLeftOf(renderPage('id'), '过滤私密账户');
  1150. /* ★ 断言「界面与配置一致」,不是「一定是勾上的」——
  1151. * 默认值本来就是 true,点一下变 false,写死期望值会把正确行为判成失败。 */
  1152. check('重启后勾选框显示的状态与配置一致',
  1153. box2 && String(box2.getText()) === (after ? '✓' : ''),
  1154. box2 ? `实际 "${box2.getText()}",配置为 ${after}` : '没找到');
  1155. api.Store.set('tabs.id.features.filterPrivate', before);
  1156. }
  1157. console.log('');
  1158. console.log('开关(UID私信 · 进行关注):');
  1159. page = renderPage('dm');
  1160. const followChip = controlRightOf(page, '进行关注');
  1161. check('找得到「进行关注」右侧的开关', !!followChip);
  1162. if (followChip) {
  1163. const before = api.Store.getBool('tabs.dm.doFollow');
  1164. followChip.performClick();
  1165. const after = api.Store.getBool('tabs.dm.doFollow');
  1166. check('点一下 → 配置立即变化', after !== before, `${before} → ${after}`);
  1167. restartApp();
  1168. check('模拟重启后仍是新值', api.Store.getBool('tabs.dm.doFollow') === after);
  1169. const chip2 = controlRightOf(renderPage('dm'), '进行关注');
  1170. check('重启后开关显示的是新状态',
  1171. chip2 && String(chip2.getText()) === api.Store.label('toggle', after ? 'on' : 'off'),
  1172. chip2 ? `实际 "${chip2.getText()}"` : '没找到');
  1173. api.Store.set('tabs.dm.doFollow', before);
  1174. }
  1175. console.log('');
  1176. console.log('下拉框(养号 · 点赞概率):');
  1177. page = renderPage('nurture');
  1178. const rateChip = controlRightOf(page, '点赞概率');
  1179. check('找得到「点赞概率」右侧的下拉胶囊', !!rateChip);
  1180. if (rateChip) {
  1181. const before = api.Store.get('tabs.nurture.likeRate');
  1182. const beforeLabel = api.Store.label('likeRate', before);
  1183. rateChip.performClick();
  1184. const sheet2 = api.Dialog.active;
  1185. check('点开后会弹出选择窗', !!sheet2);
  1186. if (sheet2) {
  1187. /* 选一个不是当前项的候选 */
  1188. const keys = Object.keys(api.Store.enum.likeRate);
  1189. const targetKey = keys.filter((k) => k !== before)[0];
  1190. const targetLabel = api.Store.label('likeRate', targetKey);
  1191. clickByLabel(sheet2.root, targetLabel);
  1192. }
  1193. const after = api.Store.get('tabs.nurture.likeRate');
  1194. check('选择后配置立即变化', after !== before, `${beforeLabel} → ${api.Store.label('likeRate', after)}`);
  1195. restartApp();
  1196. check('模拟重启后仍是新选项', api.Store.get('tabs.nurture.likeRate') === after);
  1197. const chip2 = controlRightOf(renderPage('nurture'), '点赞概率');
  1198. check('重启后下拉框显示的是新选项',
  1199. chip2 && String(chip2.getText()) === api.Store.label('likeRate', after),
  1200. chip2 ? `实际 "${chip2.getText()}"` : '没找到');
  1201. api.Store.set('tabs.nurture.likeRate', before);
  1202. }
  1203. console.log('');
  1204. console.log('输入框与区间行(视频功能 · 关键词 / 停止个数):');
  1205. page = renderPage('video');
  1206. const kwInput = api.W.pendingFields['tabs.video.keyword'];
  1207. const stopMinInput = api.W.pendingFields['tabs.video.stopMin'];
  1208. check('页面把「关键词」登记为可写输入框', !!kwInput);
  1209. check('页面把「停止个数最小值」登记为可写输入框', !!stopMinInput);
  1210. if (kwInput && stopMinInput) {
  1211. kwInput.root.eraseAll();
  1212. kwInput.root.type('nike');
  1213. stopMinInput.root.eraseAll();
  1214. stopMinInput.root.type('9');
  1215. check('打字即落盘(文本 + 数字)',
  1216. api.Store.getText('tabs.video.keyword') === 'nike' &&
  1217. api.Store.getNumber('tabs.video.stopMin') === 9,
  1218. `关键词 "${api.Store.getText('tabs.video.keyword')}" / 最小值 ${api.Store.getNumber('tabs.video.stopMin')}`);
  1219. restartApp();
  1220. check('模拟重启后两个值都还在',
  1221. api.Store.getText('tabs.video.keyword') === 'nike' &&
  1222. api.Store.getNumber('tabs.video.stopMin') === 9);
  1223. renderPage('video');
  1224. const kw2 = api.W.pendingFields['tabs.video.keyword'];
  1225. const min2 = api.W.pendingFields['tabs.video.stopMin'];
  1226. check('重启后界面上的输入框就是新值',
  1227. kw2 && String(kw2.root.getText()) === 'nike' &&
  1228. min2 && String(min2.root.getText()) === '9',
  1229. kw2 ? `实际 "${kw2.root.getText()}" / "${min2 ? min2.root.getText() : '?'}"` : '没找到');
  1230. }
  1231. console.log('');
  1232. console.log('多选开关(视频功能 · 执行操作里的「点赞」):');
  1233. page = renderPage('video');
  1234. const likeBefore = api.Store.getBool('tabs.video.actions.like');
  1235. check('点一下「点赞」能改状态', clickByLabel(page, '点赞'));
  1236. const likeAfter = api.Store.getBool('tabs.video.actions.like');
  1237. check('「点赞」开关的改动已落盘', likeAfter !== likeBefore, `${likeBefore} → ${likeAfter}`);
  1238. restartApp();
  1239. check('模拟重启后仍是新状态', api.Store.getBool('tabs.video.actions.like') === likeAfter);
  1240. check('其余三个动作开关没被误改',
  1241. api.Store.getBool('tabs.video.actions.follow') === api.Store.defaultOf('tabs.video.actions.follow') &&
  1242. api.Store.getBool('tabs.video.actions.dm') === api.Store.defaultOf('tabs.video.actions.dm'));
  1243. api.Store.set('tabs.video.actions.like', likeBefore);
  1244. console.log('');
  1245. console.log('脚本卡(ID提取 · 勾选与展开):');
  1246. /* 页面点任何东西都会调 `Pages.requestRerender()`,真机上由 `App.renderContent` 注入。
  1247. * 这里直接渲染页面,所以要自己补上 —— 这正是「界面靠 App 装配」的那部分契约。 */
  1248. api.Pages.requestRerender = () => { };
  1249. api.App.activeTab = 'id';
  1250. page = renderPage('id');
  1251. /* ★ 脚本卡的「勾选」命中的是勾选框本身,名字文本没有监听 ——
  1252. * 勾选与展开是两个独立命中区(合并成一个的话,想展开却把脚本关掉是必然事件)。 */
  1253. const script2Box = boxLeftOf(page, '脚本二 · 评论页提 ID');
  1254. check('找得到脚本二的勾选框', !!script2Box);
  1255. if (script2Box) {
  1256. script2Box.performClick();
  1257. check('点勾选框 → 组内单选生效',
  1258. api.Store.getBool('tabs.id.scripts.s2.checked') === true &&
  1259. api.Store.getBool('tabs.id.scripts.s1.checked') === false,
  1260. `s1=${api.Store.getBool('tabs.id.scripts.s1.checked')} s2=${api.Store.getBool('tabs.id.scripts.s2.checked')}`);
  1261. restartApp();
  1262. check('模拟重启后选中的仍是脚本二',
  1263. api.Store.getBool('tabs.id.scripts.s2.checked') === true &&
  1264. api.Store.getBool('tabs.id.scripts.s1.checked') === false);
  1265. }
  1266. page = renderPage('id');
  1267. const arrow = controlRightOf(page, '脚本二 · 评论页提 ID');
  1268. const expandedBefore = api.Store.getBool('tabs.id.scripts.s2.expanded');
  1269. check('找得到脚本二的展开箭头', !!arrow);
  1270. if (arrow) {
  1271. arrow.performClick();
  1272. check('展开态已落盘',
  1273. api.Store.getBool('tabs.id.scripts.s2.expanded') === !expandedBefore,
  1274. `实际 ${api.Store.getBool('tabs.id.scripts.s2.expanded')}`);
  1275. restartApp();
  1276. check('重启后展开态被保留(不是每次启动都回到默认)',
  1277. api.Store.getBool('tabs.id.scripts.s2.expanded') === !expandedBefore);
  1278. /* 收尾:恢复成默认,免得影响后面的断言 */
  1279. api.Store.set('tabs.id.scripts.s2.expanded', expandedBefore);
  1280. api.Store.set('tabs.id.scripts.s2.checked', false);
  1281. api.Store.set('tabs.id.scripts.s1.checked', true);
  1282. }
  1283. console.log('');
  1284. console.log('文件路径(UID私信 · 随机私信文本):');
  1285. env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };
  1286. api.Store.init();
  1287. page = renderPage('dm');
  1288. const pathInput = api.W.pendingFields['tabs.dm.dmTextPath'];
  1289. check('路径也是可写输入框', !!pathInput);
  1290. if (pathInput) {
  1291. pathInput.root.eraseAll();
  1292. pathInput.root.type('/sdcard/Download/my_dm.txt');
  1293. check('手输的路径已落盘',
  1294. api.Store.getText('tabs.dm.dmTextPath') === '/sdcard/Download/my_dm.txt',
  1295. `实际 "${api.Store.getText('tabs.dm.dmTextPath')}"`);
  1296. restartApp();
  1297. check('模拟重启后路径还在',
  1298. api.Store.getText('tabs.dm.dmTextPath') === '/sdcard/Download/my_dm.txt');
  1299. }
  1300. console.log('');
  1301. console.log('登录页卡密草稿(还没点「登录激活」也要落盘):');
  1302. api.Store.set('license.status', 'none');
  1303. api.Store.set('license.draftCode', '');
  1304. api.Pages.LICENSE_RESET();
  1305. api.Pages.login();
  1306. const codeField = api.W.pendingFields['license.draftCode'];
  1307. check('登录页的卡密框绑定了配置路径', !!codeField);
  1308. if (codeField) {
  1309. codeField.root.eraseAll();
  1310. codeField.root.type('ABCD-1234-EFGH-5678');
  1311. check('打字即落盘(不用等提交)',
  1312. api.Store.getText('license.draftCode') === 'ABCD-1234-EFGH-5678',
  1313. `实际 "${api.Store.getText('license.draftCode')}"`);
  1314. restartApp();
  1315. api.Pages.login();
  1316. const codeAgain = api.W.pendingFields['license.draftCode'];
  1317. check('模拟重启后卡密框里还是那串(不用重打 16 位)',
  1318. codeAgain && String(codeAgain.root.getText()) === 'ABCD-1234-EFGH-5678',
  1319. codeAgain ? `实际 "${codeAgain.root.getText()}"` : '没找到');
  1320. api.Store.set('license.draftCode', '');
  1321. }
  1322. /* ★ 2026-10-13 修正:这一段以前是「因错误的原因」通过的。
  1323. *
  1324. * 原先它直接 `Store.set('license.draftCode', 'TK9F-…')` 就提交,**绕过输入框**:
  1325. * 栏位里还留着上一段的 'ABCD-…',配置里却是 'TK9F-…',两者不一致。
  1326. * 以前没有挂载界面骨架,`enterApp → renderScreen` 会因为 `contentHost` 不存在
  1327. * 提前抛错,`commitPendingInputs()` 根本没跑到,于是"通过"了 —— 纯属侥幸。
  1328. * (对照实验:把 2026-10-11 的旧产物拿来跑同一段时序,行为与现在完全一致,
  1329. * 说明这不是改版引入的回归,而是这条断言一直在测一个不存在的场景。)
  1330. *
  1331. * 现在按真机的方式跑:**在栏位里打字**,再提交。这也是唯一能在设备上发生的形态 ——
  1332. * 用户不可能隔着一个正在显示的输入框去改配置。 */
  1333. api.Store.state = null;
  1334. api.Store.init();
  1335. api.Store.set('license.status', 'none');
  1336. api.Store.set('license.draftCode', '');
  1337. /* 这里刻意**先**渲染再打字:栏位文字就是配置(空串),不一致的状态不会出现 */
  1338. api.Pages.LICENSE_RESET();
  1339. api.Pages.login();
  1340. const codeBox = api.W.pendingFields['license.draftCode'];
  1341. check('提交前能找到登录页卡密框', !!codeBox);
  1342. if (codeBox) {
  1343. codeBox.root.eraseAll();
  1344. codeBox.root.type('TK9F-2A7C-8E10-4B55');
  1345. }
  1346. check('提交前配置里的草稿就是栏位里的那串',
  1347. api.Store.getText('license.draftCode') === 'TK9F-2A7C-8E10-4B55',
  1348. `实际 "${api.Store.getText('license.draftCode')}"`);
  1349. api.App.submitLicense('TK9F-2A7C-8E10-4B55');
  1350. check('激活成功后卡密已落盘', api.Store.getText('license.code') === 'TK9F2A7C8E104B55',
  1351. `实际 "${api.Store.getText('license.code')}"`);
  1352. check('激活成功后草稿保留(解绑后回来不用重打)',
  1353. api.Store.getText('license.draftCode') === 'TK9F-2A7C-8E10-4B55',
  1354. `实际 "${api.Store.getText('license.draftCode')}"`);
  1355. /* ★ 顺序陷阱——**固化下来**,别让后来者"顺手"把它改回去:
  1356. * 若在成功回调里清空草稿(`Store.set('license.draftCode','')`),
  1357. * 紧接着的 `enterApp → renderContent → commitPendingInputs()` 会用
  1358. * 登录页输入框里还留着的文字把它写回来 —— 真机上就是「清了等于没清」。
  1359. * 所以本用例的第二条断言测的是"草稿要保留",而这条测的是"清不掉"这件事本身。 */
  1360. api.Store.set('license.status', 'none');
  1361. api.Pages.login(); /* 登录页当前就在屏幕上:栏位已登记、显示 TK9F-… */
  1362. api.Store.set('license.draftCode', ''); /* 模拟"在成功回调里清了草稿" */
  1363. api.App.enterApp(); /* 会走 commitPendingInputs,栏位内容写回配置 */
  1364. check('输入框里的文字会在切屏时把「已清空的草稿」写回来(清了等于没清)',
  1365. api.Store.getText('license.draftCode') === 'TK9F-2A7C-8E10-4B55',
  1366. `实际 "${api.Store.getText('license.draftCode')}"`);
  1367. api.Store.set('license.status', 'none');
  1368. api.Store.set('license.draftCode', '');
  1369. api.App.screen = 'app';
  1370. /* —— F3. 落盘的内容就是「配置全量」,不是一个子集 —— */
  1371. console.log('');
  1372. console.log('落盘内容的完整性:');
  1373. const persisted = JSON.parse(env.memoryStorage[api.Store.STORAGE_NAME].config);
  1374. let flatCount = 0;
  1375. function countLeaves(node) {
  1376. if (node === null || typeof node !== 'object') { flatCount += 1; return; }
  1377. Object.keys(node).forEach((k) => countLeaves(node[k]));
  1378. }
  1379. countLeaves(persisted);
  1380. check('落盘 JSON 里的叶子节点数 ≥ 字段表声明的字段数',
  1381. flatCount >= api.Store.FIELDS.length,
  1382. `落盘 ${flatCount} / 字段表 ${api.Store.FIELDS.length}`);
  1383. check('落盘 JSON 带版本号(老存档才可被迁移)', typeof persisted.version === 'number');
  1384. /* 字段表里没有界面控件的字段 —— 它们由程序写入,不是「用户能改的值」。
  1385. * 列出来是为了让「哪些状态没有对应控件」一目了然,不是失败项。 */
  1386. const PROGRAMMATIC = {
  1387. activeTab: '底部页签栏的当前页(由点页签写入,本身不是一个「值控件」)',
  1388. activeDetail: '当前打开的二级页(由进/退详情页写入,同样不是一个「值控件」)',
  1389. 'ball.side': '悬浮球吸附到哪一边(拖动松手时写入,没有对应的界面控件)',
  1390. 'ball.yRatio': '悬浮球的垂直位置比例(拖动松手时写入,存比例而不是像素)',
  1391. /* v7:AI 三项归入「AI 画像识别 · 暂不支持」这一行灰字。
  1392. * 值仍保留在配置里(将来接上模型直接生效),但界面**故意不给控件** ——
  1393. * 给一个开了什么也不发生的开关,比不给更糟。 */
  1394. 'tabs.id.features.aiGender': 'AI 性别:v7 起随「AI 画像识别」一起标为暂不支持,值保留、暂无控件',
  1395. 'tabs.id.features.aiAgeMax': 'AI 年龄:同上'
  1396. };
  1397. const noUiFields = api.Store.FIELDS
  1398. .filter((f) => !boundPaths[f.path])
  1399. .map((f) => f.path);
  1400. console.log('');
  1401. console.log(` 字段表共 ${api.Store.FIELDS.length} 个字段,其中 ${noUiFields.length} 个没有界面控件:`);
  1402. if (noUiFields.length === 0) {
  1403. console.log(' (无 —— 每个字段都有对应控件)');
  1404. } else {
  1405. noUiFields.forEach((p) => {
  1406. console.log(' · ' + p + (PROGRAMMATIC[p] ? ' —— ' + PROGRAMMATIC[p] : ' ⚠ 既没有控件、也没登记为程序写入'));
  1407. });
  1408. }
  1409. /* 除了明确登记为「程序写入」的那几个,不该再有别的手段没有控件 ——
  1410. * 那意味着要么界面漏做了,要么字段表里留了没人用的死字段。 */
  1411. const unaccounted = noUiFields.filter((p) => !PROGRAMMATIC[p]);
  1412. check('没有控件的字段都已明确登记为「程序写入」',
  1413. unaccounted.length === 0,
  1414. '未登记:' + unaccounted.join(', '));
  1415. /* ------------------------------------------------------------ 汇总 */
  1416. console.log('');
  1417. if (issueCount > 0) {
  1418. console.log(`AUDIT_FOUND_ISSUES=${issueCount}`);
  1419. process.exit(1);
  1420. }
  1421. console.log('AUDIT_CLEAN');