audit-values.js 73 KB

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