diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 2074247..dfb3efb 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -12,7 +12,8 @@ import { readdirSync } from 'node:fs'; import { code, prose, stripComments } from './lib/read.mjs'; import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk, - noteBusySkip, noteRan, busyLimit, busyStreak } from './lib/harmony-device.mjs'; + noteBusySkip, noteRan, busyLimit, busyStreak, + noteBehavioralRan, behavioralRanAt } from './lib/harmony-device.mjs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; @@ -852,6 +853,13 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout `底栏导航项(bounds=${it.attributes.bounds})要至少亮一个源码定义过的标签;` + `实际文字:${texts.join('、')} —— 一个都没命中 NAV_ITEMS(live ⊆ source 被破坏)`); } + /* + * ★ 走到这里 = 行为层**真的跑绿了** ⇒ 留一条"本机验过"的记录(pi 2026-09-18 闸 (ii))。 + * 这是"结算(移出 STATIC_ONLY)"的前提:没有这条记录,那条判据就只是被**挪走**、 + * 不是被**升级** —— 而 `static=` 那个余额会读起来像"又清了一条"。 + * ⚠️ 位置必须在**全部断言之后**:红了也记 = 把没验过的当成验过了。 + */ + noteBehavioralRan(CRIT); }); /* diff --git a/client/electron/test/lib/harmony-device.mjs b/client/electron/test/lib/harmony-device.mjs index 5116a1e..e183037 100644 --- a/client/electron/test/lib/harmony-device.mjs +++ b/client/electron/test/lib/harmony-device.mjs @@ -104,6 +104,51 @@ export function busyStreak(criterion) { /** 供报文用:本机繁忙上限 K。 */ export const busyLimit = () => busyLimitOf(); +/* + * ─── "本机真的跑过行为层"的记录(pi 2026-09-18 闸 (ii) 的证据)─── + * + * 问题:把一条判据从 `STATIC_ONLY` **移除**(7→6)现在是个纯**记账动作** —— + * 它只是从"到期闸点名"挪到"行为条管辖",而**没有任何东西要求行为条真的执行过**。 + * ⇒ 一条判据可以在"已升级"的名义下**永远不跑**。 + * + * pi 用变异证明了后果:设备不在时注入一条真实回归,红清单与基线**逐条一致**(零痕迹)。 + * 于是 `static=6` 这个余额读起来像"又清了一条",实际可能是"又一条进了永久 skip"。 + * 与上面那条同源:**余额里读不出来的东西等于不存在**。 + * + * ⇒ 行为条**真绿一次**就在这里留一条记录;`run-all` 拿它当"结算(移出 STATIC_ONLY)" + * 的前提。记录是**本机的**(`.tmp/`,已 gitignore)—— 判的是"**这台机器上**验过", + * 换机器不继承,这正是对的:换一台没设备的机器,"验过"不该跟着走。 + */ +const ranPath = () => process.env.AGENTMAIL_BEHAVIORAL_RAN + || join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '.tmp', 'harmony-behavioral-ran.json'); + +function readRan() { + try { + const d = JSON.parse(prose(ranPath())); + return (d && typeof d === 'object') ? d : {}; + } catch { + return {}; + } +} + +/** + * 行为条**真的跑绿了**(断言全过)⇒ 记一笔。 + * ⚠️ 只在**断言之后**调用:红了也记,就等于"把没验过的当成验过了",比不记更糟。 + */ +export function noteBehavioralRan(criterion) { + const d = readRan(); + d[criterion] = new Date().toISOString(); + try { + mkdirSync(dirname(ranPath()), { recursive: true }); + writeFileSync(ranPath(), JSON.stringify(d, null, 2)); + } catch { /* 记不下不该让判据崩;run-all 那边把"没记录"当未升级(保守方向) */ } +} + +/** 本机有没有"这条行为层真跑过"的记录?返回时间戳或 null。 */ +export function behavioralRanAt(criterion) { + return readRan()[criterion] || null; +} + /** * 找到能跑的 hdc 二进制。候选顺序:SDK 文档安装根下的 hdc,再 PATH 上的 hdc。 * 返回**跑通了 `list targets`** 的那条(status 0);都不行返回 null。 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 56b0c90..a20a28f 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -407,6 +407,21 @@ for (const [file, flags, expected] of SUITE) { * `check()` 风格的文件没有跳过机制 ⇒ 恒 0。 */ const nodeSkip = /^# skipped (\d+)/m.exec(out); + /* + * ★★ 跳过的**具名**与**原因**(pi 2026-09-18 的闸 (i) 前半)。 + * + * 上面那格 `skip=N` 让"跳过"在余额里**数得出来**了,但**看不出来是谁**: + * 实测(变异验证)—— + * 设备在 + 注入一条真实回归 ⇒ `fail=5 … red=10`,红清单里有 `harmony-nav`; + * 设备不在 + **同一条回归** ⇒ `fail=4 skip=1 … red=9`,红清单与**基线逐条一致**。 + * ⇒ **一条真实回归在设备不在时零痕迹**。`skip=1` 那个数不具名, + * 与"设备恰好不在、什么都没坏"**不可区分**。 + * (pi 的话:这与"红的判据"应当同级 —— 红的能点名,跳过的也必须能点名。) + * + * node:test 其实**已经**把原因打出来了(`ok 16 - … # SKIP 设备不在 —— …`), + * 是这段解析只取了计数、把名字和原因丢了。⇒ 取回来。 + */ + const nodeSkips = parseSkips(out); /* * `# tests` 缺失时**回退**到 `# pass + # fail + # skipped`(不是 `# pass`)—— * 老版本 node 或输出被截断时,和才等于总条数;单取 `# pass` 正是上面那个 bug。 @@ -447,6 +462,8 @@ for (const [file, flags, expected] of SUITE) { * 也不能让它落在 `pass = checks - fail` 里冒充通过。 */ skip: fileChecks === null ? null : fileSkip, + /* 跳过的**明细**(名字 + 原因):让"谁没跑"能像"谁红了"一样被点名。 */ + skips: fileChecks === null ? [] : nodeSkips, exitCode: r.status, }); /* @@ -725,6 +742,20 @@ function hdcServerLooksHealthy() { /** 探针结论 → 是否等于"前提成立(到期)" */ const probeIsDue = v => v === true || v === 'unknown'; +/* + * 从 node:test 的 TAP 输出里取"哪几条被跳过了、为什么"。 + * + * ★ 必须是**唯一实现**(`--skip-selftest` 要验的就是这个函数)。 + * 我第一版把正则**抄了一份**进自检(当时的想法是"自检不该依赖被测对象")—— + * 而那样一来自检验的是**副本**:生产那份改坏了,自检照样绿。 + * 这正是本仓反复消的"同一个事实多份实现"(`blurStyleFor`、`stripStrings` 兄弟副本 + * 都栽在这上面),所以只留这一份,自检**直接调它**。 + */ +function parseSkips(out) { + return [...(out || '').matchAll(/^(?:ok|not ok) \d+ - (.+?) # SKIP(.*)$/gm)] + .map(m => ({ name: m[1].trim(), why: m[2].trim().replace(/^[-–—::]\s*/, '') })); +} + /** 只能验形态的判据:文件 + 为什么只能静态 + 到期前提 */ /* * 探针自检(`--probe-selftest`):它判的是**判据自己的分辨力** —— @@ -745,6 +776,45 @@ if (process.argv.includes('--probe-selftest')) { } process.exit(bad ? 1 : 0); } + +/* + * 跳过解析自检(`--skip-selftest`):与上面那条同形状 —— 判的是**解析器的分辨力**。 + * + * 为什么需要:闸 (i) 的"具名跳过"完全依赖从 node:test 的 TAP 输出里取回 + * 名字与原因。那个正则哪天写坏了(`# SKIP` 大小写、` - ` 分隔、原因前缀的破折号), + * **表现是"跳过的判据"那一行不打印** —— 而"没打印"和"没有跳过"长得一模一样。 + * ⇒ 拿合成 TAP 行把三种情形钉住:带原因的、不带原因的、以及**不该被当成跳过的**普通通过行。 + */ +if (process.argv.includes('--skip-selftest')) { + // ★ 调**生产那一个**函数,不抄副本 —— 抄副本的话自检验的是副本(见 parseSkips 的注释)。 + const parse = parseSkips; + const cases = [ + /* + * ⚠️ 第一条我第一版写**错**了:拿的是"不带 ` # SKIP` 标记的行"却期望解析出 1 条 —— + * 自检当场红("解析出 0 条")。真输出里的跳过行**一定**带 ` # SKIP <原因>`, + * 所以第一条要用**真形状**;而不带标记的行正是后面那条要钉的"不许被当成跳过"。 + * (顺带:这正是自检该有的样子 —— 它连**我写测试时的错**都抓到了。) + */ + ['真形状:` # SKIP` + 原因', 'ok 16 - ★ 行为(设备):底栏 # SKIP 设备不在 —— 本次不跑\n# skipped 1\n', + [{ name: '★ 行为(设备):底栏', why: '设备不在 —— 本次不跑' }]], + ['原因带破折号前缀要剥掉', 'ok 3 - 某条 # SKIP - 设备忙\n', + [{ name: '某条', why: '设备忙' }]], + ['`not ok` 的跳过行(node 也会这么打)', 'not ok 4 - 某条 # SKIP 不支持的平台\n', + [{ name: '某条', why: '不支持的平台' }]], + ['理由为空也要认出来(别因没原因就当没跳过)', 'ok 5 - 某条 # SKIP\n', + [{ name: '某条', why: '' }]], + ['普通通过行**不该**被当成跳过', 'ok 1 - 一条普通的判据\n# tests 1\n# pass 1\n', []], + ]; + let bad = 0; + for (const [what, tap, want] of cases) { + const got = parse(tap); + const ok = JSON.stringify(got) === JSON.stringify(want); + console.log(`${ok ? 'ok ' : 'RED '} ${what}:解析出 ${got.length} 条` + + (ok ? '' : `(期望 ${JSON.stringify(want)},实际 ${JSON.stringify(got)})`)); + if (!ok) bad++; + } + process.exit(bad ? 1 : 0); +} const STATIC_ONLY = [ ['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'], ['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'], @@ -964,6 +1034,76 @@ try { let dueFailed = false; const dueStatic = STATIC_ONLY.filter(([, , probe]) => probeIsDue(probeResults[probe])); const unknownProbes = Object.entries(probeResults).filter(([, v]) => v === 'unknown').map(([k]) => k); +/* + * ★★ 闸 (ii):「**移出 STATIC_ONLY**」必须要求「行为层**真跑过一次**」(pi 2026-09-18)。 + * + * 为什么需要它:把一条判据从 `STATIC_ONLY` 移除,现在是个纯**记账动作** —— + * 它从"到期闸点名"挪到"行为条管辖",而**没有任何东西要求行为条真的执行过**。 + * ⇒ 一条判据可以在"已升级"的名义下**永远不跑**,而 `static=` 那个余额读起来像 + * "又清了一条"。这与本文件反复消的形状同源:**余额里读不出来的东西等于不存在**。 + * + * pi 用变异把后果钉出来了(我复现,两处都一致): + * 设备在 + 注入一条真实回归 ⇒ `fail=5 … red=10`,红清单里有 harmony-nav; + * 设备不在 + **同一条回归** ⇒ `fail=4 skip=1 … red=9`,红清单与基线**逐条一致** + * ⇒ **一条真实回归,在设备不在时零痕迹。** + * + * ⇒ 这里记一份"已结算"清单:每条都必须能出示**本机**的行为层绿跑记录 + * (由 `lib/harmony-device.mjs` 的 `noteBehavioralRan` 在断言**之后**写)。 + * 记录不存在 ⇒ 到期闸继续**点名**它(回到被看见的状态),而不是让它静静滑过去。 + * ★ 方向要紧:这条闸宁可**多报**(说"你还没验过"),也不许**漏报**(把没验过的 + * 当成验过了)—— 所以"记录读不到"一律按"没验过"处理。 + */ +const SETTLED_FROM_STATIC = [ + // 移出 STATIC_ONLY 的判据要登记在这里,并出示行为层绿跑记录。 + // 形状:['判据文件', '行为记录里的 key(noteBehavioralRan 的那个字符串)', '行为条在哪', '到期前提探针'] + ['test/harmony-nav.test.mjs', 'harmony-nav/底栏可点项', + 'test/harmony-nav.test.mjs 的「★ 行为(设备):底栏真渲染了可点的导航项」', 'device'], +]; +let unsettled = []; +try { + // 账本路径必须与 `lib/harmony-device.mjs` 里 `ranPath()` 的默认值**一致**: + // 那个文件在 `test/lib/` 下,用 `../../../../.tmp/…`(= 仓库根); + // 这里 `ROOT` = `client/electron`,所以是 `../..` 再进 `.tmp`。 + // 两处各拼一次是"同一个事实多份实现"——但这边**只读**、写的那边是唯一权威; + // 若哪天路径改了,这条闸会因"读不到记录"而**多报**(保守方向),不会漏报。 + const ranLedger = JSON.parse(prose(join(ROOT, '..', '..', '.tmp', 'harmony-behavioral-ran.json'))); + for (const [file, key, where, probe] of SETTLED_FROM_STATIC) { + // 只在**本相位会跑**它时才判:构建相位不跑判据文件,不该因此报"没验过" + if (skippedByPhase.includes(file)) continue; + /* + * ★ 只在**到期前提成立**(探针判到期)时才追这条账 —— 与到期闸本身同一条纪律。 + * + * 为什么必须这样收口:没设备/没 SDK 的机器上,行为层**不可能**留下记录 + * (它只会 skip),那条红就是**天天假红**且**无法行动** —— 没人能在那台机器上 + * 把它做绿。这正是探针三值设计要避免的东西。 + * ⇒ 前提不成立时,这条判据的可见性交给**闸 (i) 具名跳过**:它会点名到文件与 + * 判据、写出原因、并给出连续跳过轮数(且连续超 K 轮会自行变红)。 + * 也就是说"没跑"这件事**仍然看得见**,只是不以这条清不掉的账的形式。 + * 前提成立而记录仍缺 ⇒ 那才真异常:"设备明明在,却从没跑绿过"。 + */ + if (!probeIsDue(probeResults[probe])) continue; + if (!ranLedger[key]) unsettled.push([file, key, where]); + } +} catch { + // 记录文件不存在/坏了 ⇒ **前提成立**的那些算"没验过"(保守方向:宁可多报) + unsettled = SETTLED_FROM_STATIC + .filter(([file, , , probe]) => !skippedByPhase.includes(file) && probeIsDue(probeResults[probe])); +} +if (unsettled.length > 0) { + console.error('\n✗ 有判据**被移出 STATIC_ONLY,但行为层从未真跑过**:'); + for (const [file, key, where] of unsettled) { + console.error(` - ${file}(行为记录 key:${key})`); + console.error(` 它已不在到期闸的点名范围里,等于"已升级"—— 但没有任何证据表明`); + console.error(` 行为条真的执行过。${where}`); + } + console.error( + ' ⇒ 这只是被**挪走**,不是被**升级**。让行为条在本机真跑绿一次即可(记录会自动落下),\n' + + ' 或者把它放回 STATIC_ONLY(继续被点名,好过静静滑过去)。\n' + + ' 为什么要有这条闸(pi 2026-09-18 实测):设备不在时,注入一条真实回归,\n' + + ' 红清单与基线**逐条一致** —— 判据"已升级"而实际零覆盖,且看不出来。' + ); + dueFailed = true; +} if (dueStatic.length > 0) { const firstUnknown = unknownProbes.includes(dueStatic[0][2]); console.error(firstUnknown @@ -1138,6 +1278,23 @@ if (reds.length) { console.error(`红的判据(${reds.length}/${SUITE.length}):`); for (const r of reds) console.error(` - ${r}`); } +/* + * ★★ 跳过的要**具名**(pi 2026-09-18 闸 (i) 前半)—— 与上面的红清单**同级**。 + * + * 为什么必须并列:红的是"判据说 NO",跳过的是"判据没说话"。后者只在余额里有个数时, + * **一条真实回归可以零痕迹**(实测:设备不在时注入回归,红清单与基线逐条一致)。 + * 红清单能点名,跳过清单也必须能点名 —— 否则"没跑"就永久藏在 `skip=1` 里。 + */ +if (totalSkip > 0) { + const named = reportedRecords.filter(r => r.skip > 0); + console.error(`跳过的判据(${totalSkip} 条,分布在 ${named.length}/${SUITE.length} 个文件)` + + `—— **不是通过**,也不等于没问题:`); + for (const r of named) { + const list = (r.skips || []).map(s => `「${s.name}」${s.why ? `:${s.why}` : ''}`); + console.error(` - ${r.file}(跳过 ${r.skip} 条)${list.length ? `\n ${list.join('\n ')}` + : '(原因未能从输出里解析出来 —— 这条本身就是个缺口)'}`); + } +} if (dueFailed && reds.length === 0 && brokens.length === 0) { console.error('另外:静态判据**到期**(报文见上)—— 它现在**不再吃掉**上面的汇总与其它红。'); }