pi 2026-09-18 报的是**我上一笔(b84880f)自己的缺陷**,我按两处都实测复现,没有一条靠信。
## 一、缺陷:升级后有一条真实回归**零痕迹**
形状是**两件事同时发生**:
1. `harmony-nav` 移出 `STATIC_ONLY`(7→6)⇒ 到期闸**不再点名它**;
2. 行为条在设备不在时 `t.skip` ⇒ **不跑,也不算红**。
⇒ 这条判据有了一个"既不红、也不算没升级"的状态。升级前它在 `STATIC_ONLY` 里,每轮都被
点名(红但**看得见、可行动**);升级后**不被点名**,而行为条可以**永远不跑**。
**我的复现**(不是转述):注入 `assert.ok(false,'MUTANT: 真实回归')`,同一棵树两种设备态:
| | RESULT | 红清单里有 harmony-nav |
|---|---|---|
| 设备在 | `checks=459 pass=454 fail=5 skip=0 red=10` | **有** |
| 设备不在 | `checks=459 pass=454 fail=4 skip=1 red=9` | **没有** —— 与未变异基线**逐条一致** |
文件级同样:设备在 `fail 1`;设备不在 `fail 0 / skipped 1`(变异**够不着**)。
★ 还有一层 pi 点出的:两次 `verdict` 都是 `red`,是**被别的红兜住的** ——
等那批红清掉,这条回归就能让套件在"绿"的状态下藏着。
## 二、闸 (i):跳过必须**具名**(与红清单同级)
原来 `skip=N` 只在余额里**数得出来**,但**看不出是谁** —— 于是和"设备恰好不在、
什么都没坏"不可区分。node:test 其实**已经**把原因打出来了(`ok 16 - … # SKIP 设备不在 —— …`),
是解析只取了计数、把名字和原因丢了。⇒ 取回来,并在汇总里按**与红清单并列**的格式打印
(文件 + 判据名 + 原因)。实测(设备不在):
```
跳过的判据(1 条,分布在 1/29 个文件)—— **不是通过**,也不等于没问题:
- test/harmony-nav.test.mjs(跳过 1 条)
「★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)」:设备不在 —— 行为部分本次不跑
```
## 三、闸 (ii):移出 `STATIC_ONLY` 必须**出示行为层绿跑记录**
"7→6"原来是纯**记账动作**:从"到期闸点名"挪到"行为条管辖",而**没有任何东西要求
行为条真的执行过**。⇒ 行为条**断言全过之后**留一条本机记录(`noteBehavioralRan`),
`run-all` 拿它当结算前提;没有记录就**点名**(回到被看见的状态)。
★ 方向要紧:宁可**多报**(说你还没验过),不许**漏报**(把没验过的当成验过了)。
★ 我自己在这里补了一层 pi 没提的收口:**只在到期前提成立时才追这条账**。否则没设备
的机器上行为层**不可能**留记录,那条红就是**天天假红且无法行动**(没人能在那台机器上
把它做绿)—— 正是探针三值设计要避免的。前提不成立时,可见性交给闸 (i)(具名 + 连续
轮数 + 超 K 自红)。四态实测:
| 场景 | 闸(ii) | 闸(i) |
|---|---|---|
| 设备在 + 有记录 | 静默 | 静默 |
| 设备在 + 记录被删(真跑不了) | **红** | **点名** |
| 探针说不(无设备机器) | 静默(避免清不掉的假红) | **点名** |
| 设备在但"忙"(前台是别人的) | **红** | **点名** |
## 四、我在实现过程中自己写错的(照实记)
1. **自检抄了一份副本**:我把 `# SKIP` 正则**复制**进 `--skip-selftest`
(当时的想法是"自检不该依赖被测对象")—— 那样自检验的是**副本**,生产那份改坏了
自检照样绿。是变异时 `AssertionError: 命中 2 处` 把它暴露出来的。
⇒ 抽成唯一实现 `parseSkips`,自检**直接调它**。再变异**生产那份**(去掉破折号剥离)
⇒ 自检红(`why: "- 设备忙"` vs `"设备忙"`)、exit 1,**这次验的是真身**。
这正是本仓反复消的"同一个事实多份实现"(`blurStyleFor`、`stripStrings` 兄弟副本同款)。
2. **自检案例我写错了**:第一条拿的是"不带 ` # SKIP` 标记的行"却期望解析出 1 条 ——
自检当场红。真形状一定带标记;不带标记的行正是另一条要钉的"不许被当成跳过"。
(顺带:自检连**我写测试时的错**都抓到了,方向对。)
3. **账本路径两处各拼一次**:`run-all` 与 `lib/harmony-device.mjs` 各按自己的位置算
`.tmp/` 路径。已注释说明"写的那边是唯一权威、这边只读,路径若漂移会**多报**不会漏报"。
## 验证
· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 与改动前**同样 9 条**
(并发会话的"自报>清单" + 4 条 exit 1),两条新闸在正常路径上**都静默**。
· 变异:M7 自检②、M8 自检③、M12 自检正则、M14 **生产**解析器,全部按预期红并已还原。
· `--skip-selftest` 5/5 绿;它与既有 `--probe-selftest` **同形状**(都先跑套件再判定),
不是我引入的新形状。
989 lines
64 KiB
JavaScript
989 lines
64 KiB
JavaScript
// P5 悬浮玻璃导航:判据钉"点得到、点对了、没盖住内容",不钉观感。
|
||
//
|
||
// 这一期换掉的是**条**(系统 `Tabs` 的 bar → 自绘悬浮玻璃条),信息架构没动:
|
||
// 内容仍按 `currentIndex` 挂载,平级项是 通信 / 日历 / 联系人 / 我的(四项内容窗格,
|
||
// 2026-09-17 起「我的」也从外壳入口改成了窗格 —— 见 `NavItems.ts` 的说明)。
|
||
// 所以判据分四类:
|
||
// ① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"存在 onClick");
|
||
// ② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人,不是挂了但没变);
|
||
// ③ 命中区:≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜;
|
||
// ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质),
|
||
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
|
||
import { readdirSync } from 'node:fs';
|
||
import { code, prose, stripComments } from './lib/read.mjs';
|
||
import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk,
|
||
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';
|
||
import assert from 'node:assert/strict';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(HERE, '..', '..', '..');
|
||
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||
const NAV_TS = join(HARMONY_ETS, 'model/NavItems.ts');
|
||
|
||
const read = p => prose(join(HARMONY_ETS, p));
|
||
/** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */
|
||
/** 取某个 `@Builder` 的正文(按行切到下一个成员) */
|
||
function builderBody(src, signature) {
|
||
const lines = src.split('\n');
|
||
const start = lines.findIndex(l => l.includes(signature));
|
||
assert.ok(start > 0, `要能找到 ${signature}`);
|
||
let stop = lines.length;
|
||
for (let i = start + 1; i < lines.length; i++) {
|
||
if (/^ (@Builder|build\()/.test(lines[i])) { stop = i; break; }
|
||
}
|
||
return lines.slice(start, stop).join('\n');
|
||
}
|
||
|
||
/**
|
||
* 取 `from` 处第一个 `{` 到**配对**的 `}` 之间的正文。
|
||
*
|
||
* ⚠️ 我第一版用的是非贪婪正则 `\{([\s\S]*?)\}`:它在
|
||
* `CommPage({ bgActive: this.bgActive })` 的**对象字面量** `}` 上就收尾了,
|
||
* 于是"else 分支里有没有 ContactsTab"被判成没有 —— 又是我自己那条
|
||
* "判结构要配对/解析,不要窗口"(判据也被它咬)。这里按花括号配对取。
|
||
*/
|
||
function braceBody(src, from) {
|
||
const at = src.indexOf('{', src.indexOf(from));
|
||
assert.ok(at > 0, `要能找到 ${from} 后面的 {`);
|
||
let depth = 0;
|
||
for (let i = at; i < src.length; i++) {
|
||
if (src[i] === '{') depth++;
|
||
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(at + 1, i); }
|
||
}
|
||
assert.fail(`${from} 的花括号没有闭合`);
|
||
}
|
||
|
||
const N = await import(NAV_TS);
|
||
const main = read('pages/MainPage.ets');
|
||
|
||
test('① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"有 onClick")', () => {
|
||
/*
|
||
* 这条刻意不写成"存在 onClick" —— 那是最容易被满足、也最容易骗人的形状:
|
||
* 点哪个都赋 0 也能过。这里要求**每一项的点击目标与它的项下标配对**:
|
||
* 点击处理器里赋的值,必须来自这一项自己的 index(ForEach 的第二个参数),
|
||
* 并且过 `normalizeNavIndex` 归一化(与内部页签同一套纪律:不直接赋值)。
|
||
*/
|
||
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
|
||
assert.ok(item.length > 100, '要能取到导航项的正文');
|
||
const itemCode = stripComments(item);
|
||
/*
|
||
* 点击处理器是 `() => { this.currentIndex = normalizeNavIndex(index); }`。
|
||
* 取赋值语句并断言它**同时**用到 `index` 与归一化函数 ——
|
||
* 只看"出现过 normalizeNavIndex"不够(可以写 `normalizeNavIndex(0)` 骗过去),
|
||
* 所以要求括号里的实参就是 `index`。
|
||
*/
|
||
// 只数**赋值**(`= `),不数三元式里的 `===` —— 外壳入口不赋值,所以正文里仍只能有一次赋值
|
||
/*
|
||
* ★ 2026-09-17:赋值改成在 `animateTo` 回调里写一个**局部变量** `target`,
|
||
* 而这个 `target` 由 `normalizeNavIndex(index)` 算出来 —— 仍然要求
|
||
* "点第 N 项落到第 N 个",只是多了一层变量。
|
||
* 判据跟着改成:先要求 `target` 出自 `normalizeNavIndex(index)`,
|
||
* 再要求赋值写的是 `target`(而不是某个写死的下标)。
|
||
*/
|
||
assert.match(itemCode, /const\s+target:\s*number\s*=\s*normalizeNavIndex\(\s*index\s*\)/,
|
||
'点击目标要由本项自己的 index 过归一化算出来(`target = normalizeNavIndex(index)`)');
|
||
const assigns = [...itemCode.matchAll(/(?<!=)this\.currentIndex\s*=\s*([^;=]+);/g)].map(m => m[1].trim());
|
||
assert.equal(assigns.length, 1, `导航项里应当恰好一次 currentIndex 赋值(实际 ${assigns.length} 次)`);
|
||
assert.equal(assigns[0], 'target',
|
||
`赋值要写算出来的 target(本项自己的 index),现在写的是:${assigns[0]}`);
|
||
/* 切窗格包在 animateTo 里(否则是硬切 —— 用户 2026-09-17「一点动画都没有」) */
|
||
assert.match(itemCode, /getUIContext\(\)\.animateTo\(/,
|
||
'★ 切窗格要包在 `getUIContext().animateTo(...)` 里(全局 animateTo 已废弃,判据会红)');
|
||
/*
|
||
* ★ 2026-09-17:**不再**要求"route 非空就 pushUrl"。
|
||
*
|
||
* 那条正是"我的页面不遵守导航规则"的源头:点底栏「我的」推一个独立 @Entry 页,
|
||
* 底部导航整条消失。现在四项都是内容窗格,统一走 currentIndex 分派。
|
||
* 这里反过来钉:导航项里**不该**再有 pushUrl 分支(否则那个形状会回来)。
|
||
*/
|
||
assert.ok(!/pushUrl/.test(itemCode),
|
||
'★ 导航项里不许再有 pushUrl —— 那会把导航条整个推走(用户 2026-09-17 抱怨的形状)');
|
||
// ForEach 要真的把 index 传进来(否则上面那句 index 无从谈起)
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
assert.match(bar, /ForEach\(NAV_ITEMS,\s*\(item: NavItem,\s*index: number\)/, 'ForEach 要带 index 参数');
|
||
assert.match(bar, /this\.NavItem\(item,\s*index\)/, '要把每一项(连同 index)交给导航项 builder');
|
||
});
|
||
|
||
test('② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人)', () => {
|
||
/*
|
||
* 换了条之后最容易出的错是"点了没反应"——点击改了状态,但内容仍写死挂第一个页面。
|
||
* 所以把 index → 页面的映射钉死:0 → CommPage、2 → ContactsTab、1 → CalendarPage,
|
||
* 且三边都要收到 `bgActive`(背景开着时让出页面底,否则壁纸被内容盖住)。
|
||
*
|
||
* 日历与另两个不同:它**常驻挂载**(不是 if/else 的一支),用 `visibility` 控制显示 ——
|
||
* 理由写在 `CalendarPage.ets` 与 build() 的注释里(today 跨午夜、保住"在看哪个月")。
|
||
* 所以这里断言的是**两件事**:索引→页面的配对,以及日历那一支确实是"常驻 + 可见性开关"。
|
||
*/
|
||
const stack = main.slice(main.indexOf('Stack({ alignContent: Alignment.Bottom })'));
|
||
// 只在**根 build()** 里数分派(页面内部也有 if/else,扫全文会数错)
|
||
const root = stack.slice(0, stack.indexOf('this.NavBar()') + 100);
|
||
const dispatch = [...root.matchAll(/if \(this\.currentIndex === 0\)/g)];
|
||
assert.equal(dispatch.length, 1, `根里应当恰好一处"按 index 分派内容"(实际 ${dispatch.length} 处)`);
|
||
const first = braceBody(root, 'if (this.currentIndex === 0)');
|
||
const second = braceBody(root.slice(root.indexOf('if (this.currentIndex === 0)')), 'else');
|
||
assert.match(first, /CommPage\(\{ bgActive: this\.bgActive,\s*navReserve: this\.navReserve \}\)/,
|
||
'index 0 要挂通信页(并把底部条高度透传下去作为列表末尾让位)');
|
||
assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive,\s*navReserve: this\.navReserve \}\)/,
|
||
'index 2 要挂联系人页(同样透传底部条高度)');
|
||
/*
|
||
* 联系人那一支必须**显式绑在 index 2**。注意条件在 `else if (...)` 里,
|
||
* 不在 `else` 的**花括号正文**里 —— braceBody 只取正文,所以这条要看 root 原文。
|
||
* 写成"否则就挂联系人"的后果:以后再加一项,新索引会被它静默接住(点了显示联系人)。
|
||
*/
|
||
assert.match(root, /else if \(this\.currentIndex === 2\)/, '联系人必须显式绑在 index 2,不许用 else 兜底');
|
||
/* 第 4 项「我的」也是内容窗格(2026-09-17:原来是 pushUrl 推独立页 ⇒ 导航消失) */
|
||
assert.match(root, /else if \(this\.currentIndex === 3\)/,
|
||
'「我的」要显式绑在 index 3(内容窗格),不许用 else 兜底');
|
||
assert.match(root, /SettingsPane\(\{\s*bgActive: this\.bgActive,\s*navReserve: this\.navReserve\s*\}\)/,
|
||
'★ index 3 要挂 SettingsPane(**窗格**,不是 pushUrl 出去的 @Entry 页)——' +
|
||
'用户 2026-09-17:「我的页面完全没有遵守 nav 的导航规则」');
|
||
/*
|
||
* 日历(index 1):**常驻挂载 + visible 由索引驱动 + Visibility 开关**,三样缺一不可。
|
||
* · `visible: this.currentIndex === 1` 是 `@Watch` 的触发源 —— 不带它,today 就不会在
|
||
* pane 变可见时重算(DEBTS 的 calendar-today-recompute);
|
||
* · `visibility(...=== 1 ? Visible : None)` 是显示开关;常驻 + 不隐藏 = 三个 pane 叠在一起。
|
||
*/
|
||
assert.match(root, /CalendarPage\(\{\s*bgActive: this\.bgActive,\s*visible: this\.currentIndex === 1,\s*navReserve: this\.navReserve\s*\}\)/,
|
||
'index 1 要挂日历页,并把"是否可见"与底部条高度都传下去');
|
||
assert.match(root, /\.visibility\(this\.currentIndex === 1 \? Visibility\.Visible : Visibility\.None\)/,
|
||
'常驻挂载就要用 visibility 控制显示');
|
||
assert.ok(!/if \(this\.currentIndex === 1\)/.test(root),
|
||
'日历不该写成 if/else 的一支:它是常驻 pane(卸载重挂会丢掉"在看哪个月",且 today 只能靠挂载重算)');
|
||
/*
|
||
* 分派必须**穷尽内容窗格**。2026-09-14 日历入口上架时这条**如约变红**
|
||
* (当时是 `length === 2` + 两支 if/else)—— 加**窗格**必须同时加分派。
|
||
*
|
||
* ★ 2026-09-17:内容窗格 3 → **4**(「我的」从外壳入口改成窗格)。
|
||
* 用户原话:「我的页面完全没有遵守 nav 的导航规则」——
|
||
* 原来点底栏「我的」是 `pushUrl('pages/SettingsPage')` 推一个独立 @Entry 页,
|
||
* 底部导航整条消失;而 WebUI 的 `account` 只是一个 `viewMode`,导航常驻。
|
||
* 所以现在四项**都是内容窗格**,`NAV_ITEMS.length === NAV_CONTENT_COUNT`,
|
||
* 不再有"外壳入口"这一类。
|
||
*/
|
||
assert.equal(N.NAV_CONTENT_COUNT, 4, `内容窗格应为 4(通信/日历/联系人/我的),实际 ${N.NAV_CONTENT_COUNT}`);
|
||
assert.equal(N.NAV_ITEMS.length, N.NAV_CONTENT_COUNT,
|
||
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项;应与内容窗格数一致(${N.NAV_CONTENT_COUNT})—— 四项都是窗格`);
|
||
/* 第 4 项「我的」**不得**再带 route:带 route 就会被当外壳入口 push 出去(导航消失) */
|
||
const me = N.NAV_ITEMS[3];
|
||
assert.equal(me.key, 'me', '第 4 项应是「我的」');
|
||
assert.equal(me.route, undefined,
|
||
'★ 「我的」不许再带 route —— 带 route 就是"点了导航、导航条整个消失"那个形状')
|
||
// 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到
|
||
const navAt = stack.indexOf('this.NavBar()');
|
||
const contentAt = stack.indexOf('CommPage({ bgActive: this.bgActive })');
|
||
assert.ok(navAt > contentAt, '浮动条要在内容之后渲染(浮在上层)');
|
||
});
|
||
|
||
test('③ 命中区 ≥44vp:判的是数值本身(从 NavItems.ts 导入,不猜源码)', () => {
|
||
assert.ok(N.NAV_ITEM_MIN_HIT >= 44, `命中区下限必须 ≥44vp(现在 ${N.NAV_ITEM_MIN_HIT})`);
|
||
assert.ok(N.NAV_BAR_HEIGHT >= N.NAV_ITEM_MIN_HIT,
|
||
`条高(${N.NAV_BAR_HEIGHT})不得小于命中区下限(${N.NAV_ITEM_MIN_HIT})`);
|
||
// 数值写了还要**用上**:项必须有下限约束,且高度取条高
|
||
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
|
||
assert.match(item, /\.constraintSize\(\{\s*minHeight:\s*NAV_ITEM_MIN_HIT,\s*minWidth:\s*NAV_ITEM_MIN_HIT\s*\}\)/,
|
||
'导航项要显式声明命中区下限(minHeight 与 minWidth 都要)');
|
||
assert.match(item, /\.height\(NAV_BAR_HEIGHT\)/, '导航项高度取条高');
|
||
assert.match(item, /\.layoutWeight\(1\)/, '两项要等分条宽(否则命中区宽度靠内容撑,会小于下限)');
|
||
// 条自身也要够高
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
assert.match(bar, /\.height\(NAV_BAR_HEIGHT\)/, '条高取同一常量');
|
||
});
|
||
|
||
/*
|
||
* ③ 的**来源**那一半(pi 2026-09-14;配对规则见 CRITERIA.md §6.7.0):
|
||
* 上面的"≥44"判的是**值**,但值对不等于"组件用的是那个值" —— 组件自己写死一个
|
||
* 够大的数字照样绿,而共享常量被改小时它不会跟着变。所以这一条判**来源**:
|
||
* 应用点必须引用常量,不许出现裸数字。
|
||
*/
|
||
test('③-b 命中区是**来源**判据:`.ets` 不许自己写数字,必须引用 NAV_ITEM_MIN_HIT', () => {
|
||
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
|
||
const bare = /(minHeight|minWidth|height|width):\s*\d+/.exec(item);
|
||
assert.equal(bare, null,
|
||
`导航项里出现了裸数字 \`${bare?.[0]}\` —— 命中区/条高必须引用 NavItems.ts 里的常量:` +
|
||
`值判据(≥44)管"数字够不够大",来源判据(这条)管"用的是不是同一个数字"。` +
|
||
`两条合起来才闭合(CRITERIA.md §6.7.0)。`);
|
||
});
|
||
|
||
/*
|
||
* 让位高度必须**派生**(pi 2026-09-14 §6:`76` 不该是并列常量)。
|
||
*
|
||
* 判的是 NavItems.ts 里的**声明**:算式里要出现条高与离底留白两个常量 ——
|
||
* 写死 `= 76` 就会在"哪天把条高改成 64"时静默失配(内容被压住,看得见点不到)。
|
||
* 顺带钉住:算式里不许出现裸数字(余量要有名字)。
|
||
*/
|
||
test('④-b 内容让位高度是**派生**的,不是并列常量', () => {
|
||
const src = prose(NAV_TS);
|
||
const decl = /export const NAV_CONTENT_RESERVE: number = ([^;]+);/.exec(src);
|
||
assert.ok(decl, 'NAV_CONTENT_RESERVE 的声明要能被判据读到(判据跟着改)');
|
||
const expr = decl[1];
|
||
assert.match(expr, /NAV_BAR_HEIGHT/, `让位高度必须含条高,现在是 \`${expr}\``);
|
||
assert.match(expr, /NAV_BAR_BOTTOM/, `让位高度必须含离底留白,现在是 \`${expr}\``);
|
||
const bare = /[^A-Z_]\d+/.exec(expr.replace(/NAV_[A-Z_]+/g, ''));
|
||
assert.equal(bare, null,
|
||
`算式里还有裸数字 \`${bare?.[0]}\` —— 余量也要起名字(NAV_CONTENT_GAP),否则"该让多少"永远是谜`);
|
||
});
|
||
|
||
test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),内容底部让出的高度 ≥ 条高 + 离底留白', () => {
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
/*
|
||
* "悬浮"是可判的形状:四周留白 + 圆角 + 浮在内容之上。
|
||
* 这三样缺一样就不再是 WebUI 那条玻璃条了(贴边的全宽条 = 又变回系统 bar 的样子)。
|
||
*/
|
||
/*
|
||
* ★ 留白必须靠**外层容器的 padding**,不能靠 `width('100%') + margin`(2026-09-17 修)。
|
||
*
|
||
* ArkUI 的 margin 加在宽度**外面**:`width('100%')` 再配左右 margin 不会缩到
|
||
* 「100% − margin」,而是整个顶出父容器、两侧被裁。实测底栏左缘 x=0、右缘贴满
|
||
* 1256(应各留 16vp=56px)⇒ 看起来是**贴边通栏**,不是浮在壁纸上的胶囊,
|
||
* 玻璃材质也随之失去意义(背后没有内容/壁纸可透)。
|
||
* 这与收件箱头卡片修过的是同一个坑。
|
||
*/
|
||
assert.match(bar, /\.padding\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/,
|
||
'留白要用外层容器的 padding(左右 + 离底)—— width(100%) + margin 在 ArkUI 里不缩宽,会顶出父容器');
|
||
assert.ok(!/\.margin\(\{[^}]*NAV_BAR_SIDE/.test(bar),
|
||
'★ 不许再用 margin 做留白:ArkUI 的 margin 加在宽度外面,100% 宽度 + margin 会撑出屏幕边界');
|
||
assert.match(bar, /\.borderRadius\(NAV_BAR_RADIUS\)/, '要圆角(胶囊)');
|
||
assert.match(bar, /\.backgroundBlurStyle\(Theme\.navMaterial\)/,
|
||
'材质用系统档次,不手写 alpha(且**不跟随** `bg_blur` —— 理由见本文件末那条"可达性"判据)');
|
||
/*
|
||
* 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值",
|
||
* 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。
|
||
* 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。
|
||
*/
|
||
assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(stripComments(bar)),
|
||
`条上不许出现手写色值(深浅两套由系统给),实际:${(stripComments(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`);
|
||
|
||
// 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到)
|
||
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM,
|
||
`内容让位(${N.NAV_CONTENT_RESERVE})必须 ≥ 条高 + 离底留白(${N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM})`);
|
||
/*
|
||
* ★ 2026-09-17:让位方式**改了**,这条判据跟着改(旧形状已被证明是错的)。
|
||
*
|
||
* 旧做法是把 `NAV_CONTENT_RESERVE` 加在**窗格的 `padding({bottom})`** 上。
|
||
* 那看着等价于"最后一行能滚出来",实际还多了一个副作用:padding 会
|
||
* **缩短窗格本身** ⇒ 内容永远到不了条底下 ⇒ 玻璃条背后只剩一张已经被壁纸层
|
||
* 模糊过的壁纸 ⇒ 系统材质无东西可糊 ⇒ 看起来是一块普通浅色面板,而不是玻璃。
|
||
* 用户 2026-09-17:「底栏不是玻璃质感,滑动内容无法穿过底栏」——同一个根因。
|
||
*
|
||
* 正确做法:窗格**满高**(内容滑得到条底下,真正穿过),
|
||
* 让位加在各滚动容器的**内容末尾**(`contentEndOffset(this.navReserve)`)。
|
||
* 与 WebUI 同构:`.narrow-shell` 是 flex-col,列表满高、`.narrow-nav` 叠在上面。
|
||
*/
|
||
const contentOffsets = [...main.matchAll(/\.contentEndOffset\(this\.navReserve\)/g)];
|
||
assert.ok(contentOffsets.length >= 5,
|
||
`各滚动容器都要在**内容末尾**让位(至少 5 处:收件箱/发件箱/授权/联系人×2/日历),实际 ${contentOffsets.length} 处`);
|
||
assert.ok(!/\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main),
|
||
'★ 不许再用窗格 padding 让位 —— 那会缩短窗格、内容到不了条底下,玻璃就没东西可糊');
|
||
/*
|
||
* 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)。
|
||
*
|
||
* 这里原来是 `main.slice(定位).slice(0, 400)` 的**窗口式**断言 —— 2026-09-14 加日历
|
||
* 那一支时它红了:内容层里多了一个 pane,`.padding(...)` 落到了 400 字符之外。
|
||
* 判的是"同一件事",红的原因却只是"变长了",正是本仓库反复记的窗口式判据。
|
||
* 改成**按花括号配对取正文**:内容层那个 Column 的 `{...}` 里必须出现让位。
|
||
*/
|
||
const contentM = /Column\(\) \{\n\s*if \(this\.currentIndex === 0\)/.exec(main);
|
||
const contentOpen = contentM ? contentM.index : -1;
|
||
assert.ok(contentOpen > 0, '要能找到挂载内容的那层 Column');
|
||
// `.padding(...)` 是**链在 `}` 之后**的,所以不能只看 {} 里面 —— 从配对结束处往后取到 NavBar 之前
|
||
const braceAt = main.indexOf('{', contentOpen);
|
||
let depth = 0;
|
||
let end = -1;
|
||
for (let i = braceAt; i < main.length; i++) {
|
||
if (main[i] === '{') depth++;
|
||
else if (main[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||
}
|
||
assert.ok(end > 0, '内容层的花括号要闭合');
|
||
/*
|
||
* 注:这里**不再**用"从内容层 `}` 往后扫"的窗口式断言。
|
||
* 那个切片靠数花括号定界,而本文件(以及这些注释本身)里就有大量 `{` / `}`
|
||
* (`.padding({` 这类字面量)—— 计数被注释里的括号带偏,切片能长到 1700+ 字符,
|
||
* 一路扫进 `onAreaChange`(那里会合法地出现 `NAV_CONTENT_RESERVE`)⇒ 假红。
|
||
* 正是本仓库反复记的"窗口式判据"。
|
||
*
|
||
* 要判的事上面那条**已经判了**:全文件不得再有
|
||
* `.padding({ bottom: ... NAV_CONTENT_RESERVE })`(负向断言,不依赖切片)。
|
||
*/
|
||
});
|
||
|
||
test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项是 通信/日历/联系人', () => {
|
||
/*
|
||
* 反面:如果只是"加了自绘条但仍留着系统 bar",界面上会出现两条导航(且系统那条
|
||
* 依然贴边、依然占位),这正是这一期要消除的东西。所以断言根导航里**没有**
|
||
* `Tabs(` / `TabContent` / `tabBar(`。
|
||
* 注意:**通信页内部**的三栏仍然用系统 `Tabs`(那是页面内部页签,不是根导航)——
|
||
* 所以这条只在"根组件"的范围内断言,别扫全文。
|
||
*/
|
||
const root = main.slice(main.indexOf('build() {\n /*\n * 底部四枚图标'));
|
||
assert.ok(root.length > 300, '要能取到根 build() 的正文');
|
||
const navCode = stripComments(root);
|
||
assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs');
|
||
assert.ok(!/TabContent/.test(navCode), '根导航里不该再有 TabContent(内容改为按 index 挂载)');
|
||
assert.ok(!/\.tabBar\(/.test(navCode), '根导航里不该再有 tabBar()');
|
||
// 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效)
|
||
assert.ok(!/TabBarBuilder/.test(stripComments(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
|
||
|
||
// 平级项:通信/日历/联系人 + 外壳入口「我的」(与 WebUI NarrowNav 的四入口一致)
|
||
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '日历', '联系人', '我的'], '底栏四入口与顺序');
|
||
assert.equal(new Set(N.NAV_ITEMS.map(i => i.key)).size, N.NAV_ITEMS.length, '键要唯一(ForEach 的 key)');
|
||
// navKeyAt 只覆盖内容窗格(前三项),越界回 0 —— 外壳入口不在此函数范围内
|
||
assert.deepEqual(N.NAV_CONTENT_ITEMS.map(i => N.navKeyAt(N.NAV_CONTENT_ITEMS.indexOf(i))), N.NAV_CONTENT_ITEMS.map(i => i.key),
|
||
'navKeyAt 与内容窗格清单一致');
|
||
// 归一化:越界/负数都退回第一项(点击与外部传值都过它)
|
||
assert.equal(N.normalizeNavIndex(-1), 0);
|
||
assert.equal(N.normalizeNavIndex(99), 0);
|
||
assert.equal(N.normalizeNavIndex(1), 1);
|
||
assert.equal(N.normalizeNavIndex(2), 2, '第三项(联系人)也必须归一到自己');
|
||
/* 第 4 项「我的」是内容窗格 ⇒ index 3 要归一到自己(不再是"越界回 0") */
|
||
assert.equal(N.normalizeNavIndex(3), 3,
|
||
'「我的」是内容窗格(2026-09-17),index 3 要归一到自己 —— 否则点它会被静默踢回收件箱');
|
||
assert.equal(N.normalizeNavIndex(4), 0, '越界(4)才回 0');
|
||
assert.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效');
|
||
});
|
||
|
||
/*
|
||
* ★ 窗格切换必须**真的有**过场动画。
|
||
*
|
||
* 用户(2026-09-17):「一方面一点动画都没有」。实测改造前全仓
|
||
* `animateTo` / `transition` / `animation` **一次都没用过**。
|
||
*
|
||
* 这条判据钉三件事,缺一条动画在这套代码里就是**静默失效**:
|
||
* ① `animateTo` 只负责开动画窗口 —— 被换掉的子树自己不声明 `.transition(...)`
|
||
* 就**什么都不会动**。实测确认:只包 `animateTo` 与硬切肉眼看不出差别。
|
||
* ② 过场动画必须挂在**会被插入/移除的那棵子树**上(if/else 的分支根),
|
||
* 挂在两级之上的稳定父容器上等于没挂(第一版就是这么写错的,
|
||
* 实测 6 秒取样仍是硬切)。
|
||
* ③ 时长/曲线必须来自令牌,不许就地拍数 —— 与 WebUI 的
|
||
* `--dur-fast`/`--dur-base`/`--ease-out-soft` 同一根尺子。
|
||
*/
|
||
test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵子树上)', () => {
|
||
const theme = code(join(HARMONY_ETS, 'common', 'Theme.ets'));
|
||
const mainCode = stripComments(main);
|
||
|
||
// 令牌:三个数与 WebUI 逐字一致
|
||
assert.match(theme, /durFast:\s*number\s*=\s*120/,
|
||
'★ durFast 必须是 120(WebUI `--dur-fast: 120ms`)');
|
||
assert.match(theme, /durBase:\s*number\s*=\s*180/,
|
||
'★ durBase 必须是 180(WebUI `--dur-base: 180ms`)');
|
||
assert.match(theme, /curves\.cubicBezierCurve\(\s*0\.22\s*,\s*1\s*,\s*0\.36\s*,\s*1\s*\)/,
|
||
'★ 缓动必须是 cubic-bezier(0.22, 1, 0.36, 1)(WebUI `--ease-out-soft`)——' +
|
||
'用 Curve.EaseOut 之类枚举是**另一根**曲线,观感对不上');
|
||
// 上浮量与 WebUI @keyframes rise-in 的 translateY(4px) 一致
|
||
assert.match(theme, /riseInOffset:\s*number\s*=\s*4/,
|
||
'★ 入场上浮量必须是 4(WebUI `@keyframes rise-in` 的 `translateY(4px)`)');
|
||
|
||
// transition 构造器存在,且入场/出场**不对称**(同长会闪)
|
||
assert.match(theme, /paneRiseIn\(\):\s*TransitionEffect/,
|
||
'要有 paneRiseIn() 这个过渡构造器');
|
||
assert.match(theme, /TransitionEffect\.asymmetric\(/,
|
||
'★ 入场/出场必须 asymmetric(出场更快)—— 同长会让新旧两层半透明叠着,看起来像"闪一下"');
|
||
|
||
// ② 关键:transition 要挂在 if/else 的**分支根**上
|
||
const branchRoots = (mainCode.match(/\}\)\s*\n\s*\.transition\(Theme\.paneRiseIn\(\)\)/g) ?? []).length;
|
||
assert.ok(branchRoots >= 2,
|
||
`★ transition 要挂在会被换掉的子树根上(if/else 分支),实际只找到 ${branchRoots} 处。` +
|
||
'挂在两级之上的稳定父容器上等于没挂(实测过:6 秒取样仍是硬切)。');
|
||
// 日历那一支(常驻 + visibility)也要有
|
||
assert.match(mainCode, /\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/,
|
||
'★ 日历窗格(常驻挂载 + visibility 控制)也要挂过场过渡');
|
||
|
||
// animateTo 走 getUIContext(全局 animateTo 已废弃,另行有判据钉)
|
||
assert.match(mainCode, /getUIContext\(\)\.animateTo\(/,
|
||
'切窗格要开动画窗口:`getUIContext().animateTo(...)`');
|
||
});
|
||
|
||
test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS 登记的放行条件)', () => {
|
||
/*
|
||
* pi 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次;
|
||
* ②模糊该出现在"背后是可变内容"的层。悬浮条背后是**会滚动的内容** ✓,
|
||
* 而壁纸层背后什么都没有 → 壁纸层不许有材质/模糊。
|
||
* 这里钉的是这一期的分工,跨端的登记册在 `cross-client-theme.test.mjs`(GLASS_REGISTRY)。
|
||
*/
|
||
const wallpaper = builderBody(main, 'WallpaperLayer() {');
|
||
/*
|
||
* ★ 2026-09-15 修正(P4c 补上"壁纸真的会糊"之后):原来这里写的是
|
||
* 「壁纸层不许有**任何**模糊调用」。那句把两种不同的物理量混为一谈 ——
|
||
* 壁纸层要做的是 **图片内容模糊**(`.blur(px)`,与 WebUI 的
|
||
* `filter: blur(var(--bg-blur))` 同一个量),**不许**做的是 **面板材质**
|
||
* (`backgroundBlurStyle`:作用对象是"背后的内容",而壁纸层背后什么都没有,
|
||
* 那才是"给一张糊过的底再糊一遍"的形状)。理由与出处见
|
||
* `harmony-appearance.test.mjs` 里那条"模糊归属"。判定改成按**两种模糊**分别钉。
|
||
*/
|
||
const wallpaperCode = stripComments(wallpaper);
|
||
assert.ok(!/backgroundBlurStyle/.test(wallpaperCode),
|
||
'壁纸层不许有**面板材质**(作用在背后内容上;壁纸层背后什么都没有)');
|
||
const imgBlurs = wallpaperCode.match(/\.blur\(this\.bgPlan\.blurPx\)/g) || [];
|
||
assert.equal(imgBlurs.length, 1,
|
||
`壁纸层的**图片内容模糊**只许一次(现在 ${imgBlurs.length} 次)—— 同一张底糊两遍 = 更脏更掉帧`);
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/,
|
||
'悬浮条必须有系统材质(背后是滚动内容),且**经 navMaterialFor 保底**(blur=0 也不许变透明)');
|
||
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里)
|
||
const mainRaw = read('pages/MainPage.ets');
|
||
const navDoc = mainRaw.slice(mainRaw.indexOf('底部导航:**自绘的悬浮玻璃条**'), mainRaw.indexOf('@Builder\n NavBar() {'));
|
||
assert.match(navDoc, /会滚动的内容|滚动内容/, '要写清"为什么这一处可以有材质"(背后是可变内容)');
|
||
assert.match(navDoc, /GLASS_REGISTRY/, '要指名登记册,后人查得到放行流程');
|
||
});
|
||
|
||
/**
|
||
* ⑤ 选中态:**只换颜色**,且**图标与文字都换** —— 与 WebUI 同一套表达。
|
||
*
|
||
* 用户(2026-09-14):「同时底部导航栏选中对应的文字和图标变色即可」。
|
||
* 这条要求有两半,缺任何一半都会变成"假同步":
|
||
* · **只有颜色**:多一个背景块或指示条就不是"即可"了(WebUI 侧刚删掉这两样,
|
||
* `NarrowNav.tsx` 的 `bg-blue-400` 指示条与 `.nav-item` 的 nav-active-bg);
|
||
* · **图标也要变**:鸿蒙侧原来只给 label 上了色,图标保持默认色 ——
|
||
* 结构上"存在选中态"、看上去却像选中了一半。图标是文本字形(`item.icon`),
|
||
* 所以它必须和 label 过**同一个**三元式。
|
||
*
|
||
* 跨端对齐:这条与 WebUI 的 `nav-merge.test.mjs ④` 是同一条要求的两端实现,
|
||
* 两边各自钉住自己的写法(`cross-client-*` 那几条钉的是共享数值,不是这里)。
|
||
*/
|
||
test('⑤ 选中态只换颜色:图标与文字**一起**换色,且不引入背景/指示条', () => {
|
||
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
|
||
const itemCode = stripComments(item);
|
||
/*
|
||
* ★ 2026-09-17 修正一处**事实错误**。
|
||
*
|
||
* 这条判据原来钉的是"纯图标、不允许有文字",依据是注释里那句
|
||
* 「WebUI 的底部导航是纯图标」。那句话是**错的**:
|
||
* `NarrowNav.tsx:83-86` 的每个导航项是
|
||
* `flex flex-col items-center justify-center gap-0.5`
|
||
* 里面 `<Icon />` 之后就是 `<span className="text-3xs leading-none">{short}</span>`
|
||
* —— WebUI **一直有文字**(通信 / 日历 / 联系人 / 我的)。
|
||
*
|
||
* 于是那条判据把一个**假事实**固化成了规则,还反过来挡住了正确做法。
|
||
* 用户 2026-09-17 原话:「还是在导航栏加上文字吧,没有文字还是不好看」。
|
||
*
|
||
* 现在钉的是真正的契约:**图标与文字都跟着选中态换色**(两处三元式),
|
||
* 且**不引入**背景块/指示条 —— 用户 2026-09-14:「选中对应的文字和图标变色即可」。
|
||
*/
|
||
const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
|
||
const hits = itemCode.match(active) || [];
|
||
assert.equal(hits.length, 2,
|
||
`图标与文字**各一处**选中三元式(现在 ${hits.length} 处)—— ` +
|
||
'少了是"选中了一半"(图标或文字不换色),多了是别的形状混进来了');
|
||
|
||
// 图标:换色 + 尺寸(与 WebUI 的 w-5 h-5 = 20 同量级,鸿蒙取 24)
|
||
const icon = itemCode.slice(itemCode.indexOf('AmIcon({'), itemCode.indexOf('AmIcon({') + 200);
|
||
assert.match(icon, /iconColor:\s*this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/,
|
||
'图标的 iconColor 必须跟着选中态走');
|
||
assert.match(icon, /iconSize:\s*24/,
|
||
'底栏图标 24vp(与 WebUI `NarrowNav` 的 24×24 同几何;原来 28 是为了"补没有文字时的小")');
|
||
|
||
// 文字:**必须有**,且与图标过**同一个**三元式
|
||
assert.match(itemCode, /Text\(item\.label\)/,
|
||
'底栏要有文字 label —— WebUI `NarrowNav.tsx` 一直是「图标 + 文字」,' +
|
||
'「纯图标」那条依据是错的(用户 2026-09-17:「没有文字还是不好看」)');
|
||
const label = itemCode.slice(itemCode.indexOf('Text(item.label)'));
|
||
assert.match(label.slice(0, 220),
|
||
/fontColor\(this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg\)/,
|
||
'文字要与图标过同一个选中三元式(否则"选中了一半")');
|
||
|
||
// 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素)
|
||
assert.ok(!/backgroundColor\([^)]*currentIndex/.test(itemCode),
|
||
'选中态不许改背景色("变色即可",形状是另一套语言)');
|
||
assert.ok(!/Divider\(|\.borderRadius\([^)]*currentIndex/.test(itemCode),
|
||
'选中态不许加指示条 / 圆角块');
|
||
|
||
// 选中色与未选中色必须真的是两个不同来源(都指向同一个令牌就成了恒等)
|
||
const theme = read('common/Theme.ets');
|
||
assert.match(theme, /static readonly navFgActive: string = Theme\.accentStrong/,
|
||
'选中色取品牌深色变体');
|
||
assert.match(theme, /static readonly navFg: Resource = \$r\('sys\.color\.ohos_id_color_text_secondary'\)/,
|
||
'未选中色取系统次要文字色(不手写色值)');
|
||
});
|
||
|
||
/** ⑤ 的变异自检:退回"只有图标换色 / 没有文字 / 图标不上色"必须被判红。 */
|
||
test('★ ⑤ 变异自检:nav 项退回缺文字、或图标/文字只换一半色,必须被判红', () => {
|
||
const oneActive = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
|
||
const twoActive = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
|
||
|
||
// 变异 1:没有文字(就是被纠正的那版)⇒ 三元式只剩 1 处 + 没有 Text(item.label)
|
||
const noLabel = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg }) }';
|
||
assert.equal((noLabel.match(oneActive) || []).length, 1,
|
||
'变异 1 自检失败:无文字样本应当只有 1 处选中三元式');
|
||
assert.ok(!/Text\(item\.label\)/.test(noLabel),
|
||
'变异 1 自检失败:无文字样本里不该有 Text(item.label)');
|
||
|
||
// 变异 2:文字不换色(选中了一半)
|
||
const labelNoColor = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg }) Text(item.label).fontColor(Theme.navFg) }';
|
||
assert.equal((labelNoColor.match(twoActive) || []).length, 1,
|
||
'变异 2 自检失败:文字不换色时应当只有图标那 1 处三元式(判据会判红)');
|
||
|
||
// 变异 3:图标不上色
|
||
const noIconColor = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24 }) Text(item.label).fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg) }';
|
||
assert.ok(!/iconColor/.test(noIconColor),
|
||
'变异 3 自检失败:样本里图标不该有 iconColor');
|
||
});
|
||
|
||
/**
|
||
* ★ 导航条材质:**固定系统档**,且**不随 `bg_blur` 变**(契约判据,不是源码形状判据)。
|
||
*
|
||
* ── 这条判据换过两次形状,两次都值得记 ──
|
||
*
|
||
* ① **不能钉整行字面表达式**。此前它写的是
|
||
* `/\.backgroundBlurStyle\(NAV_MATERIAL_OF\[navMaterialFor\(\.\.\.\)\]/` 那一整串 ——
|
||
* 那是"对源码形状的匹配"(`CRITERIA.md` 明令不许退化成这个):换个等价写法就误红,
|
||
* 而真正的语义("用系统材质、不手写 alpha")它并没在判。
|
||
* pi 2026-09-15 指出五处都是这个形状 ⇒ 现在改为**语义断言**:
|
||
* 导航条那一处的材质必须来自 `Theme.navMaterial`(系统枚举令牌)。
|
||
*
|
||
* ② **"可达性"那版随方案一起作废**。它曾断言"`navMaterialFor` 在 0..40 的每个整数上
|
||
* 都不返回 `'NONE'`"—— 那是在给**方案 (b)**(档位跟随 `bg_blur` + 保底下限)把关。
|
||
* pi 推翻了 (b):导航条是 **chrome**,材质应当稳定,不该因为用户换张壁纸而变厚变薄;
|
||
* 而滑杆的语义是"**背景**"(`BackgroundPicker.tsx:183` 的 label/hint),
|
||
* 它**已经**被壁纸层消费(`.blur(this.bgPlan.blurPx)`),从来不是导航条的控件。
|
||
* ⇒ 方案 (b) 与配套的 `navMaterialFor` 一并删除,"可达性"就**没有对象**了。
|
||
* **判据随契约走,不随实现走**:所以这里换成判 (a) 的契约。
|
||
*/
|
||
test('★ 导航条材质是**固定系统档**:来自 Theme.navMaterial,且不随 bg_blur 变', () => {
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
|
||
// 语义 A:那一处的材质**来自系统令牌**,不是手写色值/alpha("用系统方案"的落点)
|
||
assert.match(bar, /\.backgroundBlurStyle\(Theme\.navMaterial\)/,
|
||
'悬浮条必须有系统材质(背后是滚动内容),且来自 `Theme.navMaterial` 这个系统档令牌');
|
||
|
||
/*
|
||
* 语义 B:**不许跟随 `bg_blur`** —— 这是 pi 的裁定,也是本条的核心。
|
||
* 判法:导航条那一段的**真代码**里不许出现 `blurPx`/`blurStyleFor`
|
||
* (**协议级**的否定,而不是"没出现某个特定表达式"——后者换个表达式就绕过去了)。
|
||
*
|
||
* ★ 必须**剥掉注释**再判(与上面"条上不许手写色值"同一手法):
|
||
* 本判据第一版没剥,当场红了 —— 而红的原因不是代码错,是 `NavBar` 的
|
||
* **文档注释**里恰好写着"我一度把档位接过用户偏好
|
||
* (`navMaterialFor(this.bgPlan.blurPx)`)"这句历史说明。
|
||
* 注释**说明**禁令 ≠ 违反禁令;不剥注释,这条判据就会变成"逼人删掉解释",
|
||
* 而那正好与本仓库"理由要写清"的纪律相反。
|
||
*/
|
||
const barCode = stripComments(bar);
|
||
assert.ok(!/blurStyleFor|blurPx/.test(barCode),
|
||
'★ 导航条材质**不许**由 `bg_blur` 驱动:`blurPx`/`blurStyleFor` 不得出现在 NavBar 的真代码里。\n' +
|
||
' (导航条是 chrome —— 材质应当稳定;滑杆的语义是"背景",它已经被壁纸层消费了。)');
|
||
// 自检前提:注释里**确实**留着那处历史说明(否则上面那条"剥注释"就是空跑)
|
||
assert.ok(/blurPx/.test(bar),
|
||
'自检前提失效:NavBar 的注释里本应留着一处含 `blurPx` 的历史说明');
|
||
|
||
/*
|
||
* 反面自检:造一个"跟随用户偏好"的样本,确认语义 B **抓得到**。
|
||
* 没有这一枪,"不出现 blurPx"可能只是因为那段代码里恰好没有别的写法。
|
||
*/
|
||
const badSample = 'Row() { Text("x") }.backgroundBlurStyle(MATERIAL[blurStyleFor(this.bgPlan.blurPx)])';
|
||
assert.ok(/blurStyleFor|blurPx/.test(badSample),
|
||
'自检失败:跟随 bg_blur 的写法应当被判据抓到(否则语义 B 是个空壳)');
|
||
// 且合法写法不许被它误伤
|
||
const goodSample = 'Row() { Text("x") }.backgroundBlurStyle(Theme.navMaterial)';
|
||
assert.ok(!/blurStyleFor|blurPx/.test(goodSample), '自检失败:合法写法被语义 B 误伤了');
|
||
|
||
/*
|
||
* 语义 C:`Theme.navMaterial` 必须是**系统枚举**里的档位、且**不是 NONE** ——
|
||
* 否则"固定档"固定到了一个"没有材质"的值上,等于导航条没有玻璃。
|
||
* (这正是我这轮被抓的另一个形状:令牌有"引用"但那份引用不可达 ⇒ 判据照样绿。)
|
||
*/
|
||
const theme = read('common/Theme.ets');
|
||
const decl = /static readonly navMaterial:\s*BlurStyle\s*=\s*BlurStyle\.(\w+)\s*;/.exec(theme);
|
||
assert.ok(decl, 'Theme.navMaterial 要声明为 `BlurStyle` 枚举值(具体档位,不是变量)');
|
||
assert.notEqual(decl[1], 'NONE',
|
||
'★ `Theme.navMaterial` 不许是 `BlurStyle.NONE` —— 那等于导航条没有材质("玻璃"名存实亡)');
|
||
|
||
// 档位名必须**真实存在于 SDK 枚举**(自造名字是"编译不过或不生效",真机上最难查)
|
||
const commonDts = code(process.env.HARMONY_COMMON_DTS
|
||
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts');
|
||
const enumBlock = commonDts.slice(commonDts.indexOf('declare enum BlurStyle'));
|
||
const members = [...enumBlock.slice(0, enumBlock.indexOf('}'))
|
||
.matchAll(/^\s{2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1]);
|
||
assert.ok(members.length > 3, '要从 SDK 里读到 BlurStyle 成员');
|
||
assert.ok(members.includes(decl[1]),
|
||
`Theme.navMaterial 用的档位 \`${decl[1]}\` 必须在 SDK 的 BlurStyle 枚举里` +
|
||
`(成员:${members.join('、')})`);
|
||
});
|
||
|
||
/*
|
||
* ★ 滚动列表的上下边缘渐隐(2026-09-17 用户:「上下还是硬截断,不是 webui 那种渐变」)。
|
||
*
|
||
* WebUI 的做法是 `index.css` 里给 `.overflow-y-auto` 加
|
||
* `mask-image: linear-gradient(to bottom, transparent 0, #000 min(12px,10%), ...)`。
|
||
* ArkUI 里**不要**手搓一层渐变色遮罩 —— 那是拿背景色画一个并不存在的"底色",
|
||
* 在壁纸/玻璃主题下会露馅(壁纸本身带颜色,遮罩层一定对不上)。
|
||
* 对应物是滚动容器自带的 `fadingEdge(enabled, { fadingEdgeLength })`(API 14+,
|
||
* 定义在 `ScrollableCommonMethod` 上 ⇒ List/Scroll/Grid/WaterFlow 都有),
|
||
* 它淡掉的是**渲染结果本身**,与背景无关。
|
||
*
|
||
* 这一条钉三件事:
|
||
* ① 常量存在且是"可争论的那一个数"(派生自 WebUI 的 12px,不是随手写);
|
||
* ② 五个列表容器**都**挂上了(漏一个就是"有的地方还是硬截断");
|
||
* ③ 用的是 `LengthMetrics.vp(...)` 而不是裸数字 —— fadingEdgeLength 是 LengthMetrics。
|
||
*/
|
||
test('★ 滚动列表上下边缘渐隐:所有列表容器都挂 fadingEdge(对应 WebUI 的 mask-image)', () => {
|
||
const nav = read('model/NavItems.ts');
|
||
const len = /export const LIST_FADE_LENGTH:\s*number\s*=\s*(\d+)\s*;/.exec(nav);
|
||
assert.ok(len, 'NavItems.ts 要导出 LIST_FADE_LENGTH(渐隐高度,单一可争论的数)');
|
||
assert.equal(len[1], '12',
|
||
'渐隐高度取 12vp:与 WebUI 的 `min(12px, 10%)` 同量级、也接近列表内边距(10vp)。' +
|
||
'改这个数要有理由(它直接决定"切得有交代"的观感强度)');
|
||
|
||
// 五个列表容器:收件箱 / 发件箱 / 授权 / 联系人卡片 / 联系人列表
|
||
const lists = main.match(/List\(\{[^)]*\}\)\s*\{/g) ?? [];
|
||
assert.ok(lists.length >= 5, `MainPage 里应当有 5 个列表容器,实际 ${lists.length} 个(判据前提要复核)`);
|
||
|
||
const fades = main.match(/\.fadingEdge\(/g) ?? [];
|
||
assert.equal(fades.length, lists.length,
|
||
`每个列表容器都要挂 fadingEdge(实际 ${fades.length} 个 / ${lists.length} 个列表)。` +
|
||
'漏掉的那个就是"上下还是硬截断" —— 用户 2026-09-17 原话。');
|
||
|
||
// 每个 fadingEdge 都要带上显式长度,且走 LengthMetrics.vp(不是裸数字)
|
||
const withLen = main.match(/\.fadingEdge\(true,\s*\{\s*fadingEdgeLength:\s*LengthMetrics\.vp\(LIST_FADE_LENGTH\)\s*\}\)/g) ?? [];
|
||
assert.equal(withLen.length, fades.length,
|
||
`每个 fadingEdge 都要写 \`{ fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) }\`。` +
|
||
'不写长度会落回默认 32vp —— 在 58px 高的小容器里会把内容洗白' +
|
||
'(WebUI 那边踩过同一个坑:「渐变用的过猛了,比如收件人候选那里」)。');
|
||
|
||
// 不许用"渐变色遮罩"顶替:那是拿背景色画假底色,壁纸主题下必然露馅
|
||
assert.ok(!/linearGradient\(\{[^}]*transparent[\s\S]{0,200}\.mask\(/.test(main),
|
||
'★ 不许用 linearGradient + mask 手搓渐隐:那是拿背景色画一个不存在的"底色",' +
|
||
'壁纸/玻璃主题下会露馅。用滚动容器原生的 fadingEdge(淡的是渲染结果本身)。');
|
||
|
||
// LengthMetrics 要真的 import 进来(否则编译不过 —— 但这条更早给出可读的错)
|
||
assert.match(main, /import\s*\{[^}]*LengthMetrics[^}]*\}\s*from\s*'@kit\.ArkUI'/,
|
||
'MainPage 要从 @kit.ArkUI import LengthMetrics');
|
||
});
|
||
|
||
/*
|
||
* ★ 渐隐必须覆盖**所有**滚动容器,不只是 MainPage 那一批。
|
||
*
|
||
* 用户(2026-09-17):「你的顶栏为什么还是硬截断而不是渐变?」
|
||
*
|
||
* 根因正是上一条判据的**扫描范围太窄**:它只看 `pages/MainPage.ets`,
|
||
* 于是 `MailDetailPage` / `CalendarPage` / `SettingsPage` / `AdminUsersPage` /
|
||
* `SessionsPage` / `InboxPage` 里的滚动容器**一个都没被覆盖** ——
|
||
* 那些页面滚起来就是硬切,而判据全绿。
|
||
* 这跟本仓库反复记的"窗口式/范围式判据"是同一个病:
|
||
* 判据自己划的圈,正好把出问题的那块划在外面。
|
||
*
|
||
* 现在改成**按文件枚举 + 计数配平**:每个页面里 `Scroll()` / `List(` / `Scroll(`
|
||
* 的个数,必须等于该文件里 `.fadingEdge(` 的个数。少一个就红,
|
||
* 而且报错会指名是哪个文件少了几处。
|
||
*/
|
||
test('★ 上下渐隐覆盖**所有**页面的滚动容器(不是只有 MainPage)', () => {
|
||
const pages = readdirSync(join(HARMONY_ETS, 'pages')).filter(f => f.endsWith('.ets'));
|
||
assert.ok(pages.length >= 8, `pages/ 下应有 ≥8 个页面,实际 ${pages.length}(扫描范围别退化)`);
|
||
|
||
/*
|
||
* 例外:`LoginPage` 的 `Scroll` 是**整页根节点**(表单内容竖直排布),
|
||
* 不是"一列卡片"—— 它的上下就是屏幕边,没有任何内容从它下面穿过,
|
||
* 所以渐隐在这里只会把品牌卡与首个输入框洗白,没有任何遮挡要交代。
|
||
* 这与 WebUI 只给 `.overflow-y-auto`(列表容器)加 mask、不给页面根加是同一条判断。
|
||
* **例外必须写明理由**(本仓库纪律),且只有这一个。
|
||
*/
|
||
const EXEMPT = new Set(['LoginPage.ets']);
|
||
|
||
const offenders = [];
|
||
let totalContainers = 0;
|
||
for (const f of pages) {
|
||
if (EXEMPT.has(f)) continue;
|
||
const src = code(join(HARMONY_ETS, 'pages', f));
|
||
const containers = (src.match(/\b(?:Scroll\(\)|(?:Scroll|List)\(\{)/g) ?? []).length;
|
||
const fades = (src.match(/\.fadingEdge\(/g) ?? []).length;
|
||
totalContainers += containers;
|
||
if (fades < containers) {
|
||
offenders.push(`${f}(容器 ${containers} / 渐隐 ${fades})`);
|
||
}
|
||
}
|
||
/*
|
||
* 自检:扫到的容器总数要合理。写死一个下限是为了防"正则写坏 ⇒ 一个都匹配不到 ⇒
|
||
* 每个文件都是 0/0 ⇒ 判据全绿"。当前仓库是 13 个。
|
||
*/
|
||
assert.ok(totalContainers >= 10,
|
||
`全仓滚动容器应 ≥10 个,实际扫到 ${totalContainers} —— 正则或目录范围退化了`);
|
||
assert.deepEqual(offenders, [],
|
||
`这些页面的滚动容器缺 fadingEdge:${offenders.join('、')}\n` +
|
||
'现象就是"顶栏/底边硬截断而不是渐变"(用户 2026-09-17 原话)。' +
|
||
'每个 `Scroll()` / `List(...)` 都要挂 `.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })`。');
|
||
});
|
||
|
||
/*
|
||
* ★ 悬浮加号必须待在 Navigation 的**列表侧**,不能当 Navigation 的兄弟。
|
||
*
|
||
* 症状(2026-09-17 模拟器截图硬证):窄屏 Stack 模式下点开邮件详情,右下角
|
||
* **同时**出现两个圆形按钮 —— 详情自己的「回复」(蓝色胶囊)与外壳的
|
||
* compose 加号,后者悬空压在详情上。
|
||
*
|
||
* 根因:加号原先是 `Navigation` 的**兄弟**,平级放在外层 Stack 里。
|
||
* 详情画在 `Navigation` 内部,而兄弟节点画在 `Navigation` 之上 ⇒ 永远压住详情。
|
||
*
|
||
* WebUI 的对应物是 `.comm-pane`(`App.tsx`):`{listBody}<ComposeFab />` ——
|
||
* 加号在**列表窗格内部**,窄屏滑上来的详情层把列表整块(含加号)盖住,
|
||
* 详情自己的回复按钮才露得出来。Split 模式下加号仍在左栏,与 WebUI 两栏并排一致。
|
||
*
|
||
* 这条判据盯的是**层级**(在 Navigation 内部),不是"存在一个 compose 按钮" ——
|
||
* 后者在 bug 版本里也成立(所以它才漏过)。
|
||
*/
|
||
test('★ 悬浮加号在 Navigation 内部(列表侧),不能当 Navigation 的兄弟压在详情上', () => {
|
||
const body = braceBody(main, 'Navigation(this.navPathStack)');
|
||
assert.ok(body.length > 200, '要能取到 Navigation 的构建体');
|
||
assert.match(body, /iconName:\s*'compose'/,
|
||
'★ compose 悬浮加号要在 `Navigation(this.navPathStack) { ... }` 的**构建体内**。' +
|
||
'放在外面(兄弟位置)会在窄屏 Stack 详情页上悬空压住详情自己的「回复」按钮 —— ' +
|
||
'2026-09-17 模拟器截图硬证。');
|
||
assert.match(body, /borderRadius\(28\)/, '加号仍是 56 圆(直径 56 ⇒ 半径 28)');
|
||
|
||
// 反向:Navigation 之后(`.navDestination(...)` 那一段)不该再冒出 compose 按钮
|
||
const afterNav = main.slice(main.indexOf('.navDestination(this.DestinationBuilder)'));
|
||
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 CRIT = 'harmony-nav/底栏可点项';
|
||
const hdc = findHdc();
|
||
if (!hdc || !hasTarget(hdc)) {
|
||
// 设备不在 ⇒ **不计数、不超限**:这是 run-all.mjs 里 PROBES.device 的既有裁定
|
||
// (没装 SDK / 没起模拟器的机器不该天天假红),探针已经决定"不到期"。
|
||
noteRan(CRIT);
|
||
return t.skip('设备不在 —— 行为部分本次不跑(静态层仍把住源码契约)');
|
||
}
|
||
const fg = foregroundBundle(hdc);
|
||
if (fg !== 'com.agentmail.harmony') {
|
||
/*
|
||
* ★ 设备在、但前台不是我们的 ⇒ 记一次"忙",**连续超 K 轮就变红**(pi 2026-09-18 §3)。
|
||
*
|
||
* 只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红也不算绿,
|
||
* 于是永远不需要被升级 —— 到期机制要防的正是这个。所以 K 轮之内是礼貌,
|
||
* K 轮之外是闹钟。
|
||
*/
|
||
const { streak, over } = noteBusySkip(CRIT);
|
||
if (over) {
|
||
assert.fail(
|
||
`设备连续 ${streak} 轮被别的会话占着(前台是 ${fg || '空'}),本条行为判据` +
|
||
`**连续 ${busyLimit()} 轮以上没能真跑** —— 不再当作礼貌跳过。\n` +
|
||
` 这说明"不抢前台"已经变成永久状态:判据既不绿也不红 ⇒ 永远不必被升级。\n` +
|
||
` 需要人来接管:要么给本会话排到设备时间,要么显式决定这台设备上不再跑行为判据` +
|
||
`(那就要回到静态并写清理由,不能靠"一直忙"糊过去)。\n` +
|
||
` (账本:.tmp/harmony-busy-skips.json,已连续 ${streak} 轮;` +
|
||
`跑成一次即清零。上限 K=${busyLimit()},可用 AGENTMAIL_BUSY_SKIP_LIMIT 覆盖。)`);
|
||
}
|
||
return t.skip(`应用不在前台(当前是 ${fg || '空'})—— 设备被别的会话占用,不抢前台` +
|
||
`(连续第 ${streak}/${busyLimit()} 轮,超过就要变红)`);
|
||
}
|
||
noteRan(CRIT); // 真跑成了 ⇒ 清掉连续计数
|
||
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 被破坏)`);
|
||
}
|
||
/*
|
||
* ★ 走到这里 = 行为层**真的跑绿了** ⇒ 留一条"本机验过"的记录(pi 2026-09-18 闸 (ii))。
|
||
* 这是"结算(移出 STATIC_ONLY)"的前提:没有这条记录,那条判据就只是被**挪走**、
|
||
* 不是被**升级** —— 而 `static=` 那个余额会读起来像"又清了一条"。
|
||
* ⚠️ 位置必须在**全部断言之后**:红了也记 = 把没验过的当成验过了。
|
||
*/
|
||
noteBehavioralRan(CRIT);
|
||
});
|
||
|
||
/*
|
||
* ★ 判据自检(本仓纪律:**造坏样本必须红、好样本不许误报**)。
|
||
*
|
||
* 上面那条行为判据要连设备才跑得成,所以它的**取值逻辑**(`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"那行必须存在的理由');
|
||
});
|
||
|
||
/*
|
||
* ★ 判据自检:**"设备忙"的跳过必须有界**(pi 2026-09-18 §3)。
|
||
*
|
||
* 上面那条行为判据在"应用不在前台"时跳过(别人的会话在用模拟器)。只跳过不设界,
|
||
* "设备忙"就会变成到期判据的**永久灰区**:不算红也不算绿 ⇒ 永远不必被升级 ——
|
||
* 这正是到期机制要防的东西("不等谁想起来"),只是入口换成了"设备忙"。
|
||
*
|
||
* 所以给 skip 加了界:连续 K 轮没跑成 ⇒ 跳过**自己变红**(K 轮内是礼貌,K 轮外是闹钟)。
|
||
* 这里把那条界的**取值逻辑**钉住 —— 不连设备、走临时账本,所以每次跑都真的在跑:
|
||
* ① K 轮以内 ⇒ `over=false`(礼貌);
|
||
* ② 第 K+1 轮 ⇒ `over=true`(闹钟,调用方必须红);
|
||
* ③ 真跑成一次(`noteRan`)⇒ 计数清零,从 1 重新数("跑成了就不该再记前账");
|
||
* ④ **设备不在**那条路径不计数 —— 那是探针的既有裁定,不是"忙"。
|
||
*/
|
||
test('★ 判据自检:设备忙的跳过有界 —— K 轮内礼貌、超了必红、跑成即清零', async () => {
|
||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||
const { tmpdir } = await import('node:os');
|
||
const { join: pjoin } = await import('node:path');
|
||
// 账本与上限都走 env 覆盖到临时位置(默认那份是 `.tmp/` 下的真账本,自检不许碰它)
|
||
const dir = mkdtempSync(pjoin(tmpdir(), 'busy-ledger-'));
|
||
const prevLedger = process.env.AGENTMAIL_BUSY_LEDGER;
|
||
const prevLimit = process.env.AGENTMAIL_BUSY_SKIP_LIMIT;
|
||
process.env.AGENTMAIL_BUSY_LEDGER = pjoin(dir, 'ledger.json');
|
||
process.env.AGENTMAIL_BUSY_SKIP_LIMIT = '3';
|
||
try {
|
||
const C = 'selftest/忙';
|
||
// ① K 轮以内是礼貌
|
||
for (let i = 1; i <= 3; i++) {
|
||
const r = noteBusySkip(C);
|
||
assert.equal(r.streak, i, `第 ${i} 次跳过应当记到 streak=${i}(实际 ${r.streak})`);
|
||
assert.equal(r.over, false, `第 ${i}/${busyLimit()} 轮还不该红(礼貌期内)`);
|
||
}
|
||
// ② 第 K+1 轮 ⇒ 必须红
|
||
const over = noteBusySkip(C);
|
||
assert.equal(over.streak, 4, `第 4 次应当 streak=4(实际 ${over.streak})`);
|
||
assert.equal(over.over, true,
|
||
'★ 自检失败:连续跳过超过 K 轮仍不红 —— 那"设备忙"就成了永久灰区,' +
|
||
'到期判据既不绿也不红、永远不必被升级(这正是要防的那件事)');
|
||
// ③ 真跑成一次 ⇒ 清零,再忙从 1 重新数
|
||
noteRan(C);
|
||
assert.equal(busyStreak(C), 0, '★ 自检失败:跑成之后计数没清零 —— 会攒出假超限');
|
||
assert.equal(noteBusySkip(C).streak, 1, '清零之后应当从 1 重新数');
|
||
// ④ 上限可覆盖(否则判据自检没法验边界,测不了的边界等于没写)
|
||
process.env.AGENTMAIL_BUSY_SKIP_LIMIT = '1';
|
||
noteRan(C);
|
||
assert.equal(noteBusySkip(C).over, false, 'K=1 时第 1 轮仍是礼貌');
|
||
assert.equal(noteBusySkip(C).over, true, 'K=1 时第 2 轮必须红');
|
||
} finally {
|
||
if (prevLedger === undefined) delete process.env.AGENTMAIL_BUSY_LEDGER;
|
||
else process.env.AGENTMAIL_BUSY_LEDGER = prevLedger;
|
||
if (prevLimit === undefined) delete process.env.AGENTMAIL_BUSY_SKIP_LIMIT;
|
||
else process.env.AGENTMAIL_BUSY_SKIP_LIMIT = prevLimit;
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|