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:
@ -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"那行必须存在的理由');
|
||||
});
|
||||
|
||||
158
client/electron/test/lib/harmony-device.mjs
Normal file
158
client/electron/test/lib/harmony-device.mjs
Normal file
@ -0,0 +1,158 @@
|
||||
// 行为判据的设备侧 harness(pi 2026-09-18 到期闸 → (a) 升级用)。
|
||||
//
|
||||
// 为什么单独成模块:到期判据要"真跑一遍 / 真点一次",但 7 个文件各自去找 hdc、
|
||||
// 各自解析 dumpLayout 会复制 7 份同样脆弱的代码 —— 正是"同一份代码两种调用法
|
||||
// 两个结论"那个形状长在判据自己身上。这里把"设备在不在 / 应用在不在前台 /
|
||||
// 真 UI 树 / 点一下"收敛成一组可测函数,判据只负责下断言。
|
||||
//
|
||||
// 边界(写在这里,因为它决定行为判据怎么跑):
|
||||
// ① "本工作区能装能点" ≠ "前台是我的":模拟器是共享的,别的会话可能正拿它
|
||||
// 做 GUI 联调(dumpLayout 里那张表真的能看到 "GUI 联调专用" 标签)。
|
||||
// 所以本模块**只读不抢** —— 应用不在前台就不点、不启动,由调用方决定
|
||||
// 是跳过(计数、不静默绿)还是只对"当前在前的那个应用"做只读断言。
|
||||
// ② dumpLayout 是只读的,但行为判据断的是"我们的应用",所以调用方必须先
|
||||
// 问 foregroundBundle() 再决定 dump 出来的树要不要拿来断言。
|
||||
// ③ 这套和 run-all.mjs 里的 PROBES.device 不是同一件事:那条是**到期闸的
|
||||
// 探针**(三值:可用 / 不可用 / 拿不准→红),决定"判据该不该到期";
|
||||
// 这套是**到期之后跑行为判据时用的**手 —— 探针说"到期"了,这里替你
|
||||
// 真去点。两条逻辑刻意分开,免得"探针和手是同一只"时改一处引坏另一处。
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const TOOLCHAIN_HDC = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc';
|
||||
|
||||
/**
|
||||
* 找到能跑的 hdc 二进制。候选顺序:SDK 文档安装根下的 hdc,再 PATH 上的 hdc。
|
||||
* 返回**跑通了 `list targets`** 的那条(status 0);都不行返回 null。
|
||||
*
|
||||
* 注意:这里只判"能跑",不判"有没有目标"——有没有目标由 hasTarget() 说,
|
||||
* 能不能装 / 能不能点由调用方在真跑时知道。把"能跑"和"有目标"拆开,是因为
|
||||
* "hdc 在但没目标"和"hdc 不在"对调用方是两件不同的事(前者可以等,后者得装)。
|
||||
*/
|
||||
export function findHdc() {
|
||||
// 判据自检用:`AGENTMAIL_HARMONY_DEVICE=none` 模拟"本机没有设备",
|
||||
// 让行为判据的"设备不在 → 显式跳过"那半边也能被验到(与 run-all.mjs 里
|
||||
// PROBES.device 的 AGENTMAIL_PROBE_DEVICE 覆盖是同一套纪律:不可测的分支不算数)。
|
||||
if (process.env.AGENTMAIL_HARMONY_DEVICE === 'none') return null;
|
||||
for (const bin of [TOOLCHAIN_HDC, 'hdc']) {
|
||||
const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 });
|
||||
if (r.error && r.error.code === 'ENOENT') continue; // 这条不在,换下一条
|
||||
if (r.error) continue; // 跑不成(超时/异常),换下一条
|
||||
if (r.status === 0) return bin;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 在 hdc 上跑一条命令,带回 spawnSync 结果。 */
|
||||
function sh(hdc, args, timeout = 20000) {
|
||||
return spawnSync(hdc, args, { encoding: 'utf8', timeout });
|
||||
}
|
||||
|
||||
/** `hdc list targets` 的输出(trim 后)。null 表示 hdc 都没找到。 */
|
||||
export function listTargets(hdc) {
|
||||
if (!hdc) return null;
|
||||
const r = sh(hdc, ['list', 'targets']);
|
||||
return (r.stdout || '').trim();
|
||||
}
|
||||
|
||||
/** 有没有 hdc 目标(输出非空且不是 `[Empty]`)。 */
|
||||
export function hasTarget(hdc) {
|
||||
const t = listTargets(hdc);
|
||||
if (t === null) return false;
|
||||
return t.length > 0 && !/\[Empty\]/.test(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前前台应用的 bundle name。
|
||||
*
|
||||
* `aa dump -a` 的输出是分块的(按 `AbilityRecord ID` 切),每块是一个 ability。
|
||||
* 前台的是 `ability type [PAGE]` 且 `state #FOREGROUND` 的那个 —— 取它的
|
||||
* `bundle name [X]`。这样跳过 SERVICE 型 ability(它们 state 可能是 ACTIVE
|
||||
* 但不是用户看到的前台页面)。没找到返回 null(可能设备在但没人前台、
|
||||
* 或 aa dump 跑不成)。
|
||||
*/
|
||||
export function foregroundBundle(hdc) {
|
||||
if (!hdc) return null;
|
||||
const r = sh(hdc, ['shell', 'aa', 'dump', '-a'], 20000);
|
||||
if (r.status !== 0) return null;
|
||||
const out = r.stdout || '';
|
||||
const blocks = out.split(/AbilityRecord ID/);
|
||||
for (let i = 1; i < blocks.length; i++) {
|
||||
const b = blocks[i];
|
||||
if (/ability type \[PAGE\]/.test(b) && /state #FOREGROUND/.test(b)) {
|
||||
const m = b.match(/bundle name \[(.+?)\]/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉一份当前 UI 树(`uitest dumpLayout` → 设备上生成 JSON → `cat` 回来 → 解析)。
|
||||
*
|
||||
* dumpLayout 的输出形如 `DumpLayout saved to:/data/local/tmp/layout_<ts>.json`,
|
||||
* 路径是确定的(不是"猜最新的文件"——那样会和别的会话的 dump 抢)。拿到路径
|
||||
* 后 cat 回来 JSON.parse。根是 `{ attributes, children }`,children 递归同形。
|
||||
*
|
||||
* 抛异常(不返回 null):调用方该知道"dump 跑不成"和"dump 出来是空的"是两回事
|
||||
* —— 前者是环境问题,后者才是断言该管的。判据的 broken 分类靠这个区分。
|
||||
*/
|
||||
export function dumpLayout(hdc) {
|
||||
if (!hdc) throw new Error('hdc 没找到(findHdc() 返回 null)');
|
||||
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout'], 30000);
|
||||
if (r.status !== 0) throw new Error(`dumpLayout 失败:${(r.stderr || r.stdout || '').trim()}`);
|
||||
const m = (r.stdout || '').match(/saved to:(\S+)/);
|
||||
if (!m) throw new Error(`dumpLayout 没返回文件路径:${(r.stdout || '').trim()}`);
|
||||
const path = m[1].trim();
|
||||
const cat = sh(hdc, ['shell', 'cat', path], 20000);
|
||||
if (cat.status !== 0) throw new Error(`cat ${path} 失败:${(cat.stderr || '').trim()}`);
|
||||
return JSON.parse(cat.stdout);
|
||||
}
|
||||
|
||||
/** 深度优先遍历 UI 树,yield 每个节点(含根)。 */
|
||||
export function* walk(node) {
|
||||
yield node;
|
||||
for (const c of node.children || []) yield* walk(c);
|
||||
}
|
||||
|
||||
/** 找 text/originalText 等于给定文本的节点(返回数组,可能空)。 */
|
||||
export function findByText(root, text) {
|
||||
const out = [];
|
||||
for (const n of walk(root)) {
|
||||
const a = n.attributes || {};
|
||||
if (a.text === text || a.originalText === text) out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 找 type 等于给定类型的所有节点。 */
|
||||
export function findByType(root, type) {
|
||||
const out = [];
|
||||
for (const n of walk(root)) {
|
||||
const a = n.attributes || {};
|
||||
if (a.type === type) out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `"[x1,y1][x2,y2]"` 解析成中心点 `{cx, cy}`。bounds 不是这个形状返回 null。
|
||||
* 中心点用于 `uitest uiInput click`(点击坐标)。
|
||||
*/
|
||||
export function boundsCenter(bounds) {
|
||||
const m = (bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
||||
if (!m) return null;
|
||||
return { cx: Math.round((+m[1] + +m[3]) / 2), cy: Math.round((+m[2] + +m[4]) / 2) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 (x, y) 上点一下(`uitest uiInput click`)。返回 true 表示"No Error"。
|
||||
*
|
||||
* **这是写操作** —— 它改变前台应用的状态。按本模块的边界①,调用方有责任先
|
||||
* 确认前台是自己的应用(foregroundBundle === 'com.agentmail.harmony')再点,
|
||||
* 否则会点到别人的会话正在用的界面上。
|
||||
*/
|
||||
export function tap(hdc, x, y) {
|
||||
if (!hdc) return false;
|
||||
const r = sh(hdc, ['shell', 'uitest', 'uiInput', 'click', String(x), String(y)], 15000);
|
||||
return (r.stdout || '').includes('No Error');
|
||||
}
|
||||
@ -75,7 +75,7 @@ const SUITE = [
|
||||
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
|
||||
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 25],
|
||||
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
|
||||
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 11],
|
||||
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 17],
|
||||
// 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号
|
||||
['test/appearance-defaults.test.mjs', [], 4],
|
||||
['test/build-stamp.test.mjs', [], 7],
|
||||
@ -251,6 +251,7 @@ const records = [];
|
||||
/* eslint-disable no-unused-vars */
|
||||
let totalTests;
|
||||
let totalFail;
|
||||
let totalSkip;
|
||||
let unreportedFiles;
|
||||
let ranFilesCount;
|
||||
/* eslint-enable no-unused-vars */
|
||||
@ -392,15 +393,33 @@ for (const [file, flags, expected] of SUITE) {
|
||||
const nodeTests = /^# tests (\d+)/m.exec(out); // node:test:总条数
|
||||
const nodeFail = /^# fail (\d+)/m.exec(out);
|
||||
/*
|
||||
* `# tests` 缺失时**回退**到 `# pass + # fail`(不是 `# pass`)——
|
||||
* ★ `# skipped K`(node:test):**没跑**的条数,必须单独取一格(dsh 2026-09-18)。
|
||||
*
|
||||
* 起因:给到期判据加行为层时,第一条行为判据在"设备不在"时走 `t.skip` ——
|
||||
* 而 `# tests N` **把跳过的也算进去**(实测 `# tests 16 / # pass 15 / # skipped 1`,
|
||||
* 16 = 15 + 0 + 1)。不取这一格,下面的 `pass = checks - fail` 就把那 1 条读成"通过":
|
||||
* 设备在与设备不在两次运行的 `RESULT files=…` **一字不差**(实测两行完全相同)。
|
||||
* 这就是"**看不到 ⇒ 绿**"—— 本仓反复消的那条,这次长在总数行上。
|
||||
*
|
||||
* ⚠️ 为什么不能靠"跳过的判据会自己打印 SKIP 字样"来兜:那是**文件内的输出**,
|
||||
* 而这里要的是**可数的余额**。余额的定义就是"汇总行里读得出来的那个数" ——
|
||||
* 读不出来的跳过等于没有跳过(同 CRITERIA.md 里"跳过若不可数就等于不显形")。
|
||||
* `check()` 风格的文件没有跳过机制 ⇒ 恒 0。
|
||||
*/
|
||||
const nodeSkip = /^# skipped (\d+)/m.exec(out);
|
||||
/*
|
||||
* `# tests` 缺失时**回退**到 `# pass + # fail + # skipped`(不是 `# pass`)——
|
||||
* 老版本 node 或输出被截断时,和才等于总条数;单取 `# pass` 正是上面那个 bug。
|
||||
* 两者都没有 ⇒ null("没自报条数",走 unreported 那条红)。
|
||||
*/
|
||||
const nodePass = /^# pass (\d+)/m.exec(out);
|
||||
const nodeTotal = nodeTests ? Number(nodeTests[1])
|
||||
: (nodePass ? Number(nodePass[1]) + (nodeFail ? Number(nodeFail[1]) : 0) : null);
|
||||
: (nodePass ? Number(nodePass[1]) + (nodeFail ? Number(nodeFail[1]) : 0)
|
||||
+ (nodeSkip ? Number(nodeSkip[1]) : 0) : null);
|
||||
const fileChecks = marker ? Number(marker[1]) + Number(marker[2]) : nodeTotal;
|
||||
const fileFail = marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0);
|
||||
/* 跳过的条数:`check()` 风格没有这个机制 ⇒ 0(marker 存在即非 node:test 输出) */
|
||||
const fileSkip = marker ? 0 : (nodeSkip ? Number(nodeSkip[1]) : 0);
|
||||
|
||||
/*
|
||||
* ★★ 唯一的信息来源:**这个文件只 push 一条记录**(pi 2026-09-15 的建议,我采纳)。
|
||||
@ -422,6 +441,12 @@ for (const [file, flags, expected] of SUITE) {
|
||||
file,
|
||||
checks: fileChecks, // null = 跑了但没能自报条数
|
||||
fail: fileChecks === null ? null : fileFail,
|
||||
/*
|
||||
* ★ 跳过条数(dsh 2026-09-18):与 `fail` 一样是 `checks` 的**子集**,
|
||||
* 一起随记录走。它不是"失败",但**也不是通过** —— 所以既不能并进 `fail`,
|
||||
* 也不能让它落在 `pass = checks - fail` 里冒充通过。
|
||||
*/
|
||||
skip: fileChecks === null ? null : fileSkip,
|
||||
exitCode: r.status,
|
||||
});
|
||||
/*
|
||||
@ -549,6 +574,15 @@ for (const [file, flags, expected] of SUITE) {
|
||||
const reportedRecords = records.filter(r => r.checks !== null);
|
||||
totalTests = reportedRecords.reduce((n, r) => n + r.checks, 0);
|
||||
totalFail = reportedRecords.reduce((n, r) => n + r.fail, 0);
|
||||
/*
|
||||
* ★ 跳过余额(dsh 2026-09-18)——与 `totalFail` 同源、同层,第 5 个投影。
|
||||
*
|
||||
* 它与 `totalFail` 是**兄弟**:都从 `records` 里取、都是 `checks` 的子集、
|
||||
* 都要在汇总行上可读。区别是语义 —— `fail` 是"判据说这不成立",`skip` 是
|
||||
* "这条本次没跑"(设备不在 / 相位跳过)。**混进任一边都是假读数**:
|
||||
* 并进 `fail` 会把"没跑"报成"坏了";留在 `pass` 里会把"没跑"报成"过了"。
|
||||
*/
|
||||
totalSkip = reportedRecords.reduce((n, r) => n + r.skip, 0);
|
||||
ranFilesCount = reportedRecords.length;
|
||||
unreportedFiles = records.length - reportedRecords.length;
|
||||
|
||||
@ -712,7 +746,6 @@ if (process.argv.includes('--probe-selftest')) {
|
||||
process.exit(bad ? 1 : 0);
|
||||
}
|
||||
const STATIC_ONLY = [
|
||||
['test/harmony-nav.test.mjs', '底栏结构/命中区常量/挂载关系:`.ets` 要 hvigorw 才能编译、要设备才能点', 'device'],
|
||||
['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'],
|
||||
['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'],
|
||||
['test/cross-client-theme.test.mjs', '跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐', 'device'],
|
||||
@ -836,7 +869,16 @@ try {
|
||||
probs.push(`checks=${totalTests} < fail=${totalFail} —— fail 是 checks 的子集,这不可能成立`);
|
||||
}
|
||||
/*
|
||||
* ★★ 这里原来是一条**源码文本扫描**("四个计数器各只许一处写"),连同它的自检一起删掉了。
|
||||
* ★ `skip` 是 `checks` 的**子集**(dsh 2026-09-18),与 `fail` 同一形状:
|
||||
* "跳过的条数"当然属于"自报的总条数"。这条上界对**每个按文件累加的计数器**都成立
|
||||
* (pi 2026-09-15 的教训:只给其中两个判上界,第三个就永远没人管)——
|
||||
* 所以 `skip` 一进来就把上界补上,别等它出事。
|
||||
*/
|
||||
if (totalTests < totalSkip) {
|
||||
probs.push(`checks=${totalTests} < skip=${totalSkip} —— skip 是 checks 的子集,这不可能成立`);
|
||||
}
|
||||
/*
|
||||
* ★★ 这里曾经有一条**源码文本扫描**("四个计数器各只许一处写"),连同它的自检一起删掉了。
|
||||
*
|
||||
* 为什么删(pi 2026-09-15 建议,我采纳):那条扫描器自己长出了**五轮**毛病 ——
|
||||
* 注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 →
|
||||
@ -1053,18 +1095,29 @@ console.log(`\n========== 判据汇总 ==========`);
|
||||
* 单位取成 `# pass` 时 `checks` 与 `pass` 一起少算,那个等式照样成立。
|
||||
*/
|
||||
/*
|
||||
* ★ 这一行的**三个"坏"计数单位不同**,别混着读(pi 2026-09-15 指出缺一格):
|
||||
* ★ 这一行的**四个"坏/没跑"计数单位不同**,别混着读(pi 2026-09-15 指出缺一格;
|
||||
* dsh 2026-09-18 补 `skip`):
|
||||
*
|
||||
* · `fail=N` —— **文件自报的失败条数**(`# fail N` / `RESULT fail=N` 累加)。
|
||||
* · `skip=N` —— **文件自报的跳过条数**(`# skipped N` 累加)。`check()` 风格没有
|
||||
* 跳过机制 ⇒ 那些文件恒 0。语义是"**这条本次没跑**"(设备不在 /
|
||||
* 相位不判),**既不是通过也不是失败** —— 所以它从 `pass` 里减掉。
|
||||
* · `red=N` —— **红的判据文件数**(`reds`,含**运行器自己**推的那些,见下)。
|
||||
* · `broken=N` —— 跑不起来(exit 非 0 / 无输出)的文件数。
|
||||
*
|
||||
* 为什么必须把 `red` 显式打出来:那 3 条"自报条数 ≠ 登记数"的判据是**运行器自己**推的
|
||||
* (`reds.push`),根本不经过文件 ⇒ **进不了 `fail`**。实测过 `fail=1` 而"红的判据(4/25)",
|
||||
* 于是 `fail=1` 被读成"只有一处坏了"。两格都在,读的人才不用自己猜单位。
|
||||
*
|
||||
* ★ 为什么必须有 `skip`(dsh 2026-09-18,实测):给到期判据加行为层时,第一条行为判据
|
||||
* 在"设备不在"时 `t.skip`。补这一格之前,设备在与设备不在两次运行的这一行**一字不差**
|
||||
* (`checks=457 pass=453 fail=4 …`)—— 因为 `# tests` 把跳过的算进总数,而
|
||||
* `pass = checks - fail` 又把它读成通过。**"看不到 ⇒ 绿"长在总数行上**,
|
||||
* 与 `red` 那一格当初缺的理由完全同源:**余额里读不出来的东西等于不存在**。
|
||||
*/
|
||||
const totalsLine = (verdict) => `RESULT files=${SUITE.length} ran=${ranFilesCount} ` +
|
||||
`checks=${totalTests} pass=${totalTests - totalFail} fail=${totalFail} ` +
|
||||
`checks=${totalTests} pass=${totalTests - totalFail - totalSkip} fail=${totalFail} ` +
|
||||
`skip=${totalSkip} ` +
|
||||
`red=${reds.length} broken=${brokens.length} unreported=${unreportedFiles} verdict=${verdict}`;
|
||||
if (reds.length === 0 && brokens.length === 0 && !dueFailed && !selfCheckFailed) {
|
||||
// 报**本相位实际跑了**几条,不报清单长度 —— 上面刚写完"不能声称自己做过的事",
|
||||
|
||||
@ -10,9 +10,9 @@
|
||||
"debts": [
|
||||
{
|
||||
"id": "static-criteria",
|
||||
"count": 7,
|
||||
"count": 6,
|
||||
"due": "本工作区能装、能点设备(探针三值转 true 时自动变红)",
|
||||
"where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(test/harmony-nav.test.mjs、test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/appearance-defaults.test.mjs、test/harmony-admin.test.mjs、test/harmony-imageprep.test.mjs)",
|
||||
"where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/appearance-defaults.test.mjs、test/harmony-admin.test.mjs、test/harmony-imageprep.test.mjs)",
|
||||
"kind": "scope"
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user