audit-values.js 76 KB

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