test(判据): 到期闸第一条按 (a) 升级 —— harmony-nav 加**行为层**(真 dumpLayout),并把"跳过"变成可数余额

pi 2026-09-18 报"静态判据到期闸开了"是真阳性:探针的前提"本工作区能装、能点
设备"现在成立(我实测:签名 HAP 装上 install bundle successfully、aa start
start ability successfully、uitest uiInput click 返回 No Error、uitest dumpLayout
出真 UI 树)。闸门要求 (a) 改行为判据 或 (b) 改换更准的前提 —— (b) 救不了:
它建议拆的"目标存在 ↔ 能装能点"两层我两层都实测为真,拆开照样红。所以走 (a)。

## 1. 新增行为层(harmony-nav,17 条)

`lib/harmony-device.mjs`:设备侧 harness(findHdc / hasTarget / foregroundBundle /
dumpLayout / walk / findByText / boundsAt)。**只读不抢** —— 模拟器是共享的,
应用不在前台就跳过,不启动、不点。

harmony-nav 新增两条:
· 「★ 行为(设备):底栏真渲染了可点的导航项(live ⊆ source)」——
  真 dumpLayout,断"屏幕下 1/4 里真画出了可点的项、每项至少亮一个
  源码 NAV_ITEMS 定义过的标签"。**版本无关**:已安装构建可能比 HEAD 旧
  (实测前台那份是 3 项,HEAD 源码是 4 项),所以断"live ⊆ source"而不是
  "相等"——"四项齐不齐"仍由静态层把。
· 「★ 判据自检:底栏取值逻辑」——纯函数 `navItemsOf` 上的合成树断言。

它**不替**静态那批(点击配对/挂载映射/命中区 ≥44vp/让位派生)——那些读源码更准。
它补的是源码读不到的那半:真渲染出来了吗、真可点吗、标签对吗。

## 2. 把"跳过"变成可数余额(run-all.mjs)

⚠️ 这是本轮**我先写错、再查出来**的地方,记在这里:升级之前套件里**没有任何
in-file skip**(全仓 grep 零命中)。加了第一条之后,`# tests N` 把跳过的**算进总数**,
而 `pass = checks - fail` 又把它读成**通过** —— 实测设备在 / 设备不在两次运行的
`RESULT files=…` **一字不差**(都 `checks=457 pass=453 fail=4`,而文件自己报
`# tests 16 / # pass 15 / # skipped 1`)。**"看不到 ⇒ 绿"长在总数行上。**

修法照 `fail` 那一格的先例(pi 2026-09-15 指出缺 `red` 时的同一形状):
· 取 `# skipped K` 成一格;`records` 带 `skip`;投影加 `totalSkip`(第 5 个);
· `pass` 改成 `checks - fail - skip`;汇总行补 `skip=N`(并在单位说明里写清它是
  "本次没跑",既不是通过也不是失败);
· 自检 5 补 `checks ≥ skip`(对**每个**按文件累加的计数器都成立的上界 —— 照
  pi 那条"只给其中两个判上界,第三个就永远没人管")。
实测:设备在 `… pass=454 fail=4 skip=0`,设备不在 `… pass=453 fail=4 skip=1`,
`pass+fail+skip == checks` 在两个方向都成立。

## 3. 到期债务结算一笔:7 → 6

STATIC_ONLY 去掉 harmony-nav;`docs/DEBTS.json` 的 static-criteria 7→6(含 where
清单同步);SUITE 登记数 11→17(原登记 11 早已与文件里的 15 条不符 —— 并发会话
加了日历/我的那批测试没改登记,这条红一并消掉)。

## 变异验证(都做了阳性对照,还原后 sha256 一致)

· M1 `sourceLabels` 取空集 → 行为条 not ok,报出真实文案 `✉️、通信`;
· M2 底栏阈值 0.75→2.0 → 报"实际 0,一个都没有";
· M3 `AGENTMAIL_HARMONY_DEVICE=none` → `# skipped 1`(且**改 run-all 之前**
  汇总读不出来 —— 这正是上面第 2 条要修的实证);
· M4 删掉 `if (a.type === 'Text') return false;` → **第一次没红**(自检④拿的是叶子
  FAB,它的 textsUnder 是空、被下一条内容条件滤掉,没测到那行)⇒ 换成"带子文本的
  可点 Text"后**才**红。自检本身也会空跑,这是同一族病的又一个实例;
