#!/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');