| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557 |
- #!/usr/bin/env node
- /**
- * audit-values.js — 界面取值审计
- *
- * 回答一个很具体的问题:**攒下来的老配置,能不能让界面显示成 undefined / 空框**。
- *
- * 三个场景:
- * A. 全新配置 —— 首次安装
- * B. 脏数据 —— 设备上残留的旧版本 / 手改过的 / 缺字段的配置(真实用户场景)
- * C. 字段表完整性 —— 每个声明字段的默认值都必须合法,且能在界面上取到
- *
- * 为什么必须测 B:设备端没有测试框架,而「用户升级后发现某个框变空了」
- * 这类问题恰恰只在有历史数据时出现 —— 全新安装永远是好的。
- *
- * 用法:node tools/audit-values.js
- */
- 'use strict';
- const path = require('path');
- const stub = require('./android-stub');
- const env = stub.install();
- globalThis.__FEIZHOU_TEST__ = true;
- require(path.join(__dirname, '..', 'dist', 'tiktok-app.js'));
- const api = globalThis.__FEIZHOU__;
- /* 改版(v6)后有两层:
- * · 一级 tab:任务 / 记录 / 我的
- * · 二级任务详情:ID提取 / UID私信 / 养号 / 视频功能
- * 两层都要按同一套标准扫(undefined / 取值 / 持久化),
- * 所以 PAGES 是两者的并集,PAGE_TITLE 负责给报告里的名字。 */
- const TAB_PAGES = ['tasks', 'records', 'mine'];
- const DETAIL_PAGES = ['id', 'dm', 'nurture', 'video'];
- const PAGES = TAB_PAGES.concat(DETAIL_PAGES);
- const PAGE_TITLE = {
- tasks: '任务列表', records: '运行记录', mine: '我的',
- id: 'ID提取', dm: 'UID私信', nurture: '养号', video: '视频功能'
- };
- const BAD = /undefined|null|NaN/;
- let issueCount = 0;
- function fail(message) {
- issueCount += 1;
- console.log(' ✗ ' + message);
- }
- function pass(message) {
- console.log(' ✓ ' + message);
- }
- function check(name, ok, detail) {
- if (ok) { pass(name); } else { fail(name + (detail ? ' → ' + detail : '')); }
- }
- /** 递归找满足条件的控件。 */
- function findAll(view, predicate, out, depth) {
- if (!view || depth > 14) { return out || []; }
- const result = out || [];
- if (predicate(view)) { result.push(view); }
- const n = typeof view.getChildCount === 'function' ? view.getChildCount() : 0;
- for (let i = 0; i < n; i++) { findAll(view.getChildAt(i), predicate, result, depth + 1); }
- return result;
- }
- /* 识别输入框:桩里只有 MockEditText 带 setInputType */
- const isInput = (v) => typeof v.setInputType === 'function';
- const textIs = (t) => (v) => typeof v.getText === 'function' && String(v.getText()) === t;
- /**
- * 点弹窗里的「确定」。
- *
- * ⚠️ 弹窗按钮是「容器 FrameLayout(挂监听)+ 子 TextView(显示文字)」两层结构,
- * 直接对 TextView 调 performClick() 不会触发任何东西。
- * 所以要优先点带监听的父节点 —— 这个坑已经在 smoke.js 里踩过一次。
- */
- function clickConfirm(overlay) {
- const buttons = findAll(overlay, textIs('确定'), [], 0);
- if (buttons.length > 0) {
- const node = buttons[0];
- if (node.parent && node.parent.listeners && node.parent.listeners.click) {
- node.parent.performClick();
- } else {
- node.performClick();
- }
- }
- return buttons.length;
- }
- /* ------------------------------------------------------------ 渲染扫描 */
- /** 把一棵视图树的文字收集成数组(不走 collect 的下标轨迹,只看文案)。 */
- function collectText(view) {
- const items = [];
- collect(view, items, '', 0);
- return items.map((x) => x.text);
- }
- function collect(view, out, trail, depth) {
- if (!view || depth > 12) { return; }
- if (typeof view.getText === 'function') {
- out.push({
- trail: trail,
- text: String(view.getText()),
- hint: view.hint === undefined || view.hint === null ? '' : String(view.hint)
- });
- }
- const count = typeof view.getChildCount === 'function' ? view.getChildCount() : 0;
- for (let i = 0; i < count; i++) {
- collect(view.getChildAt(i), out, trail + '.' + i, depth + 1);
- }
- }
- /** 把 5 个页签全渲染一遍,扫出 undefined / null / NaN。 */
- function scanPages() {
- PAGES.forEach((key) => {
- let items = [];
- try {
- items = [];
- collect(renderPage(key), items, key, 0);
- } catch (error) {
- fail(`[${PAGE_TITLE[key]}] 渲染抛异常:${error.message}`);
- return;
- }
- const bad = items.filter((x) => BAD.test(x.text) || BAD.test(x.hint));
- if (bad.length === 0) {
- pass(`[${PAGE_TITLE[key]}] ${items.length} 个控件,无 undefined / null / NaN`);
- } else {
- bad.forEach((x) => {
- fail(`[${PAGE_TITLE[key]}] 非法值 ${x.trail} 正文="${x.text}" 占位="${x.hint}"`);
- });
- }
- });
- }
- /** 取某个控件的文字,用于断言「这一格到底显示了什么」。
- *
- * 走 renderPage 而不是直接取 Pages[key]:记录页有「列表 / 空态」两种形态,
- * 分流逻辑在路由层(App.TAB_PAGES),绕开它就会拿到与真实入口不一致的那一个。 */
- function textsOf(pageKey) {
- const items = [];
- collect(renderPage(pageKey), items, pageKey, 0);
- return items.map((x) => x.text);
- }
- /** 登录页也按同一套标准扫:它是独立一屏,不在 PAGES 里。 */
- function scanLogin(label) {
- let items = [];
- try {
- items = [];
- collect(api.Pages.login(), items, 'login', 0);
- } catch (error) {
- fail(`[登录页·${label}] 渲染抛异常:${error.message}`);
- return;
- }
- const bad = items.filter((x) => BAD.test(x.text) || BAD.test(x.hint));
- if (bad.length === 0) {
- pass(`[登录页·${label}] ${items.length} 个控件,无 undefined / null / NaN`);
- } else {
- bad.forEach((x) => {
- fail(`[登录页·${label}] 非法值 ${x.trail} 正文="${x.text}" 占位="${x.hint}"`);
- });
- }
- }
- /** 断言某个页面的控件文字里包含某段内容。 */
- function pageShows(pageKey, needle, why) {
- const all = textsOf(pageKey).join(' | ');
- if (all.indexOf(needle) >= 0) {
- pass(`${PAGE_TITLE[pageKey]} 显示「${needle}」(${why})`);
- } else {
- fail(`${PAGE_TITLE[pageKey]} 未显示「${needle}」(${why})`);
- }
- }
- /* ============================================================ 场景 A */
- console.log('=== 场景 A:全新配置 ===');
- console.log('');
- api.Store.init();
- scanPages();
- scanLogin('idle');
- /* ============================================================ 场景 B */
- console.log('');
- console.log('=== 场景 B:脏数据(模拟设备上残留的旧配置)===');
- console.log('');
- /* 故意造一份「什么都不对」的配置:缺字段、类型错、枚举非法、范围越界、多余字段,
- * 且 version=1 —— 也就是**老用户设备上真实的样子**。
- *
- * 关键点:v1 的 keyword / region / 各类文件路径默认值就是空串,
- * 所以老存档里存的是 "";而 "" 是合法字符串,光靠类型归一化补不上默认值,
- * 必须走版本迁移 —— 这正是用户在真机上看到「框是空的」的原因。 */
- const dirty = {
- version: 1,
- activeTab: '已废弃的页签',
- tabs: {
- id: {
- scripts: {
- s1: { checked: 'yes', enterPage: null, textIdPath: '' },
- /* v6 时代脚本三的「进入页面」可以是「评论页」,v7 值域收窄后不成立 */
- s3: { enterPage: 'comment' }
- }
- },
- dm: {
- followLimit: '两百', dmLimit: 40, aiGender: '', restMin: 900, restMax: 100,
- /* v2 用一个开关同时管关注与私信,v3 拆成两个 —— 这里验迁移 */
- doFollowDm: false,
- dmTextPath: '', commentTextPath: ''
- },
- video: {
- keyword: '', region: 42, stopCount: 7, intervalMin: -5,
- actions: { follow: 'yes' }, dmTextPath: ''
- }
- },
- license: { status: 'unknown' },
- 已废弃的字段: { x: 1 }
- };
- env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(dirty) };
- const loaded = api.Store.init();
- console.log('归一化 + 迁移结果:');
- const EXPECT = [
- ['activeTab', 'tasks', '非法 tab 回退到「任务」'],
- /* v8:四张卡的字段全部删了,脏数据里那些旧值**不该留下来**(见下面那条断言)。
- * 这里保留的是功能设置的两个去重开关(v7 起就在,v8 继续)。 */
- ['tabs.id.features.dedupeLocal', true, '归一化后仍有合法默认值'],
- /* 旧上限是「非数字」→ 迁移读不出数就不动它,直接用默认区间 */
- ['tabs.dm.followMin', 100, '旧关注上限非数字 → 不迁移,用默认区间下限'],
- ['tabs.dm.followMax', 200, '旧关注上限非数字 → 不迁移,用默认区间上限'],
- /* 旧上限是数字 → min 给 0(不设下界),max 沿用,行为不变 */
- ['tabs.dm.dmMin', 0, 'v4 私信上限 → 区间下界 0'],
- ['tabs.dm.dmMax', 40, 'v4 私信上限 → 区间上界沿用旧值'],
- ['tabs.dm.aiGender', 'male', '空串回退默认'],
- ['tabs.dm.doFollow', false, 'v2 合并开关迁移到「进行关注」'],
- ['tabs.dm.doDm', false, 'v2 合并开关迁移到「进行私信」'],
- ['tabs.dm.restMax', 900, 'min>max 自动抬齐'],
- ['tabs.dm.dmTextPath', api.Store.TEXT_DIR + '/dm_text.txt', 'v1 空串经迁移补默认'],
- ['tabs.video.keyword', 'fashion', 'v1 空串经迁移补业务默认值'],
- ['tabs.video.region', 'US', '类型不符回退默认值'],
- /* 旧「停止个数」是精确量 → 区间两端都取旧值(与稿子「20 ~ 20」同一表达) */
- ['tabs.video.stopMin', 7, 'v4 停止个数(精确量)→ 区间两端都取旧值'],
- ['tabs.video.stopMax', 7, 'v4 停止个数(精确量)→ 区间两端都取旧值'],
- ['tabs.video.intervalMin', 0, '负数夹到下限'],
- ['tabs.video.actions.follow', true, '字符串真值转布尔'],
- ['tabs.video.dmTextPath', api.Store.TEXT_DIR + '/dm_text.txt', 'v1 空串经迁移补默认'],
- ['license.status', 'none', '非法状态回退']
- ];
- EXPECT.forEach(([p, want, why]) => {
- const got = api.Store.get(p);
- if (got === want) {
- pass(`${p} = ${JSON.stringify(got)} (${why})`);
- } else {
- fail(`${p} = ${JSON.stringify(got)},期望 ${JSON.stringify(want)} (${why})`);
- }
- });
- /* 表外字段必须被丢弃,否则脏数据会一路传到界面 */
- if (api.Store.get('已废弃的字段') === undefined && loaded.tabs.id.scripts === undefined) {
- pass('表外字段已被丢弃,不会穿透到界面');
- } else {
- fail('表外字段没有被丢弃');
- }
- console.log('');
- console.log('脏数据下的渲染:');
- scanPages();
- console.log('');
- console.log('关键格子的实际显示:');
- pageShows('video', 'fashion', '关键词有默认值');
- pageShows('video', 'US', '主播区域有默认值');
- pageShows('video', '停止个数', '停止个数是区间行');
- pageShows('video', '7', '旧 stopCount=7 迁移后在界面上显示为 7 ~ 7');
- pageShows('id', '本地去重', '去重开关已收到功能设置(全页只出现一次)');
- pageShows('id', '只要这样的账号', '过滤条件独立成段(原来混在功能设置卡里)');
- pageShows('id', '还没有取材', '空清单有明确占位,不是一片空白');
- pageShows('dm', '关注+私信去重', '去重方式有默认值');
- /* 关注与私信各自独立成项;数量是「一行一个区间」 */
- pageShows('dm', '进行关注', '「进行关注」独立一行');
- pageShows('dm', '进行私信', '「进行私信」独立一行');
- pageShows('dm', '关注数量', '「关注数量」独立一行');
- pageShows('dm', '私信数量', '「私信数量」独立一行');
- pageShows('dm', '休息时间(秒)', '「休息时间(秒)」独立一行');
- pageShows('video', '停止个数', '停止个数独立一行');
- pageShows('video', '间隔时间(秒)', '间隔时间(秒)独立一行');
- pageShows('mine', '未登录', '卡密状态有默认值');
- /* 养号参数:两个区间 + 三个概率档位,都不能出现空框 */
- pageShows('nurture', '浏览时长(分钟)', '浏览时长是区间行');
- pageShows('nurture', '30%', '点赞概率有默认档位');
- pageShows('nurture', '10%', '评论概率有默认档位');
- pageShows('nurture', '15%', '关注概率有默认档位');
- pageShows('nurture', '单次操作', '单次操作是区间行');
- /* 新增的 ID提取功能设置 */
- pageShows('id', 'JP#TW#US', '提取区域填过的值仍然保留(即使当前不生效)');
- pageShows('id', '暂不支持', '没有承载者的项被明确标注,而不是给个能开的空开关');
- pageShows('id', '通杀', '语言区域有默认档位');
- pageShows('id', '500000', '数量门槛有默认上限');
- /* 四个数量门槛是「一行一个区间」,不是上下限各占一行 */
- pageShows('id', '关注量', '关注量有独立一行');
- pageShows('id', '~', '数量门槛显示成「最小值 ~ 最大值」');
- /* —— v8:ID提取的「取材清单」——
- *
- * 这一段守的是 v8 的核心结构:清单为空 → 占位;加一条 → 按起点出现对应输入;
- * 换起点 → **旧起点的输入必须消失**(不留残影);加满 → 添加入口变成提示。
- * 每一条都是"结构真的换了"的直接证据,而不是"文案改过了"。
- */
- console.log('');
- console.log('ID提取 · 取材清单(v8):');
- api.Sources.clear();
- let idTexts = textsOf('id').join(' | ');
- check('空清单时显示「还没有取材」占位',
- idTexts.indexOf('还没有取材') >= 0, idTexts.slice(0, 170));
- check('空清单时三段标题都在(取材 / 只要这样的账号 / 输出)',
- idTexts.indexOf('取材') >= 0
- && idTexts.indexOf('只要这样的账号') >= 0
- && idTexts.indexOf('输出') >= 0, idTexts.slice(0, 170));
- /* 指定账号:账号输入 + 三个列表 + 深度 */
- api.Sources.add('anchor');
- idTexts = textsOf('id').join(' | ');
- check('指定账号:渲染出「第 1 条」与账号输入',
- idTexts.indexOf('第 1 条') >= 0 && idTexts.indexOf('账号') >= 0, idTexts.slice(0, 170));
- check('指定账号:三个列表都可选',
- ['粉丝列表', '关注列表', '主页展示'].every((w) => idTexts.indexOf(w) >= 0));
- check('指定账号:有深度选项', idTexts.indexOf('深度') >= 0);
- check('指定账号:不出现别的起点的输入',
- idTexts.indexOf('视频从哪来') < 0 && idTexts.indexOf('搜索关键词') < 0);
- /* 换起点:旧起点的输入必须消失 —— "选了才出现"的另一半 */
- const oneSource = api.Sources.list()[0];
- api.Sources.update(oneSource.id, { kind: 'text' });
- idTexts = textsOf('id').join(' | ');
- check('换成文本文件后出现「文件」行', idTexts.indexOf('文件') >= 0, idTexts.slice(0, 170));
- check('换成文本文件后「取哪些列表」消失', idTexts.indexOf('取哪些列表') < 0, idTexts.slice(0, 170));
- check('换起点后数据里也不留旧参数', api.Sources.find(oneSource.id).anchors === undefined,
- String(api.Sources.find(oneSource.id).anchors));
- /* 视频起点:默认当前屏幕,选关键词才出输入框 */
- api.Sources.update(oneSource.id, { kind: 'video' });
- idTexts = textsOf('id').join(' | ');
- check('视频起点默认「当前屏幕」并有说明',
- idTexts.indexOf('当前屏幕') >= 0 && idTexts.indexOf('评论区') >= 0, idTexts.slice(0, 170));
- api.Sources.update(oneSource.id, { videoSource: 'keyword' });
- idTexts = textsOf('id').join(' | ');
- check('选「关键词搜索」后才出现搜索关键词输入', idTexts.indexOf('搜索关键词') >= 0);
- /* 当前屏幕:没有深度(它已经是最底层,没得展开) */
- api.Sources.update(oneSource.id, { kind: 'screen' });
- idTexts = textsOf('id').join(' | ');
- check('当前屏幕起点不提供「深度」', idTexts.indexOf('深度') < 0, idTexts.slice(0, 170));
- /* 上限:5 条封顶,添加入口变成提示 */
- api.Sources.clear();
- for (let si = 0; si < api.Sources.MAX; si++) { api.Sources.add('anchor'); }
- check('清单可以加到上限', api.Sources.count() === api.Sources.MAX,
- api.Sources.count() + ' / ' + api.Sources.MAX);
- idTexts = textsOf('id').join(' | ');
- check('满额时不再显示「添加一条取材」', idTexts.indexOf('添加一条取材') < 0, idTexts.slice(-170));
- check('满额时说明已达上限', idTexts.indexOf('已达上限') >= 0);
- /* 回到一条,供后面的用例使用 */
- api.Sources.clear();
- api.Sources.add('anchor');
- /* ============================================================ v6 改版 */
- /* 2026-10-13 规格书:底栏 5 页签 → 3 tab,四个任务降级为任务卡 + 二级页。
- * 这一节专门盯改版新增的三样东西:任务卡、运行记录、v5→v6 迁移。 */
- console.log('');
- console.log('v6 改版(任务卡 / 运行记录 / 迁移):');
- /* (v7 的"清语义"断言已随 v8 改结构整体作废:四张卡与它们的字段都不在了。) */
- /* —— 标题区只能出现一次 ——
- * 真机踩过:页面函数里画了一次、路由层又加了一次,界面上就是两个「飞舟脚本」。
- * 这类重复在桩测里不会抛错,只能靠数次数。 */
- /* 数标题次数需要真的搭一次界面骨架(App.contentHost 是挂载时才有的) */
- if (!api.App.contentHost) {
- api.App.mount(api.App.build());
- api.Dialog.mount(api.App.overlayHost);
- api.App.screen = 'app'; /* 不走登录页分支,否则 contentHost 永远没机会建 */
- api.App.renderScreen();
- }
- ['tasks', 'records', 'mine'].forEach((tabKey) => {
- api.App.detail = '';
- api.App.recordId = '';
- api.App.activeTab = tabKey;
- api.App.renderContent();
- const heads = collectText(api.App.contentHost).filter((t) => t === '飞舟脚本').length;
- check(`[${PAGE_TITLE[tabKey]}] 页面标题只渲染一次`, heads === 1, `出现 ${heads} 次`);
- });
- /* —— 任务卡:四张,且每张都有图标、颜色、状态徽标 —— */
- const taskCards = api.Pages.TASKS;
- check('任务卡数量为 4', taskCards.length === 4, `期望 4,实际 ${taskCards.length}`);
- check('每张任务卡都有名字/图标/颜色',
- taskCards.every((t) => t.name && t.icon && t.colorHex),
- JSON.stringify(taskCards.map((t) => t.key)));
- check('任务卡的图标都是几何块字符(不是 emoji 码位)',
- taskCards.every((t) => t.icon.charCodeAt(0) >= 0x2500 && t.icon.charCodeAt(0) <= 0x25FF),
- taskCards.map((t) => t.icon).join(','));
- /* 状态徽标三态:未配置(清单为空)/ 空闲(有取材没跑)/ 运行中 */
- api.Sources.clear();
- check('清单为空时任务卡是「未配置」', api.Pages.taskState('id').badge === '未配置',
- `期望 '未配置',实际 ${api.Pages.taskState('id').badge}`);
- api.Sources.add('anchor');
- check('有取材没跑时是「空闲」', api.Pages.taskState('id').badge === '空闲',
- `期望 '空闲',实际 ${api.Pages.taskState('id').badge}`);
- const wasRunning = api.Session.running;
- api.Session.start('id', 'id', 'ID提取', 'run-audit');
- check('正在跑的那个任务是「运行中」', api.Pages.taskState('id').badge === '运行中',
- `期望 '运行中',实际 ${api.Pages.taskState('id').badge}`);
- check('别的任务不受影响(仍然空闲)', api.Pages.taskState('dm').badge === '空闲',
- `期望 '空闲',实际 ${api.Pages.taskState('dm').badge}`);
- api.Session.stop();
- if (wasRunning) { api.Session.start('id', 'id', 'ID提取', 'run-audit'); }
- /* 任务卡的「当前:xxx」跟着**取材摘要**走(v8 起没有"当前脚本"了) */
- const idLine = api.Pages.taskScriptLine('id');
- check('任务卡的当前行显示的是取材摘要,不是"脚本一"',
- idLine.indexOf('当前:') === 0 && idLine.indexOf('脚本') < 0, idLine);
- api.Sources.clear();
- check('清单为空时如实说「未添加取材」',
- api.Pages.taskScriptLine('id') === '当前:未添加取材',
- `实际 ${api.Pages.taskScriptLine('id')}`);
- api.Sources.add('anchor');
- /* —— 任务列表页渲染出来的是四张卡,且没有 undefined —— */
- const tasksTexts = textsOf('tasks').join(' | ');
- check('任务列表渲染出四张任务卡',
- ['ID提取', 'UID私信', '养号', '视频功能'].every((n) => tasksTexts.indexOf(n) >= 0),
- tasksTexts.slice(0, 160));
- check('任务列表写明「全局单跑」这条规则', tasksTexts.indexOf('全局单跑') >= 0);
- check('没有记录时任务卡说「还未运行过」', tasksTexts.indexOf('还未运行过') >= 0);
- /* —— 运行记录:空态与列表是**同一页的两种状态** —— */
- api.Records.clear();
- const emptyTexts = textsOf('records').join(' | ');
- check('没有记录时渲染空态', emptyTexts.indexOf('还没有运行记录') >= 0, emptyTexts.slice(0, 160));
- api.Records.add({
- taskKey: 'id', taskName: 'ID提取', scriptKey: 's1', scriptName: '脚本一 · 粉丝页提 ID',
- result: 'success', startedAt: Date.now(), endedAt: Date.now(), durationMs: 750000,
- resultText: '提取 128 个 ID', resultFile: 'uid_20261013.txt',
- logs: [{ t: Date.now(), level: 'INFO', text: '[启动脚本] 启动 · 粉丝页提 ID' }]
- });
- const listTexts = textsOf('records').join(' | ');
- check('有记录时渲染列表而不是空态', listTexts.indexOf('还没有运行记录') < 0);
- check('记录页写明条数', listTexts.indexOf('共 1 条') >= 0, listTexts.slice(0, 160));
- check('记录条目显示「任务 · 脚本」与结果标签',
- listTexts.indexOf('ID提取 · 脚本一') >= 0 && listTexts.indexOf('已完成') >= 0);
- /* D3 运行详情:摘要卡 + 日志卡(日志是**真实回传**的那一条) */
- const oneRecord = api.Records.list()[0];
- const detailTexts = collectText(api.Pages.recordDetail(oneRecord)).join(' | ');
- check('运行详情显示结果文件与文件名', detailTexts.indexOf('uid_20261013.txt') >= 0, detailTexts.slice(0, 200));
- check('运行详情显示真实日志行', detailTexts.indexOf('[启动脚本] 启动 · 粉丝页提 ID') >= 0);
- check('运行详情有「执行日志」标题与「复制」', detailTexts.indexOf('执行日志') >= 0 && detailTexts.indexOf('复制') >= 0);
- /* 记录被清理掉(id 不存在)时不能白屏 */
- const goneTexts = collectText(api.Pages.recordDetail(null)).join(' | ');
- check('记录已不在本地时显示说明而不是白屏', goneTexts.indexOf('这条记录已不在本地') >= 0);
- /* 记录的持久化:模拟重启后仍在(storages 里真的写进去了) */
- const recordsRaw = env.memoryStorage[api.Records.STORAGE_NAME][api.Records.STORAGE_KEY];
- check('运行记录已落盘', typeof recordsRaw === 'string' && recordsRaw.indexOf('uid_20261013.txt') >= 0);
- api.Records.clear();
- check('清空后记录为空', api.Records.list().length === 0, `期望 0,实际 ${api.Records.list().length}`);
- /* 脏记录(缺 id / 不是对象)必须被丢弃,否则 D1 上会出现点不开的行 */
- env.memoryStorage[api.Records.STORAGE_NAME][api.Records.STORAGE_KEY] =
- JSON.stringify([{ taskKey: 'id', taskName: 'X' }, null, { id: 'ok', taskKey: 'video' }, 'junk']);
- check('脏记录被丢弃,只剩合法条目', api.Records.list().length === 1, `期望 1,实际 ${api.Records.list().length}`);
- api.Records.clear();
- /* —— v5 → v6 迁移:老用户的 activeTab 必须被映射,配置一条不丢 —— */
- const v5 = api.Store.validate({
- version: 5,
- activeTab: 'nurture',
- tabs: { nurture: { browseMin: 5, browseMax: 9 } }
- });
- check('v5 的页签 nurture → tasks', v5.config.activeTab === 'tasks', `期望 'tasks',实际 ${v5.config.activeTab}`);
- check('v5 的老页签被映射成二级页(直接停在他原来那个任务上)', v5.config.activeDetail === 'nurture', `期望 'nurture',实际 ${v5.config.activeDetail}`);
- check('迁移会说明它做了什么',
- v5.problems.some((p) => p.indexOf('activeTab') >= 0 && p.indexOf('tasks') >= 0),
- v5.problems.join(' | '));
- check('老页签的业务配置一条没丢', v5.config.tabs.nurture.browseMin === 5, `期望 5,实际 ${v5.config.tabs.nurture.browseMin}`);
- const v5mine = api.Store.validate({ version: 5, activeTab: 'mine' });
- check('v5 的「我的」保持不变', v5mine.config.activeTab === 'mine', `期望 'mine',实际 ${v5mine.config.activeTab}`);
- check('「我的」不需要映射二级页', v5mine.config.activeDetail === '', `期望 '',实际 ${v5mine.config.activeDetail}`);
- const v5bad = api.Store.validate({ version: 5, activeTab: '已删除的页签' });
- check('不认识的老页签回退到「任务」', v5bad.config.activeTab === 'tasks', `期望 'tasks',实际 ${v5bad.config.activeTab}`);
- check('不认识的老页签不带二级页', v5bad.config.activeDetail === '', `期望 '',实际 ${v5bad.config.activeDetail}`);
- /* —— v6/v7 → v8:旧四张卡配置 → 取材清单 ——
- * 单独造一份旧存档来测:场景 B 的脏数据里 `tabs.id.scripts` 已被整棵丢弃,
- * 只有这里能验证"用户原来的选择真的一条不丢"。
- * 最值得看的是 **s4 的三个开关展开成三条取材** —— 那是旧结构塞不下的能力。 */
- api.Sources.clear();
- env.memoryStorage[api.Store.STORAGE_NAME] = {
- config: JSON.stringify({
- version: 6,
- tabs: {
- id: {
- scripts: {
- s1: { checked: true, anchorIds: 'aaa#bbb', enterPage: 'fans' },
- s4: {
- checked: true, keyword: 'fashion',
- anchorFans: true, commentArea: true, commenterFans: true
- }
- }
- }
- }
- })
- };
- const migrated = api.Sources.migrateLegacy();
- check('旧配置能翻译成取材清单', !!migrated && migrated.length > 0,
- migrated ? String(migrated.length) + ' 条' : 'null');
- check('s1 的主播账号与粉丝列表被带过来',
- migrated && migrated[0].kind === 'anchor' && migrated[0].anchors === 'aaa#bbb'
- && migrated[0].lists.join(',') === 'fans',
- migrated ? JSON.stringify(migrated[0]) : 'null');
- check('s4 的三个开关展开成三条(加上 s1 共 4 条)',
- migrated && migrated.length === 4, '共 ' + (migrated ? migrated.length : 0) + ' 条');
- check('作者粉丝 → 视频作者 + 深度粉丝',
- migrated && migrated[1].kind === 'search' && migrated[1].searchType === 'videos'
- && migrated[1].expand === 'fans',
- migrated ? JSON.stringify(migrated[1]) : 'null');
- check('评论区 → 独立的评论区取材',
- migrated && migrated[2].kind === 'video' && migrated[2].expand === '',
- migrated ? JSON.stringify(migrated[2]) : 'null');
- check('评论者的粉丝 → 评论区 + 深度粉丝',
- migrated && migrated[3].kind === 'video' && migrated[3].expand === 'fans',
- migrated ? JSON.stringify(migrated[3]) : 'null');
- /* 已经用过新版的用户:清单非空时**不许**被旧配置覆盖 */
- api.Sources.clear();
- api.Sources.add('text');
- const again = api.Sources.migrateLegacy();
- check('清单非空时不再迁移(用户自己加的取材不被覆盖)',
- again === null && api.Sources.count() === 1,
- again === null ? 'null' : JSON.stringify(again));
- /* 复位:恢复默认配置与一条取材,供后面的用例使用 */
- env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };
- api.Store.init();
- api.Sources.clear();
- api.Sources.add('anchor');
- /* ============================================================ 悬浮球 */
- /* 规格书 §10 / H1–H7。设备端能建窗,桩里建不了 —— 所以这一节验的是
- * **可离线判定的那部分**:显示条件、位置记忆与夹紧、开关双向、以及"建不出来也不许崩"。 */
- console.log('');
- console.log('悬浮球(§10):');
- check('悬浮球默认显示(未隐藏)', api.Store.defaultOf('ball.hidden') === false);
- check('悬浮球默认贴左边', api.Store.defaultOf('ball.side') === 'left');
- check('悬浮球默认垂直居中(比例 500)', api.Store.defaultOf('ball.yRatio') === 500);
- /* 显示条件:卡密有效 + 悬浮窗权限 + 用户没隐藏,三者缺一不可 */
- api.Store.set('license.status', 'active');
- api.Store.set('ball.hidden', false);
- globalThis.floaty.checkPermission = () => true;
- check('三条件齐备时才显示悬浮球', api.FB.canShow() === true);
- globalThis.floaty.checkPermission = () => false;
- check('缺悬浮窗权限 → 不显示', api.FB.canShow() === false);
- globalThis.floaty.checkPermission = () => true;
- api.Store.set('ball.hidden', true);
- check('用户主动隐藏 → 不显示', api.FB.canShow() === false);
- api.Store.set('license.status', 'none');
- api.Store.set('ball.hidden', false);
- check('卡密无效 → 不显示(登录页不该有球)', api.FB.canShow() === false);
- api.Store.set('license.status', 'active');
- /* 「隐藏」是双向的:关掉能建、打开能收(没有恢复入口的话用户就找不回来了) */
- api.FB.setHidden(true);
- check('关掉开关 → 落盘 hidden=true', api.Store.getBool('ball.hidden') === true);
- api.FB.setHidden(false);
- check('打开开关 → 落盘 hidden=false', api.Store.getBool('ball.hidden') === false);
- /* 位置记忆:存比例,换机型也不会把球丢到屏幕外 */
- api.Store.set('ball.side', 'right');
- api.Store.set('ball.yRatio', 250);
- api.FB.restoreAnchor();
- check('右侧 + 25% 位置能正确还原',
- api.FB.side === 'right' && api.FB.anchorCy === Math.round(api.Device.screenHeight() * 0.25),
- api.FB.side + ' / ' + api.FB.anchorCy);
- check('还原后球在屏幕内(X 靠右边缘)',
- api.FB.anchorX > 0 && api.FB.anchorX < api.Device.screenWidth());
- /* 越界/脏数据必须被夹回来:手改配置或换分辨率后,球不能跑到屏幕外 */
- api.Store.set('ball.yRatio', 990);
- api.FB.restoreAnchor();
- check('极端比例也不会把球顶出屏幕',
- api.FB.anchorCy > 0 && api.FB.anchorCy <= api.Device.screenHeight(), String(api.FB.anchorCy));
- api.FB.anchorX = -500;
- api.FB.clampAnchor();
- check('坐标被夹到屏幕内', api.FB.anchorX >= 0, String(api.FB.anchorX));
- api.FB.destroy();
- check('没建过窗时 destroy() 也不报错(幂等)', api.FB.window === null);
- /* 建窗失败必须被吞掉 —— 这条守的是「猜一个不存在的 API → 整个界面起不来」那类事故。
- * 悬浮球现在走**裸 WindowManager**(不是 floaty),所以这里显式让 addView 抛错来造这个场景。
- * 被测的不变量是「异常不许冒到调用方、也不许假装成功」。 */
- const realGetSystemService = globalThis.context.getSystemService;
- globalThis.context.getSystemService = (name) => {
- const svc = realGetSystemService(name);
- if (name === 'window' && svc) {
- return { addView: () => { throw new Error('模拟:设备不支持悬浮窗'); },
- updateViewLayout: svc.updateViewLayout, removeView: svc.removeView };
- }
- return svc;
- };
- globalThis.floaty.checkPermission = () => false;
- api.Store.set('ball.yRatio', 500);
- api.Store.set('ball.side', 'left');
- /* 我的页必须有悬浮球的恢复入口,且权限缺失时要说清原因 */
- const mineTexts = textsOf('mine').join(' | ');
- check('我的页有「悬浮球」开关', mineTexts.indexOf('悬浮球') >= 0, mineTexts.slice(0, 160));
- /* —— 无记录 / 无日志时界面绝不显示 undefined —— */
- const bareTexts = collectText(api.Pages.recordDetail({ id: 'x', taskKey: 'id', result: 'stopped' })).join(' | ');
- check('记录字段缺失时渲染不出现 undefined',
- bareTexts.indexOf('undefined') < 0 && bareTexts.indexOf('null') < 0, bareTexts.slice(0, 200));
- /* 区间行的结构断言:每个区间行必须是「标签 + 两个数字胶囊 + 一个 ~」 */
- console.log('');
- console.log('区间行结构:');
- function rangeRowShape(pageKey, label) {
- /* 找到带该标签的那一行,数一数它下面有几个纯数字胶囊 */
- const rows = [];
- (function walk(view) {
- if (!view) { return; }
- const t = typeof view.getText === 'function' ? String(view.getText()) : '';
- if (t === label) { rows.push(view.parent); }
- const n = typeof view.getChildCount === 'function' ? view.getChildCount() : 0;
- for (let i = 0; i < n; i++) { walk(view.getChildAt(i)); }
- })(renderPage(pageKey));
- if (rows.length === 0) { fail(`[${pageKey}] 找不到「${label}」这一行`); return; }
- const nums = [];
- for (let i = 0; i < rows[0].getChildCount(); i++) {
- const c = rows[0].getChildAt(i);
- /* 同一行里除了两个数字胶囊,还有标签、~ 分隔符、弹性占位 View(没有 getText) */
- const t = typeof c.getText === 'function' ? String(c.getText()) : '';
- if (/^\d+$/.test(t)) { nums.push(t); }
- }
- check(`「${label}」是一行两项(实际数字胶囊 ${nums.length} 个)`, nums.length === 2,
- nums.join(' / '));
- }
- rangeRowShape('dm', '关注数量');
- rangeRowShape('dm', '私信数量');
- rangeRowShape('dm', '休息时间(秒)');
- rangeRowShape('nurture', '浏览时长(分钟)');
- rangeRowShape('nurture', '单次操作');
- rangeRowShape('video', '停止个数');
- rangeRowShape('video', '间隔时间(秒)');
- rangeRowShape('id', '关注量');
- /* 反向断言:不应该再有任何「一个控件承载两个值」的写法。
- * 这类回归很容易发生 —— 加字段时顺手塞进已有的胶囊里就退回去了。 */
- console.log('');
- console.log('反向断言(不应再出现合体控件):');
- const COMBINED = [
- ['dm', '200 / 30', '关注/私信数量合体'],
- ['dm', '100 ~ 500', '休息时间合体'],
- ['video', '20 ~ 20', '间隔时间合体'],
- ['nurture', '10 ~ 30', '单次操作合体'],
- /* 这些是**上一版的旧标签**,改成区间后不该再出现 */
- ['dm', '关注上限', '旧标签「关注上限」应已改为「关注数量」'],
- ['dm', '私信上限', '旧标签「私信上限」应已改为「私信数量」'],
- ['dm', '休息间隔', '旧标签「休息间隔」应已改为「休息时间(秒)」'],
- ['nurture', '单次操作上限', '旧标签「单次操作上限」应已改为「单次操作」'],
- /* ID提取的数量门槛走的是「区间一行」形态,所以**不该**再出现上下限两行 */
- ['id', '关注量下限', '数量门槛又被拆成上下限两行'],
- ['id', '粉丝量上限', '数量门槛又被拆成上下限两行']
- ];
- COMBINED.forEach(([page, needle, why]) => {
- const all = textsOf(page).join(' | ');
- check(`不再出现「${needle}」(${why})`, all.indexOf(needle) < 0);
- });
- /* ============================================================ 场景 C */
- console.log('');
- console.log('=== 场景 C:字段表完整性 ===');
- console.log('');
- const defaults = api.Store.defaults();
- /* ① 每个字段的默认值必须与声明的类型相符 */
- let typeErrors = 0;
- api.Store.FIELDS.forEach((f) => {
- const value = f.path.split('.').reduce((node, seg) => (node === undefined ? undefined : node[seg]), defaults);
- let ok = true;
- if (f.type === 'bool') { ok = typeof value === 'boolean'; }
- else if (f.type === 'int') { ok = typeof value === 'number' && !isNaN(value) && value >= f.min && value <= f.max; }
- else if (f.type === 'enum') {
- const keys = f.values || Object.keys(api.Store.enum[f.group] || {});
- ok = keys.indexOf(value) >= 0;
- } else { ok = typeof value === 'string'; }
- if (!ok) {
- typeErrors += 1;
- fail(`字段 ${f.path} 的默认值 ${JSON.stringify(value)} 与类型 ${f.type} 不符`);
- }
- });
- if (typeErrors === 0) { pass(`${api.Store.FIELDS.length} 个字段的默认值全部与声明类型相符`); }
- /* ② 文本类型的字段默认值不应为空 —— 空串就是「没有默认值」。
- * 例外是显式标了 optional 的字段:它们的空串是真实且合理的状态
- * (例如「云端去重的表名」,功能默认关着,没填就是没填)。 */
- const emptyDefaults = api.Store.FIELDS
- .filter((f) => f.type === 'text' && !f.optional &&
- f.path.indexOf('license.') !== 0 && f.def === '')
- .map((f) => f.path);
- if (emptyDefaults.length === 0) {
- pass('除卡密与显式 optional 的字段外,所有文本字段都有非空默认值');
- } else {
- fail('以下文本字段默认值为空:' + emptyDefaults.join(', '));
- }
- /* ③ 默认配置必须能原样通过校验(幂等)——否则每次启动都会「修正」一遍 */
- const roundTrip = api.Store.validate(defaults);
- if (roundTrip.problems.length === 0) {
- pass('默认配置通过校验且不产生任何修正(幂等)');
- } else {
- fail('默认配置自身就会触发修正:' + roundTrip.problems.join(' | '));
- }
- /* ④ 序列化往返后仍相等 —— 防止存进去再读出来变了样 */
- const revived = api.Store.validate(JSON.parse(JSON.stringify(defaults)));
- if (JSON.stringify(revived.config) === JSON.stringify(defaults)) {
- pass('配置序列化往返后完全一致');
- } else {
- fail('配置序列化往返后发生变化');
- }
- /* ============================================================ 场景 D */
- console.log('');
- console.log('=== 场景 D:输入与弹窗交互 ===');
- console.log('');
- /* 从干净配置起步,避免前面的脏数据影响断言 */
- env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };
- api.Store.init();
- let sheet = null;
- /**
- * 模拟用户在该输入框里逐字输入一个新值:
- * 先连续退格清空(走 TextWatcher),再逐字敲进去。
- * 桩里的 `type` / `eraseAll` 与输入法走同一条通路,所以测的是真实交互路径 ——
- * 不是「直接 setText 一下」那种绕开监听器的假测法。
- */
- function typeInto(control, value) {
- control.root.eraseAll();
- control.root.type(String(value));
- }
- /** 在输入框里敲入值后失焦(= 用户改完点走)。 */
- function fillAndConfirmInline(control, value) {
- control.root.gainFocus();
- typeInto(control, value);
- control.root.loseFocus();
- }
- /* —— 直接输入:所有输入框都不再弹窗 —— */
- console.log('直接输入(不弹窗):');
- const numField = api.W.numberField('tabs.video.stopMax', '停止个数最大值');
- check('数字框是常驻可编辑输入框', typeof numField.root.setInputType === 'function');
- numField.root.performClick();
- check('点输入框不再弹出任何弹窗', api.Dialog.active === null);
- numField.root.gainFocus();
- typeInto(numField, '8');
- check('打字过程中就已写入配置', api.Store.getNumber('tabs.video.stopMax') === 8,
- `实际 ${api.Store.getNumber('tabs.video.stopMax')}`);
- check('输入全过程都没有弹窗', api.Dialog.active === null);
- numField.root.loseFocus();
- check('失焦后栏位显示的就是配置里的值', String(numField.root.getText()) === '8',
- `实际 "${numField.root.getText()}"`);
- /* —— 越界值在失焦时被夹紧并写回栏位(用户能看见被夹了) —— */
- console.log('');
- console.log('越界与清空:');
- const clampField = api.W.numberField('tabs.video.stopMax', '');
- clampField.root.gainFocus();
- typeInto(clampField, '99999');
- check('越界值写入时被字段表夹到上限', api.Store.getNumber('tabs.video.stopMax') === 500,
- `实际 ${api.Store.getNumber('tabs.video.stopMax')}`);
- clampField.root.loseFocus();
- check('失焦后栏位回写成夹紧后的值(不是用户敲的 99999)',
- String(clampField.root.getText()) === '500',
- `实际 "${clampField.root.getText()}"`);
- api.Store.set('tabs.video.stopMax', 42);
- const clearField = api.W.numberField('tabs.video.stopMax', '');
- clearField.root.gainFocus();
- clearField.root.eraseAll();
- check('清空是个中间态,不把配置冲成 0', api.Store.getNumber('tabs.video.stopMax') === 42,
- `实际 ${api.Store.getNumber('tabs.video.stopMax')}`);
- clearField.root.loseFocus();
- check('失焦后栏位恢复成配置里的原值', String(clearField.root.getText()) === '42',
- `实际 "${clearField.root.getText()}"`);
- /* —— 关注 / 私信 是两个独立开关(原先一个开关管两件事)—— */
- console.log('');
- console.log('关注与私信是否真正独立:');
- api.Store.set('tabs.dm.doFollow', true);
- api.Store.set('tabs.dm.doDm', true);
- const followToggle = api.W.toggle(api.Store.getBool('tabs.dm.doFollow'), function (on) {
- api.Store.set('tabs.dm.doFollow', on);
- });
- followToggle.root.performClick();
- check('单独关掉「进行关注」', api.Store.getBool('tabs.dm.doFollow') === false,
- `实际 ${api.Store.getBool('tabs.dm.doFollow')}`);
- check('「进行私信」不受影响,仍为开', api.Store.getBool('tabs.dm.doDm') === true,
- `实际 ${api.Store.getBool('tabs.dm.doDm')}`);
- const dmToggle = api.W.toggle(api.Store.getBool('tabs.dm.doDm'), function (on) {
- api.Store.set('tabs.dm.doDm', on);
- });
- dmToggle.root.performClick();
- check('再单独关掉「进行私信」', api.Store.getBool('tabs.dm.doDm') === false);
- /* —— 关注数量与私信数量是两组独立区间,互不影响 —— */
- console.log('');
- console.log('两组数量区间各自独立:');
- api.Store.set('tabs.dm.followMin', 100);
- api.Store.set('tabs.dm.followMax', 200);
- api.Store.set('tabs.dm.dmMin', 20);
- api.Store.set('tabs.dm.dmMax', 30);
- fillAndConfirmInline(api.W.numberField('tabs.dm.followMax', '关注数量最大值'), '1234');
- check('关注数量上限写入', api.Store.getNumber('tabs.dm.followMax') === 1234,
- `实际 ${api.Store.getNumber('tabs.dm.followMax')}`);
- check('私信数量不受影响',
- api.Store.getNumber('tabs.dm.dmMin') === 20 && api.Store.getNumber('tabs.dm.dmMax') === 30,
- `实际 ${api.Store.getNumber('tabs.dm.dmMin')} ~ ${api.Store.getNumber('tabs.dm.dmMax')}`);
- fillAndConfirmInline(api.W.numberField('tabs.dm.dmMax', '私信数量最大值'), '56');
- check('私信数量上限写入且关注数量不变',
- api.Store.getNumber('tabs.dm.dmMax') === 56 &&
- api.Store.getNumber('tabs.dm.followMax') === 1234,
- `实际 关注 ${api.Store.getNumber('tabs.dm.followMax')} / 私信 ${api.Store.getNumber('tabs.dm.dmMax')}`);
- /* —— 区间联动:下界超过上界时自动抬齐 —— */
- console.log('');
- console.log('区间联动:');
- api.Store.set('tabs.dm.restMin', 100);
- api.Store.set('tabs.dm.restMax', 500);
- fillAndConfirmInline(api.W.numberField('tabs.dm.restMin', '休息时间(秒)最小值'), '2000');
- check('下界写入', api.Store.getNumber('tabs.dm.restMin') === 2000,
- `实际 ${api.Store.getNumber('tabs.dm.restMin')}`);
- check('下界超过上界时,上界被自动抬齐',
- api.Store.getNumber('tabs.dm.restMax') === 2000,
- `实际 ${api.Store.getNumber('tabs.dm.restMin')} ~ ${api.Store.getNumber('tabs.dm.restMax')}`);
- /* —— 区间行:一行一个区间,两个数各自在栏位里直接改 —— */
- console.log('');
- console.log('区间行(最小值 ~ 最大值):');
- api.Store.set('tabs.dm.followMin', 100);
- api.Store.set('tabs.dm.followMax', 200);
- const range = api.W.rangeRow('tabs.dm.followMin', 'tabs.dm.followMax', '关注数量', '~');
- const rangeTexts = [];
- collect(range.root, rangeTexts, 'range', 0);
- const rangeJoined = rangeTexts.map((x) => x.text).join(' | ');
- check('区间行同时显示最小值与最大值',
- rangeJoined.indexOf('100') >= 0 && rangeJoined.indexOf('200') >= 0, rangeJoined);
- check('区间行有 ~ 分隔符', rangeJoined.indexOf('~') >= 0);
- check('区间行是一行(标签里没有「下限/上限」)',
- rangeJoined.indexOf('下限') < 0 && rangeJoined.indexOf('上限') < 0, rangeJoined);
- const numFields = findAll(range.root, isInput, [], 0);
- check('区间行里正好两个可独立编辑的输入框', numFields.length === 2, `实际 ${numFields.length}`);
- check('区间行里没有「点了会弹窗」的胶囊',
- findAll(range.root, (v) => !!(v.listeners && v.listeners.click), [], 0).length === 0);
- typeInto({ root: numFields[0] }, '50');
- check('改最小值只动最小值',
- api.Store.getNumber('tabs.dm.followMin') === 50,
- `实际 ${api.Store.getNumber('tabs.dm.followMin')}`);
- check('改最小值时最大值不受影响',
- api.Store.getNumber('tabs.dm.followMax') === 200,
- `实际 ${api.Store.getNumber('tabs.dm.followMax')}`);
- typeInto({ root: numFields[1] }, '150');
- check('改最大值只动最大值', api.Store.getNumber('tabs.dm.followMax') === 150,
- `实际 ${api.Store.getNumber('tabs.dm.followMax')}`);
- check('改最大值时最小值不受影响', api.Store.getNumber('tabs.dm.followMin') === 50,
- `实际 ${api.Store.getNumber('tabs.dm.followMin')}`);
- /* 联动:最小值超过最大值 → 最大值被抬齐,且**兄弟那一格显示要跟着变**(不重建整页) */
- typeInto({ root: numFields[0] }, '999');
- check('最小值超过最大值时自动抬齐',
- api.Store.getNumber('tabs.dm.followMax') === 999,
- `实际 ${api.Store.getNumber('tabs.dm.followMax')}`);
- check('兄弟输入框的文字就地刷新(不重建整页、滚动位置不丢)',
- String(numFields[1].getText()) === '999',
- `实际 "${numFields[1].getText()}"`);
- check('正在输入的那一格不被回写抢走输入', String(numFields[0].getText()) === '999',
- `实际 "${numFields[0].getText()}"`);
- numFields[0].loseFocus();
- numFields[1].loseFocus();
- /* —— 非法输入:不改配置、不弹窗 —— */
- console.log('');
- console.log('非法输入:');
- const beforeMin = api.Store.getNumber('tabs.dm.restMin');
- const badField = api.W.numberField('tabs.dm.restMin', '休息时间(秒)最小值');
- badField.root.gainFocus();
- /* 数字键盘敲不出字母,但粘贴、输入法联想、外接键盘都可能把非数字送进来。
- * 这里模拟的是「原有文本后面被追加了非数字」,而不是先清空 —— 先清空再输
- * 是另一条路径(见下面的清空重填),混在一起测会掩盖各自的判定。 */
- badField.root.type('abc');
- check('非法输入不弹窗、不改配置', api.Store.getNumber('tabs.dm.restMin') === beforeMin,
- `实际 ${api.Store.getNumber('tabs.dm.restMin')}(应为 ${beforeMin})`);
- check('非法输入也没有替用户清空栏位', String(badField.root.getText()).length > 0);
- badField.root.loseFocus();
- check('失焦后恢复成配置里的值', String(badField.root.getText()) === String(beforeMin),
- `实际 "${badField.root.getText()}"`);
- /* —— 清空后重填:中途不会把配置留在半截数字上 —— */
- console.log('');
- console.log('清空重填:');
- api.Store.set('tabs.dm.restMin', 100);
- const retypeField = api.W.numberField('tabs.dm.restMin', '休息时间(秒)最小值');
- retypeField.root.gainFocus();
- retypeField.root.eraseAll();
- retypeField.root.type('250');
- check('清空重填后落在新值上', api.Store.getNumber('tabs.dm.restMin') === 250,
- `实际 ${api.Store.getNumber('tabs.dm.restMin')}`);
- retypeField.root.loseFocus();
- check('栏位与配置一致', String(retypeField.root.getText()) === '250',
- `实际 "${retypeField.root.getText()}"`);
- /* —— 没失焦就走了:编辑不能丢 —— */
- console.log('');
- console.log('未失焦就离开输入框:');
- api.Store.set('tabs.dm.restMin', 10); /* 先给下界留出空间,否则提交时会被联动抬齐 */
- api.Store.set('tabs.dm.restMax', 500);
- api.W.beginPage(); /* ≙ 本页开始渲染(真机上由 renderContent 调用) */
- const bsField = api.W.numberField('tabs.dm.restMax', '');
- bsField.root.gainFocus();
- bsField.root.backspace(); /* 500 → 50 */
- check('退格期间不立刻落盘(避免 500→50→5 这类半截值被写进配置)',
- api.Store.getNumber('tabs.dm.restMax') === 500,
- `实际 ${api.Store.getNumber('tabs.dm.restMax')}`);
- check('但栏位文字已经是用户改后的 50', String(bsField.root.getText()) === '50',
- `实际 "${bsField.root.getText()}"`);
- api.W.commitPendingInputs(); /* ≙ 用户没失焦就点了别处(切页签 / 开始运行) */
- check('离开前整体提交,这次退格不会丢',
- api.Store.getNumber('tabs.dm.restMax') === 50,
- `实际 ${api.Store.getNumber('tabs.dm.restMax')}`);
- /* —— 单选列表 —— */
- console.log('');
- console.log('单选列表:');
- /* 用 v7 新增的 expandList(只有粉丝/关注两值)验单选窗:
- * 默认是「关注列表」,这里点「粉丝列表」写成 fans,能真实反映写入。 */
- const dropdown = api.W.dropdown('expandList', 'tabs.id.scripts.s3.enterPage');
- dropdown.root.performClick();
- sheet = api.Dialog.active;
- const allOptions = findAll(sheet.root, textIs('关注列表'), [], 0);
- const targetOption = findAll(sheet.root, textIs('粉丝列表'), [], 0);
- check('选择窗列出了全部候选', allOptions.length === 1 && targetOption.length === 1);
- if (targetOption.length === 1) { targetOption[0].performClick(); }
- check('选中项写回配置', api.Store.get('tabs.id.scripts.s3.enterPage') === 'fans',
- `实际 "${api.Store.get('tabs.id.scripts.s3.enterPage')}"`);
- check('选择后弹窗关闭', api.Dialog.active === null);
- /* 复原,免得影响后面的用例 */
- api.Store.set('tabs.id.scripts.s3.enterPage', 'follow');
- /* —— 文本输入:同样直接在栏位里改 —— */
- console.log('');
- console.log('文本输入:');
- const textField = api.W.textField('tabs.video.keyword', '搜索');
- textField.root.performClick();
- check('点文本框不弹窗', api.Dialog.active === null);
- textField.root.gainFocus();
- textField.root.eraseAll();
- textField.root.type('travel');
- check('文本打字即写入配置', api.Store.getText('tabs.video.keyword') === 'travel',
- `实际 "${api.Store.getText('tabs.video.keyword')}"`);
- check('文本输入全程无弹窗', api.Dialog.active === null);
- textField.root.loseFocus();
- check('失焦后栏位与配置一致', String(textField.root.getText()) === 'travel',
- `实际 "${textField.root.getText()}"`);
- /* 文本字段允许空串(合法的「不填」),所以清空就是清空,不该回填默认值 */
- textField.root.gainFocus();
- textField.root.eraseAll();
- textField.root.loseFocus();
- check('文本清空后确实为空(文本字段允许留空)',
- api.Store.getText('tabs.video.keyword') === '',
- `实际 "${api.Store.getText('tabs.video.keyword')}"`);
- /* —— 弹窗唯一性:同时只允许一个 —— */
- console.log('');
- console.log('弹窗唯一性:');
- api.Dialog.alert('测试', '第一个');
- const first = api.Dialog.active;
- api.Dialog.alert('测试', '第二个');
- check('打开新弹窗会自动关掉上一个', api.Dialog.active !== first);
- if (api.Dialog.active) { api.Dialog.active.close(); }
- check('关闭后没有残留弹窗', api.Dialog.active === null);
- /* ============================================================ 场景 E */
- console.log('');
- console.log('=== 场景 E:登录页三态与运行状态条 ===');
- console.log('');
- const VIEW = api.Pages.LICENSE_VIEW;
- function loginTexts() {
- const items = [];
- collect(api.Pages.login(), items, 'login', 0);
- return items.map((x) => x.text).join(' | ');
- }
- VIEW.state = 'idle'; VIEW.message = '';
- api.Store.set('license.draftCode', '');
- check('idle 态提示「卡密为一次性激活凭证…」',
- loginTexts().indexOf('卡密为一次性激活凭证,请勿泄露给他人') >= 0);
- check('idle 态按钮是「登录激活」', loginTexts().indexOf('登录激活') >= 0);
- /* 卡密现在**存在配置里**(`license.draftCode`),不再是内存里的 LICENSE_VIEW.code ——
- * 校验中重绘一次界面,输入框要能重新填出刚才那串,否则用户会以为白打了。 */
- VIEW.state = 'verifying';
- api.Store.set('license.draftCode', 'TK9F-2A7C-8E10-4B55');
- const verifying = loginTexts();
- check('verifying 态按钮变「登录中…」', verifying.indexOf('登录中…') >= 0);
- check('verifying 态提示变「正在校验卡密,请稍候…」',
- verifying.indexOf('正在校验卡密,请稍候…') >= 0);
- check('verifying 态保留已输入的卡密(重绘后输入框仍是那串)',
- verifying.indexOf('TK9F-2A7C-8E10-4B55') >= 0);
- VIEW.state = 'failed'; VIEW.message = '卡密无效或已过期,请检查后重试';
- const failed = loginTexts();
- check('failed 态显示失败原因', failed.indexOf('卡密无效或已过期,请检查后重试') >= 0);
- check('failed 态仍可再次提交(按钮回到「登录激活」)', failed.indexOf('登录激活') >= 0);
- scanLogin('failed');
- api.Pages.LICENSE_RESET();
- check('复位后回到 idle,且草稿保留(下次进来不用重打)',
- VIEW.state === 'idle' && VIEW.message === '' &&
- api.Store.getText('license.draftCode') === 'TK9F-2A7C-8E10-4B55',
- `state=${VIEW.state} draft="${api.Store.getText('license.draftCode')}"`);
- api.Store.set('license.draftCode', '');
- /* 运行状态条:文案与计时格式 */
- const bar = api.W.runBar('脚本一 · 运行中', '00:00:00');
- const barTexts = [];
- collect(bar.root, barTexts, 'bar', 0);
- const barJoined = barTexts.map((x) => x.text).join(' | ');
- check('运行状态条含脚本名与时长',
- barJoined.indexOf('脚本一 · 运行中') >= 0 && barJoined.indexOf('00:00:00') >= 0);
- bar.setElapsed('05:06:07');
- const barTexts2 = [];
- collect(bar.root, barTexts2, 'bar', 0);
- check('状态条可就地刷新时长(不重建整页)',
- barTexts2.map((x) => x.text).join(' | ').indexOf('05:06:07') >= 0);
- /* 时长格式化:超过 24 小时不回绕,否则长跑会显示成"刚开始" */
- check('时长格式化为 01:02:05',
- api.Session.formatElapsed(3725000) === '01:02:05',
- api.Session.formatElapsed(3725000));
- check('时长超过 24 小时不回绕',
- api.Session.formatElapsed(90061000) === '25:01:01',
- api.Session.formatElapsed(90061000));
- check('未运行时长为 0', api.Session.elapsedMs() === 0);
- /* ============================================================ 场景 F */
- console.log('');
- console.log('=== 场景 F:所有可输入 / 可选择的控件都要落盘,且重启后自动读回 ===');
- console.log('');
- env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };
- api.Store.init();
- /* —— F1. 界面上绑定的每个路径,都必须在字段表里 ——
- *
- * 这是最隐蔽的一类「改了但存不住」:路径拼错(例如 `tabs.video.stopCount` 写成
- * `stopCounts`)时 `Store.set` 照样写进内存,但下次启动 `validate()` 是按**字段表**
- * 重建配置的 —— 表外的键会被直接丢掉,用户的改动就凭空消失,且全程没有任何报错。
- *
- * 怎么拿到「界面上绑定了哪些路径」:所有取值函数(getNumber/getText/getBool/getEnum)
- * 最终都走 `Store.get`,而每个值控件在渲染时都会读一次自己的路径。
- * 所以渲染一遍 5 个页面、记录所有读过的路径即可,不必去翻代码。 */
- const readPaths = {};
- const rawGet = api.Store.get;
- api.Store.get = function (p, fb) { readPaths[p] = true; return rawGet.call(api.Store, p, fb); };
- const boundPaths = {};
- function sweepPages() {
- PAGES.forEach((key) => {
- api.W.beginPage();
- const page = api.Pages[key](() => { });
- if (!page) { fail(`渲染 ${PAGE_TITLE[key]} 时返回了空视图`); return; }
- /* 绑定配置的输入框是「写入端」,即使渲染时没读也一定要算进来 */
- Object.keys(api.W.pendingFields).forEach((p) => { boundPaths[p] = true; });
- });
- /* 登录页也是界面的一部分(未激活时它就是整个界面),同样要扫 */
- api.W.beginPage();
- api.Pages.login();
- Object.keys(api.W.pendingFields).forEach((p) => { boundPaths[p] = true; });
- }
- sweepPages();
- /* ★ 有些控件是**条件渲染**的(勾上「云端去重」才出现表名输入框、
- * 勾上「AI 头像识别」才出现 AI 性别下拉)。只按默认值扫一遍会把它们当成
- * 「没有界面的字段」,所以把所有布尔开关都打开再扫一遍。
- * 这样扫出来的路径集合才是「界面上真正可能出现的全部路径」。 */
- const boolFields = api.Store.FIELDS.filter((f) => f.type === 'bool').map((f) => f.path);
- const boolBackup = {};
- boolFields.forEach((p) => { boolBackup[p] = api.Store.get(p, false); api.Store.set(p, true); });
- sweepPages();
- /* ★ 还有一类条件渲染是**枚举驱动**的:脚本二选「关键词搜索」才出现关键词输入框。
- * 布尔那一轮扫不到它(它不是一个开关),于是会被误判成「没有界面的字段」。
- * 枚举每组只有 2~4 个取值,逐个取值各扫一遍,代价可控。
- *
- * ⚠️ 必须放在**布尔全开还没恢复**的时候扫:卡片的展开本身是个 bool,
- * 先恢复的话四张卡都折回去了,卡内的条件控件一个都扫不到(踩过)。 */
- const enumFields = api.Store.FIELDS.filter((f) => f.type === 'enum' && f.group);
- enumFields.forEach((f) => {
- const backup = api.Store.get(f.path);
- const keys = Object.keys(api.Store.enum[f.group] || {});
- keys.forEach((k) => {
- api.Store.set(f.path, k);
- sweepPages();
- });
- api.Store.set(f.path, backup);
- });
- boolFields.forEach((p) => { api.Store.set(p, boolBackup[p]); });
- Object.keys(readPaths).forEach((p) => { boundPaths[p] = true; });
- const allPathList = Object.keys(boundPaths);
- const notInTable = allPathList.filter((p) => !api.Store.fieldOf(p));
- check(`界面绑定了 ${allPathList.length} 个配置路径,且**全部**在字段表里`,
- notInTable.length === 0,
- '表外路径(重启会被丢弃):' + notInTable.join(', '));
- /* 数量下限:扫出来的路径太少说明这个覆盖检查本身失效了 */
- check('覆盖检查确实扫到了足够多的路径(≥ 40)', allPathList.length >= 40,
- `只扫到 ${allPathList.length} 个`);
- /* ID提取四张脚本卡的字段必须**都有**界面控件。
- * 它们是**条件渲染**的(要展开那张卡才出现),所以这条同时也是在验证
- * 上面「把所有 bool 打开再扫一遍」真的覆盖到了展开态 ——
- * 只要有一张卡的字段没被扫到,就说明它展开后其实什么都没渲染。 */
- const idCardPaths = api.Store.FIELDS
- .filter((f) => /^tabs\.id\.scripts\.s[1-4]\./.test(f.path))
- .map((f) => f.path);
- const idCardMissing = idCardPaths.filter((p) => !boundPaths[p]);
- check(`ID提取四张脚本卡的 ${idCardPaths.length} 个字段都有界面控件`,
- idCardMissing.length === 0,
- '没有控件的字段:' + idCardMissing.join(', '));
- /* 只读路径不该出现:能读就意味着能写 */
- const uiWritable = allPathList.filter((p) => {
- const f = api.Store.fieldOf(p);
- return f && f.type !== undefined;
- });
- check('扫到的路径都是字段表里声明过的字段', uiWritable.length === allPathList.length,
- `${allPathList.length - uiWritable.length} 个无声明`);
- /* —— F2. 逐个控件驱动:操作 → 落盘 → 模拟重启 → 读回 ——
- *
- * 「模拟重启」= 把内存里的 state 丢掉、重新从 storages 读一遍。
- * 这是真机上「杀进程再打开」的等价物,也是这个场景唯一有意义的验证方式 ——
- * 只在内存里对得上、重启就变回默认值,那种「保存」等于没保存。 */
- /**
- * 渲染某个页面。
- *
- * 两层都从这里进:一级 tab 走 TAB_PAGES(任务/记录/我的),
- * 二级任务详情走 DETAIL_PAGES(ID提取/UID私信/养号/视频功能)。
- * 每次渲染前 `W.beginPage()`:它清空「待提交输入框」登记表,
- * 于是 renderPage 之后 `W.pendingFields` 必然是**本页**的控件(场景 F 靠它列控件)。
- */
- function renderPage(key) {
- api.W.beginPage();
- if (DETAIL_PAGES.indexOf(key) >= 0) { return api.App.DETAIL_PAGES[key](() => { }); }
- return api.App.TAB_PAGES[key]();
- }
- function restartApp() {
- api.Store.state = null;
- api.Store.init();
- }
- /** 按文字找控件:文字可能在子节点上,监听挂在父级容器上,所以往上找带监听的节点。 */
- function clickByLabel(root, text) {
- const hits = findAll(root, textIs(text), [], 0);
- for (let i = 0; i < hits.length; i++) {
- let cur = hits[i];
- for (let hop = 0; hop < 4 && cur; hop++) {
- if (cur.listeners && cur.listeners.click) { cur.performClick(); return true; }
- cur = cur.parent;
- }
- }
- return false;
- }
- /** 某行「标签」右侧的那个控件(开关/下拉都在标签所在行的末位)。 */
- function controlRightOf(root, label) {
- const hits = findAll(root, textIs(label), [], 0);
- if (hits.length === 0 || !hits[0].parent) { return null; }
- const row = hits[0].parent;
- return row.getChildAt(row.getChildCount() - 1);
- }
- /** 勾选框在「标签所在行」的第一个子节点上(结构:勾选框 + 间距 + 标签)。 */
- function boxLeftOf(root, label) {
- const hits = findAll(root, textIs(label), [], 0);
- if (hits.length === 0) { return null; }
- let cur = hits[0];
- for (let hop = 0; hop < 4 && cur; hop++) {
- if (cur.getChildCount && cur.getChildCount() > 1) { return cur.getChildAt(0); }
- cur = cur.parent;
- }
- return null;
- }
- console.log('');
- console.log('勾选框(ID提取 · 过滤私密账户):');
- let page = renderPage('id');
- const box = boxLeftOf(page, '过滤私密账户');
- check('找得到「过滤私密账户」的勾选框', !!box);
- if (box) {
- const before = api.Store.getBool('tabs.id.features.filterPrivate');
- box.parent.performClick();
- const after = api.Store.getBool('tabs.id.features.filterPrivate');
- check('点一下 → 配置立即变化', after !== before, `${before} → ${after}`);
- restartApp();
- check('模拟重启后仍是新值', api.Store.getBool('tabs.id.features.filterPrivate') === after);
- const box2 = boxLeftOf(renderPage('id'), '过滤私密账户');
- /* ★ 断言「界面与配置一致」,不是「一定是勾上的」——
- * 默认值本来就是 true,点一下变 false,写死期望值会把正确行为判成失败。 */
- check('重启后勾选框显示的状态与配置一致',
- box2 && String(box2.getText()) === (after ? '✓' : ''),
- box2 ? `实际 "${box2.getText()}",配置为 ${after}` : '没找到');
- api.Store.set('tabs.id.features.filterPrivate', before);
- }
- console.log('');
- console.log('开关(UID私信 · 进行关注):');
- page = renderPage('dm');
- const followChip = controlRightOf(page, '进行关注');
- check('找得到「进行关注」右侧的开关', !!followChip);
- if (followChip) {
- const before = api.Store.getBool('tabs.dm.doFollow');
- followChip.performClick();
- const after = api.Store.getBool('tabs.dm.doFollow');
- check('点一下 → 配置立即变化', after !== before, `${before} → ${after}`);
- restartApp();
- check('模拟重启后仍是新值', api.Store.getBool('tabs.dm.doFollow') === after);
- const chip2 = controlRightOf(renderPage('dm'), '进行关注');
- check('重启后开关显示的是新状态',
- chip2 && String(chip2.getText()) === api.Store.label('toggle', after ? 'on' : 'off'),
- chip2 ? `实际 "${chip2.getText()}"` : '没找到');
- api.Store.set('tabs.dm.doFollow', before);
- }
- console.log('');
- console.log('下拉框(养号 · 点赞概率):');
- page = renderPage('nurture');
- const rateChip = controlRightOf(page, '点赞概率');
- check('找得到「点赞概率」右侧的下拉胶囊', !!rateChip);
- if (rateChip) {
- const before = api.Store.get('tabs.nurture.likeRate');
- const beforeLabel = api.Store.label('likeRate', before);
- rateChip.performClick();
- const sheet2 = api.Dialog.active;
- check('点开后会弹出选择窗', !!sheet2);
- if (sheet2) {
- /* 选一个不是当前项的候选 */
- const keys = Object.keys(api.Store.enum.likeRate);
- const targetKey = keys.filter((k) => k !== before)[0];
- const targetLabel = api.Store.label('likeRate', targetKey);
- clickByLabel(sheet2.root, targetLabel);
- }
- const after = api.Store.get('tabs.nurture.likeRate');
- check('选择后配置立即变化', after !== before, `${beforeLabel} → ${api.Store.label('likeRate', after)}`);
- restartApp();
- check('模拟重启后仍是新选项', api.Store.get('tabs.nurture.likeRate') === after);
- const chip2 = controlRightOf(renderPage('nurture'), '点赞概率');
- check('重启后下拉框显示的是新选项',
- chip2 && String(chip2.getText()) === api.Store.label('likeRate', after),
- chip2 ? `实际 "${chip2.getText()}"` : '没找到');
- api.Store.set('tabs.nurture.likeRate', before);
- }
- console.log('');
- console.log('输入框与区间行(视频功能 · 关键词 / 停止个数):');
- page = renderPage('video');
- const kwInput = api.W.pendingFields['tabs.video.keyword'];
- const stopMinInput = api.W.pendingFields['tabs.video.stopMin'];
- check('页面把「关键词」登记为可写输入框', !!kwInput);
- check('页面把「停止个数最小值」登记为可写输入框', !!stopMinInput);
- if (kwInput && stopMinInput) {
- kwInput.root.eraseAll();
- kwInput.root.type('nike');
- stopMinInput.root.eraseAll();
- stopMinInput.root.type('9');
- check('打字即落盘(文本 + 数字)',
- api.Store.getText('tabs.video.keyword') === 'nike' &&
- api.Store.getNumber('tabs.video.stopMin') === 9,
- `关键词 "${api.Store.getText('tabs.video.keyword')}" / 最小值 ${api.Store.getNumber('tabs.video.stopMin')}`);
- restartApp();
- check('模拟重启后两个值都还在',
- api.Store.getText('tabs.video.keyword') === 'nike' &&
- api.Store.getNumber('tabs.video.stopMin') === 9);
- renderPage('video');
- const kw2 = api.W.pendingFields['tabs.video.keyword'];
- const min2 = api.W.pendingFields['tabs.video.stopMin'];
- check('重启后界面上的输入框就是新值',
- kw2 && String(kw2.root.getText()) === 'nike' &&
- min2 && String(min2.root.getText()) === '9',
- kw2 ? `实际 "${kw2.root.getText()}" / "${min2 ? min2.root.getText() : '?'}"` : '没找到');
- }
- console.log('');
- console.log('多选开关(视频功能 · 执行操作里的「点赞」):');
- page = renderPage('video');
- const likeBefore = api.Store.getBool('tabs.video.actions.like');
- check('点一下「点赞」能改状态', clickByLabel(page, '点赞'));
- const likeAfter = api.Store.getBool('tabs.video.actions.like');
- check('「点赞」开关的改动已落盘', likeAfter !== likeBefore, `${likeBefore} → ${likeAfter}`);
- restartApp();
- check('模拟重启后仍是新状态', api.Store.getBool('tabs.video.actions.like') === likeAfter);
- check('其余三个动作开关没被误改',
- api.Store.getBool('tabs.video.actions.follow') === api.Store.defaultOf('tabs.video.actions.follow') &&
- api.Store.getBool('tabs.video.actions.dm') === api.Store.defaultOf('tabs.video.actions.dm'));
- api.Store.set('tabs.video.actions.like', likeBefore);
- console.log('');
- console.log('');
- console.log('取材卡片(删除 / 落盘):');
- /** 清单条数的断言(写成小函数,失败的提示里带实际条数)。 */
- function eqSources(want, label) {
- check(label, api.Sources.count() === want, '实际 ' + api.Sources.count() + ' 条');
- }
- /* 页面点任何东西都会调 `Pages.requestRerender()`,真机上由 `App.renderContent` 注入。
- * 这里直接渲染页面,所以要自己补上 —— 这正是「界面靠 App 装配」的那部分契约。 */
- api.Pages.requestRerender = () => { };
- api.App.activeTab = 'id';
- api.Sources.clear();
- api.Sources.add('anchor');
- api.Sources.add('text');
- eqSources(2, '准备两条取材');
- page = renderPage('id');
- const delButtons = findAll(page, textIs('删除'), [], 0);
- check('每条取材卡片都有「删除」按钮', delButtons.length === 2, '找到 ' + delButtons.length + ' 个');
- if (delButtons.length === 2) {
- delButtons[0].performClick();
- eqSources(1, '点删除后清单真的少了一条');
- check('删掉的是第 1 条(剩下的那条是文本文件)',
- api.Sources.list()[0].kind === 'text', api.Sources.list()[0].kind);
- /* 落盘 + 模拟重启:取材是**另一个储物柜**,这条专门验它真的存进了 storages,
- * 而不是只活在内存里(那会让用户加完取材、杀进程再打开就全没了)。 */
- restartApp();
- eqSources(1, '模拟重启后取材还在');
- }
- /* 起点切换要能把改动写进清单(下拉是页面里唯一"改数据但不走 Store"的控件) */
- api.Sources.clear();
- const oneForEdit = api.Sources.add('anchor');
- api.Sources.update(oneForEdit.id, { anchors: 'zzz', lists: ['following'] });
- check('改过的字段能读回', api.Sources.find(oneForEdit.id).anchors === 'zzz'
- && api.Sources.find(oneForEdit.id).lists.join(',') === 'following',
- JSON.stringify(api.Sources.find(oneForEdit.id)));
- restartApp();
- check('模拟重启后取材字段还在',
- api.Sources.find(oneForEdit.id).anchors === 'zzz',
- JSON.stringify(api.Sources.find(oneForEdit.id)));
- console.log('');
- console.log('文件路径(UID私信 · 随机私信文本):');
- env.memoryStorage[api.Store.STORAGE_NAME] = { config: JSON.stringify(api.Store.defaults()) };
- api.Store.init();
- page = renderPage('dm');
- const pathInput = api.W.pendingFields['tabs.dm.dmTextPath'];
- check('路径也是可写输入框', !!pathInput);
- if (pathInput) {
- pathInput.root.eraseAll();
- pathInput.root.type('/sdcard/Download/my_dm.txt');
- check('手输的路径已落盘',
- api.Store.getText('tabs.dm.dmTextPath') === '/sdcard/Download/my_dm.txt',
- `实际 "${api.Store.getText('tabs.dm.dmTextPath')}"`);
- restartApp();
- check('模拟重启后路径还在',
- api.Store.getText('tabs.dm.dmTextPath') === '/sdcard/Download/my_dm.txt');
- }
- console.log('');
- console.log('登录页卡密草稿(还没点「登录激活」也要落盘):');
- api.Store.set('license.status', 'none');
- api.Store.set('license.draftCode', '');
- api.Pages.LICENSE_RESET();
- api.Pages.login();
- const codeField = api.W.pendingFields['license.draftCode'];
- check('登录页的卡密框绑定了配置路径', !!codeField);
- if (codeField) {
- codeField.root.eraseAll();
- codeField.root.type('ABCD-1234-EFGH-5678');
- check('打字即落盘(不用等提交)',
- api.Store.getText('license.draftCode') === 'ABCD-1234-EFGH-5678',
- `实际 "${api.Store.getText('license.draftCode')}"`);
- restartApp();
- api.Pages.login();
- const codeAgain = api.W.pendingFields['license.draftCode'];
- check('模拟重启后卡密框里还是那串(不用重打 16 位)',
- codeAgain && String(codeAgain.root.getText()) === 'ABCD-1234-EFGH-5678',
- codeAgain ? `实际 "${codeAgain.root.getText()}"` : '没找到');
- api.Store.set('license.draftCode', '');
- }
- /* ★ 2026-10-13 修正:这一段以前是「因错误的原因」通过的。
- *
- * 原先它直接 `Store.set('license.draftCode', 'TK9F-…')` 就提交,**绕过输入框**:
- * 栏位里还留着上一段的 'ABCD-…',配置里却是 'TK9F-…',两者不一致。
- * 以前没有挂载界面骨架,`enterApp → renderScreen` 会因为 `contentHost` 不存在
- * 提前抛错,`commitPendingInputs()` 根本没跑到,于是"通过"了 —— 纯属侥幸。
- * (对照实验:把 2026-10-11 的旧产物拿来跑同一段时序,行为与现在完全一致,
- * 说明这不是改版引入的回归,而是这条断言一直在测一个不存在的场景。)
- *
- * 现在按真机的方式跑:**在栏位里打字**,再提交。这也是唯一能在设备上发生的形态 ——
- * 用户不可能隔着一个正在显示的输入框去改配置。 */
- api.Store.state = null;
- api.Store.init();
- api.Store.set('license.status', 'none');
- api.Store.set('license.draftCode', '');
- /* 这里刻意**先**渲染再打字:栏位文字就是配置(空串),不一致的状态不会出现 */
- api.Pages.LICENSE_RESET();
- api.Pages.login();
- const codeBox = api.W.pendingFields['license.draftCode'];
- check('提交前能找到登录页卡密框', !!codeBox);
- if (codeBox) {
- codeBox.root.eraseAll();
- codeBox.root.type('TK9F-2A7C-8E10-4B55');
- }
- check('提交前配置里的草稿就是栏位里的那串',
- api.Store.getText('license.draftCode') === 'TK9F-2A7C-8E10-4B55',
- `实际 "${api.Store.getText('license.draftCode')}"`);
- api.App.submitLicense('TK9F-2A7C-8E10-4B55');
- check('激活成功后卡密已落盘', api.Store.getText('license.code') === 'TK9F2A7C8E104B55',
- `实际 "${api.Store.getText('license.code')}"`);
- check('激活成功后草稿保留(解绑后回来不用重打)',
- api.Store.getText('license.draftCode') === 'TK9F-2A7C-8E10-4B55',
- `实际 "${api.Store.getText('license.draftCode')}"`);
- /* ★ 顺序陷阱——**固化下来**,别让后来者"顺手"把它改回去:
- * 若在成功回调里清空草稿(`Store.set('license.draftCode','')`),
- * 紧接着的 `enterApp → renderContent → commitPendingInputs()` 会用
- * 登录页输入框里还留着的文字把它写回来 —— 真机上就是「清了等于没清」。
- * 所以本用例的第二条断言测的是"草稿要保留",而这条测的是"清不掉"这件事本身。 */
- api.Store.set('license.status', 'none');
- api.Pages.login(); /* 登录页当前就在屏幕上:栏位已登记、显示 TK9F-… */
- api.Store.set('license.draftCode', ''); /* 模拟"在成功回调里清了草稿" */
- api.App.enterApp(); /* 会走 commitPendingInputs,栏位内容写回配置 */
- check('输入框里的文字会在切屏时把「已清空的草稿」写回来(清了等于没清)',
- api.Store.getText('license.draftCode') === 'TK9F-2A7C-8E10-4B55',
- `实际 "${api.Store.getText('license.draftCode')}"`);
- api.Store.set('license.status', 'none');
- api.Store.set('license.draftCode', '');
- api.App.screen = 'app';
- /* —— F3. 落盘的内容就是「配置全量」,不是一个子集 —— */
- console.log('');
- console.log('落盘内容的完整性:');
- const persisted = JSON.parse(env.memoryStorage[api.Store.STORAGE_NAME].config);
- let flatCount = 0;
- function countLeaves(node) {
- if (node === null || typeof node !== 'object') { flatCount += 1; return; }
- Object.keys(node).forEach((k) => countLeaves(node[k]));
- }
- countLeaves(persisted);
- check('落盘 JSON 里的叶子节点数 ≥ 字段表声明的字段数',
- flatCount >= api.Store.FIELDS.length,
- `落盘 ${flatCount} / 字段表 ${api.Store.FIELDS.length}`);
- check('落盘 JSON 带版本号(老存档才可被迁移)', typeof persisted.version === 'number');
- /* 字段表里没有界面控件的字段 —— 它们由程序写入,不是「用户能改的值」。
- * 列出来是为了让「哪些状态没有对应控件」一目了然,不是失败项。 */
- const PROGRAMMATIC = {
- activeTab: '底部页签栏的当前页(由点页签写入,本身不是一个「值控件」)',
- activeDetail: '当前打开的二级页(由进/退详情页写入,同样不是一个「值控件」)',
- 'ball.side': '悬浮球吸附到哪一边(拖动松手时写入,没有对应的界面控件)',
- 'ball.yRatio': '悬浮球的垂直位置比例(拖动松手时写入,存比例而不是像素)',
- /* v7:AI 三项归入「AI 画像识别 · 暂不支持」这一行灰字。
- * 值仍保留在配置里(将来接上模型直接生效),但界面**故意不给控件** ——
- * 给一个开了什么也不发生的开关,比不给更糟。 */
- 'tabs.id.features.aiGender': 'AI 性别:v7 起随「AI 画像识别」一起标为暂不支持,值保留、暂无控件',
- 'tabs.id.features.aiAgeMax': 'AI 年龄:同上'
- };
- const noUiFields = api.Store.FIELDS
- .filter((f) => !boundPaths[f.path])
- .map((f) => f.path);
- console.log('');
- console.log(` 字段表共 ${api.Store.FIELDS.length} 个字段,其中 ${noUiFields.length} 个没有界面控件:`);
- if (noUiFields.length === 0) {
- console.log(' (无 —— 每个字段都有对应控件)');
- } else {
- noUiFields.forEach((p) => {
- console.log(' · ' + p + (PROGRAMMATIC[p] ? ' —— ' + PROGRAMMATIC[p] : ' ⚠ 既没有控件、也没登记为程序写入'));
- });
- }
- /* 除了明确登记为「程序写入」的那几个,不该再有别的手段没有控件 ——
- * 那意味着要么界面漏做了,要么字段表里留了没人用的死字段。 */
- const unaccounted = noUiFields.filter((p) => !PROGRAMMATIC[p]);
- check('没有控件的字段都已明确登记为「程序写入」',
- unaccounted.length === 0,
- '未登记:' + unaccounted.join(', '));
- /* ------------------------------------------------------------ 汇总 */
- console.log('');
- if (issueCount > 0) {
- console.log(`AUDIT_FOUND_ISSUES=${issueCount}`);
- process.exit(1);
- }
- console.log('AUDIT_CLEAN');
|