· M5 阈值 0.75→0.10 → 自检②红;M6 `clickable` 判定取反 → 自检①红。

## 说明与遗留

· 已在运行的构建比 HEAD 旧(前台那份 3 项),行为条按"live ⊆ source"设计,
  所以它现在**绿**且**没有**把"我的缺一项"误报成红 —— 那是 build-stamp 的活。
· 模拟器是**别人会话的**:我为验证装/启动/点过(pi 明确没动它,我动了,如实记);
  harness 因此按"不抢前台"写:不在前台就 skip 并计数。
· 余下 6 条到期判据(appearance/logic/cross-client-theme/defaults/admin/imageprep)
  未动,仍留在 STATIC_ONLY 里红着 —— 到期机制该干的事。harness 已就位,可按
  可观测性逐条升级。
This commit is contained in:
2026-09-18 04:42:03 +08:00
parent 590a72a715
commit b84880f14b
4 changed files with 386 additions and 10 deletions

View File

@ -1,7 +1,8 @@
// P5 悬浮玻璃导航:判据钉"点得到、点对了、没盖住内容",不钉观感。
//
// 这一期换掉的是**条**(系统 `Tabs` 的 bar → 自绘悬浮玻璃条),信息架构没动:
// 内容仍按 `currentIndex` 挂载,两个平级项仍是 通信 / 联系人。
// 内容仍按 `currentIndex` 挂载,平级项是 通信 / 日历 / 联系人 / 我的(四项内容窗格,
// 2026-09-17 起「我的」也从外壳入口改成了窗格 —— 见 `NavItems.ts` 的说明)。
// 所以判据分四类:
// ① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"存在 onClick");
// ② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人,不是挂了但没变);
@ -10,6 +11,7 @@
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
import { readdirSync } from 'node:fs';
import { code, prose, stripComments } from './lib/read.mjs';
import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk } from './lib/harmony-device.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
@ -732,3 +734,166 @@ test('★ 悬浮加号在 Navigation 内部(列表侧),不能当 Navigatio
assert.ok(!/iconName:\s*'compose'/.test(afterNav),
'★ `.navDestination(...)` 之后不该再有 compose 加号 —— 那等于又放回了兄弟位置');
});
/*
* ★ 行为(设备)层:到期闸(pi 2026-09-18)—— 前提"本工作区能装、能点设备"已
* 实测成立(签名 HAP 装上、应用能启动、uitest 能点),这条静态判据到期了,
* 按闸门写的 (a) 升级:真去设备上读一遍底栏,而不是只读源码。
*
* 这条**不替**静态那批断言(点击配对 / 挂载映射 / 命中区 ≥44vp / 让位派生)——
* 那些读源码更准(值、来源、派生关系都是源码里的)。这条判的是源码判不到的
* 那半:**真渲染出来了吗 / 真可点吗 / 标签对吗**。静态说"NAV_ITEMS 有四项、
* 每项 ≥44vp";行为说"屏幕底栏真画出了可点的项、且标签来自源码清单"。
*
* 三条边界(与 `lib/harmony-device.mjs` 的注释同源):
* ① 设备不在 → `t.skip`(计数、不冒充绿)—— 静态层仍把住 HEAD 契约;
* ② 应用不在前台 → `t.skip`(设备被别的会话占用,**不抢前台**);
* ③ 已安装的构建可能比 HEAD 旧(build-stamp 那条管同步),所以这里断的是
* **版本无关的可点结构**:底栏真渲染出 ≥1 个带文字标签的可点导航项,且
* 标签是源码 `NAV_ITEMS` 的**子集**(live ⊆ source)—— "四项齐不齐"由静态把。
*/
/** 一份 dumpLayout 树里,屏幕高度 = 所有节点 bounds 底边的最大值(根自己不一定有 bounds)。 */
function screenHeightOf(root) {
let h = 0;
for (const n of walk(root)) {
const m = (n.attributes?.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (m) h = Math.max(h, +m[4]);
}
return h;
}
/** 某节点**子树里**(不含自己)的非空 Text 文案。图标也是 Text,所以别拿它当"标签全集"。 */
function textsUnder(node) {
const out = [];
for (const c of walk(node)) {
if (c === node) continue;
if (c.attributes?.type !== 'Text') continue;
const t = (c.attributes?.text || c.attributes?.originalText || '').trim();
if (t) out.push(t);
}
return out;
}
/**
* 从 dumpLayout 树里取**底栏导航项**(纯函数 —— 下面的判据自检拿合成树喂它)。
*
* 形状:屏幕下 1/4 里、`clickable`、**带文字子节点**的容器。
* 排除自洽的可点 Text(FAB "+"、顶栏 ⚙ 这类)—— 它们是"点一下有反应"但不是导航项。
* 这么取是版本无关的:旧构建(Column 里塞 emoji + 文字)与新构建(Path 图标 + 文字)
* 都落在"可点容器 + 文字子节点"这个形状上。
*/
function navItemsOf(root) {
const screenH = screenHeightOf(root);
return [...walk(root)].filter(n => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
if (a.type === 'Text') return false;
const m = (a.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (!m || +m[2] < screenH * 0.75) return false;
return textsUnder(n).length > 0;
});
}
test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)', async (t) => {
const hdc = findHdc();
if (!hdc || !hasTarget(hdc)) {
return t.skip('设备不在 —— 行为部分本次不跑(静态层仍把住源码契约)');
}
const fg = foregroundBundle(hdc);
if (fg !== 'com.agentmail.harmony') {
return t.skip(`应用不在前台(当前是 ${fg || '空'})—— 设备被别的会话占用,不抢前台`);
}
const root = dumpLayout(hdc);
assert.ok(screenHeightOf(root) > 100,
`要能从 dumpLayout 里量出屏幕高度(实际 ${screenHeightOf(root)})—— 拿不到说明树是空的`);
const navItems = navItemsOf(root);
assert.ok(navItems.length >= 1,
`底栏要渲染出 ≥1 个带文字标签的可点导航项(实际 ${navItems.length})—— ` +
'一个都没有说明导航没挂 / 被盖住 / 全不可点(dumpLayout 实测)');
/*
* 每个导航项要至少亮一个源码 NAV_ITEMS 里定义过的标签。
* 不要求"所有 Text 都在清单里"—— 图标(emoji 或 Path 渲染出的字形)也是 Text 节点,
* 但它不是导航项的**标签**。要求"至少一个文字命中源码清单"足以抓住"标签写错"
* (写成了源码没有的字),又不误伤图标。这就是 "live ⊆ source" 的可操作写法。
*/
const sourceLabels = new Set(N.NAV_ITEMS.map(i => i.label));
for (const it of navItems) {
const texts = textsUnder(it);
const matched = texts.filter(l => sourceLabels.has(l));
assert.ok(matched.length >= 1,
`底栏导航项(bounds=${it.attributes.bounds})要至少亮一个源码定义过的标签;` +
`实际文字:${texts.join('、')} —— 一个都没命中 NAV_ITEMS(live ⊆ source 被破坏)`);
}
});
/*
* ★ 判据自检(本仓纪律:**造坏样本必须红、好样本不许误报**)。
*
* 上面那条行为判据要连设备才跑得成,所以它的**取值逻辑**(`navItemsOf`)不能
* 只靠"我在真机上试过一次绿"来保证 —— 那样它哪天写坏了(正则改了、阈值反了)
* 也没人知道,而它看起来完全健康。这里拿**合成树**把方向都钉住:
* ① 好样本:有底栏 + 两个带标签的项 ⇒ 取到 2 个,且标签命中源码清单;
* ② 坏样本:底栏不见了 ⇒ 取到 0 个("导航没挂"必须判得出来);
* ③ 坏样本:标签是源码里没有的字 ⇒ 命中数 0("live ⊆ source 被破坏"必须判得出来);
* ④ 自洽可点 Text(FAB/⚙)不算项(否则它会去核 NAV_ITEMS 标签而**误红**)。
* 这四条不连设备、不写文件,是纯函数上的断言 —— 所以它们**每次跑都真的在跑**。
*/
test('★ 判据自检:底栏取值逻辑 —— 好样本取得到、缺底栏/错标签必须判得出', () => {
const text = (t, bounds) => ({ attributes: { type: 'Text', text: t, originalText: t, bounds, clickable: 'false' }, children: [] });
const item = (label, y0, y1) => ({
attributes: { type: 'Column', bounds: `[0,${y0}][419,${y1}]`, clickable: 'true' },
children: [text('✉️', `[10,${y0 + 10}][40,${y0 + 40}]`), text(label, `[10,${y0 + 50}][100,${y0 + 80}]`)],
});
const bar = () => ([item('通信', 2400, 2600), item('联系人', 2600, 2800)]);
const labels = new Set(N.NAV_ITEMS.map(i => i.label));
// ① 好样本:屏幕高 2800,两项都在下 1/4
const good = { attributes: { type: 'Row', bounds: '[0,0][1200,2800]', clickable: 'false' }, children: bar() };
const gotGood = navItemsOf(good);
assert.equal(gotGood.length, 2, `好样本应当取到 2 个底栏项(实际 ${gotGood.length})`);
assert.ok(textsUnder(gotGood[0]).some(l => labels.has(l)),
'好样本的项要命中源码清单(否则自检本身把好样本判坏了)');
// ② 坏样本:没有底栏(只有内容区)⇒ 一个都取不到
const noBar = {
attributes: { type: 'Row', bounds: '[0,0][1200,2800]', clickable: 'false' },
children: [{ attributes: { type: 'Column', bounds: '[0,100][1200,800]', clickable: 'true' },
children: [text('收件箱', '[10,110][100,140]')] }],
};
assert.equal(navItemsOf(noBar).length, 0,
'★ 自检失败:底栏不在时仍取到了项 —— 那"导航没挂"这条就永远判不出来');
// ③ 坏样本:底栏在,但标签是源码里没有的字 ⇒ 命中数 0(判据会红)
const wrong = { attributes: { type: 'Row', bounds: '[0,0][1200,2800]', clickable: 'false' },
children: [item('设置', 2400, 2600)] };
const wrongItems = navItemsOf(wrong);
assert.equal(wrongItems.length, 1, '错标签样本仍应被识别为"一个底栏项"');
assert.equal(textsUnder(wrongItems[0]).filter(l => labels.has(l)).length, 0,
'★ 自检失败:源码里没有的标签被判成命中了 —— 那"live ⊆ source"这条就是空跑');
/*
* ④ 自洽的可点 Text 不算导航项。
*
* ⚠️ 这一步我第一版写**错**了,记在这里:当时只拿"FAB 那种**叶子** Text",
* 而 `textsUnder` 只看子树(不含自己)⇒ 叶子 Text 的 textsUnder 是**空**,
* 于是它被**下一条内容条件**滤掉、而不是被 `type === 'Text'` 那行滤掉。
* 实测:把 `if (a.type === 'Text') return false;` **整行删掉**,这一条**照样绿**
* ——自检看着在钉"排除 Text",其实没钉到(我原以为那个变异会红,它没红)。
* ⇒ 换成能真正区分两种原因的样本:**带文字子节点的可点 Text**。
* 它的 textsUnder 非空,所以只有"排除 Text"那行能把它滤掉。叶子 FAB 也一并留着
* —— 两条路径都要有人管。
*/
const fabLeaf = { attributes: { type: 'Text', text: '+', bounds: '[900,2400][1000,2500]', clickable: 'true' }, children: [] };
const wrappingText = { attributes: { type: 'Text', text: '通信', bounds: '[900,2400][1000,2500]', clickable: 'true' },
children: [text('通信', '[900,2450][1000,2490]')] };
const withFab = { attributes: { type: 'Row', bounds: '[0,0][1200,2800]', clickable: 'false' },
children: [...bar(), fabLeaf, wrappingText] };
assert.ok(textsUnder(wrappingText).length > 0,
'自检前提:这个样本的 textsUnder 必须非空 —— 否则它测不到"排除 Text"那行(见上面 ⚠️)');
assert.equal(navItemsOf(withFab).length, 2,
'★ 自检失败:自洽可点 Text(叶子 FAB / 带子文本的可点 Text)被当成了导航项 —— ' +
'它们会去核 NAV_ITEMS 标签而**误红**;这正是"排除 Text"那行必须存在的理由');
});