Files
MailUI4Agents/client/electron/test/harmony-nav.test.mjs
JianFeeeee 28e3da76d4 跨端: 左右滑动翻页(P6 第 3 步)+ 还 gesture-semantics 债 + 修跑不起来的判据基建
★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
  而是 `docs/DEBTS.json` 里挂着的一笔债 —— `gesture-semantics` 的原话是:

    「P6 第 3 步:鸿蒙侧出现滑动手势代码时**立即建**判据
      (此前建 = 只有一端存在的假判据)」

  也就是**先有手势、再钉语义**。WebUI 2026-09-14 就有滑动翻页(用户当时
  亲口提的),鸿蒙一直没有 ⇒ 之前建判据会是空真(∀x∈∅)。

## 一、手势本体(两端语义逐项对齐,数值各自定)

按 `HARMONY-ALIGN-PLAN.md:118-128` 显式选的 **(b) 口径**:
「手势的物理量本来就不该强求同值……该对齐的是**语义层**」。

· 判定逻辑放**纯逻辑层** `model/Calendar.ts` 的 `judgeSwipe`(可被 node 直跑,
  写在 .ets 里就跑不了判据,语义没法被单测钉住)。四道门:位移 / 纵向优先 /
  快滑窗口 / 方向。
· 阈值**不引用** WebUI 的 40 / 1.5 / 600(那是把巧合当契约),
  各自定为 56vp / 1.4× / 700ms,并在注释里写出取值依据。
· 接线在 `CalendarPage.ets`:`PanGesture({direction: Horizontal})` +
  `onActionStart`(记时 —— `GestureEvent` **没有时间戳字段**,我查了 SDK
  的 gesture.d.ts 确认)+ `onActionEnd`(读 offsetX/offsetY)。
· ★ 挂在**网格列**上而不是整页:右栏(日程/编辑器)里有输入框与可滚内容,
  整页挂会让「在表单里横划一下」变成翻月。
· ★ 翻页复用 `shiftRange()`(与 ‹ › 按钮**同一个来源**)—— WebUI 的注释
  专门交代过:各写一套的话,阈值、边界、三档行为迟早分叉。
  手势回调里**不准**直接改 year/month(锚点是唯一真相,年月只能由
  `shiftRange → syncYearMonthFrom` 派生)。

**有意差异(记录在案,不是漏做)**:WebUI 在周/日档会额外检查「触点是否落在
可横向滚动的区域里」,是则让给滚动条(用户 2026-09-14 报过这个冲突)。
鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该条件不适用。
哪天加了横滚必须同时补上它。

## 二、判据(8 条语义契约 + 行为层)

新增 `cross-client-gesture.test.mjs`:两端都真有手势 / 方向映射逐项相同 /
纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
**只比语义、不比数值**,并反向断言鸿蒙的阈值常量不得直接取 WebUI 的那三个数。

`harmony-logic.test.mjs` 加行为判据:真跑 `judgeSwipe`,把四道门各自验一遍
(只钉字符串的话,一个 return 写漏了照样全绿)。

## 三、顺手修掉的三处**判据基建**缺陷(不修就没法验证上面这些)

1. `run-all.mjs` 只认 `# pass N`,而 node v24 打的是 `ℹ pass N`
   ⇒ **21 个文件被记成"没自报条数"**、套件在 HEAD 就恒红(memory 里记过这条,
   修法也记过,今天终于落进代码:4 个正则加 `(?:#|ℹ)`)。修完 `unreported` 27 → 0。
   代价是暴露出一批此前被"没自报"掩盖的真实问题(下面 4~6 条)。
2. `harmony-nav` 的设备判据 `navItemsOf`:宽屏过滤条件从「左边缘靠左 1/6」
   改成「**整个盒子在侧栏轨道内**」。旧条件把**日历网格的格子**
   (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
   这是设备实测抓出来的 —— 我第一版还以为是"底部簇混进来了",
   打印真实数据才发现是隔壁页面的格子。
3. `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取
   label。原先把全文件 `label:` 一网打尽 ⇒ 得到 7 个(4+3 混在一起),
   任何一边改对了它都会红。

## 四、被判据拦住后的正经修法(每条都按判据自己给的方向改,不改判据迁就代码)

· `cross-client-theme` A2 拦住我:新增的 `navActiveBg`/`navBrandFg`/`badgePlain`
  未登记;又拦住我:`sseColorOf` 里四个裸色值。→ 抽成 `Theme.sseConnected` 等
  四个令牌(取值对齐 WebUI 的 Tailwind 类)+ 登记 + 在 Theme.ets 的表里写理由。
· 同一条判据的"死令牌"检出:`Theme.durBase` 声明了却从没人读。
  **查 WebUI 才发现壁纸淡入是真有的动效**(`index.css:742-752` 的
  `.app-backdrop` 从 opacity:0 → 1,180ms)⇒ 补上而不是删令牌
  (删掉等于把差异抹平、还说成"清理")。reset 在 `animateTo` **外**,
  与 `calPaneIn` 同一条纪律。
· 玻璃登记:`NavItemBuilder` → `SidebarItem`(重构后按最近的 @Builder 命名),
  登记同步跟上。
· `harmony-admin` 的退出判据:退出逻辑抽成 `api/Logout.ets` 的 `performLogout()`
  (两个入口——「我的」页与侧栏底簇——必须做同一件事,尤其"先注销推送 token"
  那一步)。判据相应改成**追到实际执行处**(两半都断:按钮调了 + 函数真清了全部),
  并写明"别再退回直接匹配 SETTINGS_PAGE 的写法"(那会随重构假红,
  下一个人只会去改判据而不看行为)。
· `harmony-widescreen` 的连接点色值:色值搬进 Theme 后,判据改成断
  「令牌定义对了 + 侧栏真的用了它」两半(只断任一半都有假绿形态)。
· `align-refs`:`CalendarView.tsx` 变了,按判据要求**读一遍差异**再更新登记
  (差异只有农历小字的灰阶档位 gray-300→gray-400/500,**骨架未变**)。
· `criteria-hygiene`:`harmony-contacts` 自造了一个 `code()`、`harmony-widescreen`
  裸用 `readFileSync` ⇒ 都改用 `lib/read.mjs` 的共享入口。
  途中撞出一个**判据自己的 bug**:`code()` 的块注释正则
  `/\*[\s\S]*?\*\//` 会把注释里出现的 `/*`(如 `/」**` 这种中文夹星号)
  当成块注释起点,一路吃到几十行后的 `*/`,把中间的 import 全吞掉 ——
  于是 hygiene 判据假红"没 import"。改掉那处写法后正常。

## 五、验证

判据面:`run-all.mjs` → `files=32 ran=32 checks=497 pass=497 fail=0
skip=0 red=0 broken=0 unreported=0`。
其中新/改判据:gesture 8、nav 18、widescreen 7、logic 30、admin 27、
cross-client-theme 15、criteria-hygiene 6。
构建:`hvigorw assembleHap` 成功;前端 `npm run build` + 重新打 AppImage/deb
(`build-stamp` 7/7、`packaging` 5/5)。

**设备实测(HATriple 三折叠 3184×2232,hdc 连 127.0.0.1:5555)**:
· 农历在格子里真的显示(1=二十 / 7=廿六 / 19=**初九** / 11=八月),与 WebUI 一致;
  这条同时验证了**服务端农历路由已部署**(之前线上是 404)。
· 日历左右两栏:编辑器出现在**右栏**、左栏月份仍可见(单栏模式下编辑器会整页盖掉它)。
· 侧栏 3 项 + 底部一簇;徽标回到图标右上角。

**仍未验(如实标注)**:滑动翻页的**手感**(阈值 56vp/1.4×/700ms 是否合适)
只能真人滑过才知道;我只验了判定逻辑与接线形态。动画同理 ——
机制已验证(`animateTo` 驱动 + reset 在窗口外),但"看起来顺不顺"未做取样验证。

docs:`DEBTS.json` 销掉 `gesture-semantics`(并记结算说明)、
`HARMONY-ALIGN-PLAN.md` P6 从「✅(滑动翻页除外)」改为 ✅。
2026-09-19 14:01:21 +08:00

1342 lines
85 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, ourBundle } 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)⇒ 看起来是**贴边通栏**,不是浮在壁纸上的胶囊,
* 玻璃材质也随之失去意义(背后没有内容/壁纸可透)。
* 这与收件箱头卡片修过的是同一个坑。
* ★ 2026-09-18:`bottom` 现在多加了 `windowInsets.navIndicator`(全屏后要让开系统
* 手势条,见 `harmony-window.test.mjs`)。所以断言改成:**剥注释后**看这三段在不在、
* 左右是不是 `NAV_BAR_SIDE`、离底是不是**从 `NAV_BAR_BOTTOM` 起的**。
*
* 为什么不直接写死 `bottom: NAV_BAR_BOTTOM` 那个字面串:那条正则本来就在断言
* "留白靠 padding(不是 margin)",而"离底留白多少"是另一件事(那件事由
* `NAV_BAR_BOTTOM` 的取值和 `harmony-window` 的避让判据各自守)。
* 把它写死会让"加一个正当的避让"和"改用 margin"红得一模一样 ——
* 那这条判据就不再是"守着那个坑",而是"守着当时的那个字符串"。
*/
const barCode = stripComments(bar);
assert.match(barCode, /\.padding\(\{[^}]*left:\s*NAV_BAR_SIDE[^}]*right:\s*NAV_BAR_SIDE[^}]*bottom:\s*NAV_BAR_BOTTOM/,
'留白要用外层容器的 padding(左右 + 离底)—— width(100%) + margin 在 ArkUI 里不缩宽,会顶出父容器');
assert.ok(!/\.margin\(\{[^}]*NAV_BAR_SIDE/.test(barCode),
'★ 不许再用 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);
/*
* ★★ 2026-09-18 重写:这条判据原先锚在**令牌存在**上,于是它对真正的 bug 全绿。
*
* 原先的断言是:`durBase: number = 180`、`curves.cubicBezierCurve(0.22, 1, 0.36, 1)`、
* `riseInOffset: number = 4` —— 这三个数确实在 `index.css` 里存在,**但都不是动画值**:
*
* --dur-base (180ms) + --ease-out-soft (0.22,1,0.36,1)
* → 只用在**壁纸淡入**与**控件变色 transition** 上;
* @keyframes rise-in / cal-in
* → 硬编码 **150ms / 200ms** + **cubic-bezier(0.22, 0.61, 0.36, 1)**
*
* 而当时的代码正是用 `durBase` + `easeOutSoft` 做窗格入场的 ⇒ 比 WebUI 慢 30ms
* 且曲线偏软(`(0.22,1,…)` 与 `(0.22,0.61,…)` 是两根曲线:全仓前者只出现 1 次
* =令牌定义处,后者出现 5 次 = 5 个真实动画)。
*
* **令牌存在** ≠ **动画用了它**。所以下面改成从 WebUI 源码**读出动画的真实取值**,
* 再断言鸿蒙的动画构造器引的是对应那一个 —— 而不是“仓库里有没有这个数”。
*/
const css = prose(join(ROOT, 'client', 'electron', 'src', 'index.css'));
const webuiRise = /animation:\s*rise-in\s+(\d+)ms\s+cubic-bezier\(([^)]*)\)/.exec(css);
assert.ok(webuiRise, 'WebUI 里要有 rise-in 的动画声明(本判据的参照物)');
const [, riseMs, riseCurve] = webuiRise;
assert.equal(riseMs, '150', `WebUI rise-in 实测是 150ms,读到 ${riseMs}ms —— 参照物变了,要重核两端`);
// 鸿蒙的 durRise 必须= WebUI rise-in 的真实时长(不是 --dur-base 那个 180)
assert.match(theme, new RegExp(`durRise:\\s*number\\s*=\\s*${riseMs}\\b`),
`★ paneRiseIn 的时长必须等于 WebUI rise-in 的真实值 ${riseMs}ms。` +
'写 180(=--dur-base)是拿“壁纸淡入的时长”当“面板入场的时长”——那是两块不同的东西。');
// 曲线:必须与 rise-in 那条**逐字**一致(而不是与 --ease-out-soft 一致)
const curveNums = riseCurve.split(',').map(s => s.trim().replace(/\.0+$/, ''));
assert.equal(curveNums.length, 4, 'cubic-bezier 要有 4 个参数');
assert.ok(
new RegExp(`cubicBezierCurve\\(\\s*${curveNums.map(n => n.replace('.', '\\.')).join('\\s*,\\s*')}\\s*\\)`).test(theme),
`★ 动画曲线必须与 WebUI rise-in 的 cubic-bezier(${riseCurve}) 逐字一致。` +
'用 --ease-out-soft 那条 (0.22,1,0.36,1) 是**另一根**曲线:那条只给 transition 用。');
// 两个时长/曲线令牌要**分开**存在(一个给 transition、一个给动画)
assert.match(theme, /easeOutSoft:\s*ICurve/, '要保留 transition 用的 easeOutSoft');
assert.match(theme, /easeRise:\s*ICurve/,
'★ 要有**单独一个**给 @keyframes 动画用的曲线令牌(easeRise);' +
'没有它的话,"transition 与动画各用一根曲线"这件事在代码里无处表达,下次还会被合并回去');
/*
* 上浮量与 WebUI @keyframes rise-in 的 translateY(4px) 一致
*/
assert.match(theme, /riseInOffset:\s*number\s*=\s*4/,
'★ 入场上浮量必须是 4(WebUI `@keyframes rise-in` 的 `translateY(4px)`)');
/*
* ★★ 关键:上面那些令牌必须在 `paneRiseIn()` **函数体里真的被引用**。
*
* 这一步不能省 —— 我第一版就是漏了它:断言了 `durRise = 150` 存在、
* 也断言了曲线字面量存在,**但没断言 `paneRiseIn` 用的是它们**。
* 于是把函数体换回旧的 `durBase + easeOutSoft`(真正的 bug)后,
* 判据**仍然全绿** —— 因为令牌还在,只是没人用。
* 变异自检当场抓住了这一点。
*
* 与今天修的另一处同源:**判据要锚在“这个东西被用在哪”,不是“它存在”**。
* 所以这里把函数体抠出来单独断言。
*/
const riseBody = /paneRiseIn\(\):\s*TransitionEffect\s*\{([\s\S]*?)\n \}/.exec(theme);
assert.ok(riseBody, '要能取到 paneRiseIn() 的函数体(它必须是这个方法,形状别改)');
const body = riseBody[1];
assert.match(body, /duration:\s*Theme\.durRise/,
'★ `paneRiseIn` 里入场时长必须引用 `Theme.durRise`(= WebUI rise-in 的 150ms)。' +
'写成 `Theme.durBase` 是拿“壁纸淡入的时长”当“面板入场的时长” —— 正是 2026-09-18 修的那个 bug。');
assert.match(body, /curve:\s*Theme\.easeRise/,
'★ `paneRiseIn` 里曲线必须引用 `Theme.easeRise`(= rise-in 那条 0.22,0.61,0.36,1)。' +
'写成 `Theme.easeOutSoft` 是**另一根**曲线(那是给 transition 用的)。');
assert.ok(!/Theme\.durBase/.test(body),
'★ paneRiseIn 里不得出现 `durBase`(180ms 是壁纸淡入的时长,不是面板入场)');
assert.ok(!/Theme\.easeOutSoft/.test(body),
'★ paneRiseIn 里不得出现 `easeOutSoft`(那是 transition 的曲线,不是 @keyframes 的)');
// transition 构造器存在,且入场/出场**不对称**(同长会闪)
assert.match(theme, /paneRiseIn\(\):\s*TransitionEffect/,
'要有 paneRiseIn() 这个过渡构造器');
assert.match(body, /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 秒取样仍是硬切)。');
/*
* ★★ 2026-09-19 重写日历那一条(用户:「最严重的动画问题你一点也不该改」)。
*
* 原断言:`.visibility(...)` 后面跟 `.transition(Theme.paneRiseIn())` ——
* 它锚的是**写法**,而不是“动画真的会播”。而那个写法**一帧也不会播**:
* `.transition()` 只在**挂载/卸载**时触发(SDK 原话 "when it **appears and
* disappears**"),可日历是**常驻**的(用 `visibility` 控制,因为它里面
* `today` 要随时间重算、也要保住“正在看哪个月”)—— 永不重挂载。
* 所以旧断言是**把 bug 锁住了**(与我上次给侧栏编理由同一个错法)。
*
* 新断言改成断**机制真的存在且被驱动**:
* · 常驻窗格不能用 TransitionEffect,要用可插值的属性
* (`opacity` + `translate`);
* · 那个属性要有一个 @State 支撑;
* · 切窗格时要**真的把它从 0 推到 1**(且 reset 在 animateTo 外 ——
* 否则同一帧内 0→1,起点终点都是 1,动画退化成瞬移)。
*/
assert.ok(!/\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/.test(mainCode),
'★ 日历窗格是**常驻挂载**(visibility 控制)—— 它上面的 `.transition()` 永远不会触发。' +
'那条断言锁的就是这个 bug,不得恢复。');
// 日历容器的入场:用可插值属性,且由 @State 支撑
assert.match(mainCode, /calPaneIn/,
'★ 日历窗格的入场进度要有一个 @State(`calPaneIn`)支撑 —— 常驻窗格只能靠属性插值做入场');
const calContainer = /\.visibility\(this\.currentIndex === 1[\s\S]{0,400}?\.opacity\(this\.calPaneIn\)/.exec(mainCode);
assert.ok(calContainer,
'★ 日历容器要真的用 `calPaneIn` 驱动透明度(这才是常驻窗格能播的入场)');
assert.match(mainCode, /\.translate\(\{[^}]*calPaneIn[^}]*\}\)/,
'★ 日历容器要同时驱动位移(对齐 WebUI `rise-in` 的 translateY)');
// reset 必须在 animateTo **外** —— 否则起点=终点,动画退化
/*
* ★★ 2026-09-19 修(加壁纸淡入时撞出来的):
*
* 原写法是「全文 indexOf('this.calPaneIn = 0') 与全文第一个 animateTo 比大小」。
* 而 `MainPage.ets` 里 `animateTo` 不止一处 —— 新增壁纸淡入(`bgFadeIn`)
* 之后,第一个 animateTo 出现在**日历那一大段之前** ⇒ `resetIdx < animIdx` 假红。
*
* 错法本身很典型:**拿全文下标去比两件相隔很远的事**。
* 正确的锚法是:只看**日历那一处**的局部上下文 —— 从 `calPaneIn = 0`
* 往后截一段,要求 `animateTo` 就出现在这段里且 `calPaneIn = 1` 在它之后。
*/
const calResetAt = mainCode.indexOf('this.calPaneIn = 0');
assert.ok(calResetAt > 0, '★ 切到日历前要先把 `calPaneIn` 瞬回 0(否则看不到“从无到有”)');
/* 截到 reset 之后的**同一段**(400 字符足够包住 animateTo + 赋值,又不会跨到别处) */
const calLocal = mainCode.slice(calResetAt, calResetAt + 400);
const localAnim = calLocal.indexOf('getUIContext().animateTo(');
assert.ok(localAnim > 0,
'★ `calPaneIn = 0` 之后必须紧跟 `animateTo`(同一段代码内)—— ' +
'否则这个 reset 根本没被任何动画窗口包住,等于白 reset');
assert.ok(!/calPaneIn = 1[\)\s]*;?\s*\}/.test(calLocal.slice(0, localAnim)),
'★ `calPaneIn = 1` 不得出现在 animateTo **之前**;' +
'reset 写进回调里会与同帧的 1 相抵,渲染层只看得见最终值 ⇒ 动画退化成一次瞬移(等于没修)');
const intoAnim = /getUIContext\(\)\.animateTo\([\s\S]{0,300}?this\.calPaneIn = 1/.exec(calLocal);
assert.ok(intoAnim,
'★ 推到 1 要在 `animateTo` 窗口里(与其余窗格的 transition 同时长、同曲线)');
// 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;
}
/** 屏幕宽度(px)—— 用来判断当前是窄屏(底栏)还是宽屏(左侧栏) */
function screenWidthOf(root) {
let w = 0;
for (const n of walk(root)) {
const m = (n.attributes?.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (m) w = Math.max(w, +m[3]);
}
return w;
}
/**
* 当前是否处于**宽屏**(导航是左侧栏,不是底栏)。
*
* ★ 2026-09-18 加:这个判断是因为下面那条行为判据**在三折叠展开态实测变红**了 ——
* 而它红得**没错但没用**:3184px 展开态下导航按设计就是左侧栏,
* `navItemsOf` 却只找"屏幕下 1/4"里的可点容器 ⇒ 返回 0 ⇒ 判"导航没挂"。
* 也就是说这条判据**从来没有在宽屏下跑过**(宽屏分支此前从未真正运行),
* 第一次跑就误报。
*
* 阈值:源码 `MainPage.ets` 的 `isWide` 判据是 `width >= 768`(**vp**)。
* 这里拿到的是 **px**,而密度是设备属性 ⇒ 用比例判断更稳:
* 宽屏时内容区至少能放下 navbar 侧栏 + 一个窗格,实测量到的是 910vp。
* 768vp 在 3.5 密度下 ≈ 2688px。取 0.75×宽度做"左侧栏存在"的判断会
* 与窄屏的底栏判据互斥,所以直接用 wxh 两个比例:
* 宽屏 = 宽高比 > 1.2(三折叠展开 3184/2232 = 1.43;折叠 1008/2232 = 0.45)。
* ★ 刻意用**宽高比**而不是绝对 px/vp:绝对阈值要写密度,而密度是设备属性,
* 写进来就是第二份真相(这条判据刚因为"包名写死"吃过一次亏)。
*/
function isWideLayout(root) {
const w = screenWidthOf(root);
const h = screenHeightOf(root);
if (w === 0 || h === 0) return false;
return w / h > 1.2;
}
/** 某节点**子树里**(不含自己)的非空 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);
const wide = isWideLayout(root);
/*
* 窄屏:屏幕**下 1/4** 里的可点容器(底栏)。
* 宽屏:屏幕**左 1/6** 里、高度足够大的可点容器(左侧栏项)。
*
* ★ 两个条件必须分开写,不能只把"下 1/4"放宽成"下 1/4 或左 1/6":
* 宽屏下内容区的卡片也在左侧(x 很小)且可点,放宽就全被当成导航项,
* 于是"导航项数 ≥1"恒真 —— 判据等于没有。
* 左侧栏项的实测形状(3184px 展开态):`Column [28,985][201,1380]`,
* 即 x < 220、宽 ~173、高 ~395。用"左 1/6 内 + 高 > 屏高 8%"框住它,
* 内容卡片(宽 900+)因宽度条件被排除。
*/
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) return false;
const [, x1, y1, x2, y2] = m.map(Number);
if (!wide) {
// 窄屏(底栏):必须有文字标签 —— 底栏的文字就是它的主体
if (textsUnder(n).length === 0) return false;
return y1 >= screenH * 0.75;
}
/*
* 宽屏(左侧栏):**不要求文字**。
*
* ★ 这不是放宽,是两侧本来就不同:WebUI 的 `Sidebar`(60px)是**纯图标轨**
* (无 label 文字),而用户 2026-09-16 明确说过「底部导航栏不允许有文字」
* ⇒ 侧栏同样按纯图标走。实测(3184px 展开态)侧栏项 `Column [28,985][201,1380]`
* 的子树只有 3 个节点:`Column → Stack → Path`,**没有任何 Text** ——
* 拿"有文字"去要求它,就是把底栏的契约硬套到侧栏上。
* 识别它的办法是形状(位置 + 尺寸),不是文字。
*/
const screenW = screenWidthOf(root);
const boxW = x2 - x1;
const boxH = y2 - y1;
/*
* ★★ 2026-09-19 修(被判据自己抳到):过滤条件从 “靠左 1/6” 改成
* “**整个盒子在侧栏轨道内**”。
*
* 原条件 `x1 < screenW / 6` 只要求**左边缘**靠左 —— 而日历网格的格子实测是
* `Column [229,511][366,794]`:x1=229 < 531 ✓、高 283 > 89 ✓、带文字 ✓
* ⇒ **被当成导航项**。于是一屏日历数出 11~12 个“导航项”,
* 而侧栏实际只有 3 项。
*
* 换成 “`x2`(右边缘)也在轨道内”:
* · 导航项 `[45,312][183,450]` ⇒ x2=183 ≤ 255 ✓
* · 品牌标 `[57,174][172,289]` ⇒ x2=172 ✓(但无文字,另行排除)
* · 日历格子 `[229,511][366,794]` ⇒ x2=366 > 255 ✗ **排除**
*
* 为什么用比例 `screenW * 0.08`(展开态 = 255px)而不是写死 183/200:
* 侧栏是 60vp,px 值随密度变(教训:**密度是第二个真相**,见 `isWideLayout`)。
* 0.08 在展开态的 3184px 上给 255,在单屏 1008px 上给 80(而单屏不是宽屏,不走这支)。
*/
return x2 <= screenW * 0.08 && boxH > screenH * 0.04
&& textsUnder(n).length > 0;
});
}
/**
* 宽屏侧栏里的**导航轨**(不含底部那一簇)。
*
* ★★ 2026-09-19 新增(用户:「你写的app和webui大面积不符」)。
*
* 侧栏底部那一簇(头像 / 主题 / 退出)也是可点、也有文字/图标,形状与导航项相近 ——
* `navItemsOf` 会把它们一起数进来(实测数出 12 个,而导航项应为 3 个)。
* 而 WebUI 确实有这一簇(`Sidebar.tsx:183-212`),所以**不能拿它当“多出来的项”判红**;
* 也不能因此放宽总项数(那会让“我的又回到侧栏”那个 bug 漏过去)。
*
* 所以改按**位置**切:导航项一簇**贴顶**(WebUI 实测 y = 70/122/174,`gap-1`),
* 下面用 `Blank()` 推到屏幕底部才是那一簇。取两者之间的空档切一刀即可。
*
* ★ 阈值用“屏幕高度的一半”:导航项在顶部 1/3 以内,底部簇在最后 1/4 以内,
* 中间有大段空白(实测展开态导航项在 y≈985-1380、底部簇在 y≈2000+)。
* 用比例而不是绝对 px,避开密度那第二个真相(同 `isWideLayout` 的教训)。
*/
function navRailItemsOf(root) {
const items = navItemsOf(root);
const screenH = screenHeightOf(root);
return items.filter(n => {
const m = (n.attributes?.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (!m) return false;
return Number(m[2]) < screenH * 0.5;
});
}
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);
/*
* ★ 包名从**唯一权威处**读(`ourBundle()` → AppScope/app.json5),不在这里写第二份。
*
* 2026-09-18 实测:这里原先写死 `'com.agentmail.harmony'`,包名改成
* `com.jianf.agentmail` 之后比较永远不成立 ⇒ 本条**永远走"设备忙"跳过"**,
* 账本一路数到 42 轮才被设界抓红。那 42 轮不是设备被占,是字面量漂移。
*/
if (fg !== ourBundle()) {
/*
* ★ 设备在、但前台不是我们的 ⇒ 记一次"忙",**连续超 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);
const wideNow = isWideLayout(root);
assert.ok(navItems.length >= 1,
(wideNow ? '宽屏左侧栏' : '窄屏底栏') + `要渲染出 ≥1 个可点的导航项(实际 ${navItems.length})—— ` +
'一个都没有说明导航没挂 / 被盖住 / 全不可点(dumpLayout 实测)');
/*
* 每个导航项要至少亮一个源码 NAV_ITEMS 里定义过的标签。
*
* ★ 宽屏下**侧栏没有文字**(纯图标轨,见 `navItemsOf` 的说明)⇒ 这一条
* 只在窄屏底栏上有意义。宽屏时改为判"有 ≥1 个可点导航项"(上面那条已覆盖),
* 文字对不上标签这件事在纯图标轨上不成立。
* 这是**模式差异**,不是放宽:把纯图标轨按"要有文字"判,只会永远红。
*
* (下面这段只在窄屏执行。)
* 不要求"所有 Text 都在清单里"—— 图标(emoji 或 Path 渲染出的字形)也是 Text 节点,
* 但它不是导航项的**标签**。要求"至少一个文字命中源码清单"足以抓住"标签写错"
* (写成了源码没有的字),又不误伤图标。这就是 "live ⊆ source" 的可操作写法。
*/
if (!wideNow) {
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 被破坏)`);
}
} else {
/*
* 宽屏:侧栏是**纯图标轨**(用户 2026-09-16:「底部导航栏不允许有文字」,
* 侧栏同口径 ⇒ WebUI `Sidebar` 也是无文字)。
* 但"没有文字"不等于"什么都不能判"—— 判**图标确实画出来了**:
* 每个导航项子树里要有 `Path`(`AmIcon` 的画法)且**不该有 Text**
* (有文字就说明有人往纯图标轨里塞了标签,那正是被否掉的那个方案)。
*/
/*
* ★★ 2026-09-18 修:这一段原来断言「宽屏侧栏项**不该有文字**」,理由是
* "用户明确否掉了带文字的方案"。**那是编的**:
* · 用户 2026-09-16 说的是「**底部导航栏**不允许有文字」,不是侧栏;
* · WebUI `Sidebar.tsx:110` 有 `<span className="text-3xs">{short}</span>`
* —— 侧栏**一直有**文字标签(通信/日历/联系)。
* 我据此把鸿蒙侧栏做成了纯图标,还写了判据把它锁死 —— 判据锁住的是我的错误。
*
* 现在按两侧的**共同事实**判:侧栏项 = 图标(Path)+ 文字标签。
* 图标画出 + 文字命中源码清单,两条都要。
*
* ★★ 2026-09-19 修第二处(与 harmony-widescreen ② 同一个错):
* 这一段原来拿 `N.NAV_ITEMS`(**四项**,含「我的」)的 label 去比侧栏渲染出来的文字
* —— 但侧栏是**三项**(通信/日历/**联系**),第三项对不上(“联系” ≠ “联系人”),
* 而且它反过来证明不了“侧栏多了我的”那个 bug。
* 侧栏要比的是 `NAV_SIDEBAR_ITEMS`;两项前两项相同只是巧合,不能因此混用。
*/
const sourceLabels = new Set(N.NAV_SIDEBAR_ITEMS.map(i => i.label));
/*
* 侧栏**总共**应该正好是 `NAV_SIDEBAR_ITEMS` 那个数。
* 只判“每项命中”不够:多出来的一项(例如「我的」)也可能命中一个 label,
* 而“多一个我的”本来就是这次报的 bug。所以同时判**个数**。
*/
/*
* ★★ 2026-09-19:改判**导航轨**(`navRailItemsOf`)而不是全部可点项 ——
* 侧栏底部那一簇(头像/主题/退出)是 WebUI 就有的(`Sidebar.tsx:183-212`),
* 把它数进来会得到 12,而那并不是“多了一个我的”。
* 数量仍要卡死(不然“我的回到侧栏”那个 bug 会漏过去),只是卡在**轨道**这一层。
*/
const railItems = navRailItemsOf(root);
assert.equal(railItems.length, N.NAV_SIDEBAR_ITEMS.length,
`宽屏侧栏导航轨应有 ${N.NAV_SIDEBAR_ITEMS.length} 项(与 WebUI Sidebar.tsx 的 navItems 同数),` +
`实际 ${railItems.length} 项(多出来很可能就是「我的」又回到了侧栏);` +
`(全部可点项 ${navItems.length} 个,含底部那一簇)`);
/* 后续逐项断言作用在导航轨上 */
navItems.length = 0;
navItems.push(...railItems);
for (const it of navItems) {
const paths = [...walk(it)].filter(x => x.attributes?.type === 'Path');
assert.ok(paths.length >= 1,
`宽屏侧栏项(bounds=${it.attributes.bounds})要画出图标(Path 节点)—— ` +
'没有 Path 说明图标是空的,侧栏会变成一排摸不着的空白区');
const texts = textsUnder(it);
const matched = texts.filter(l => sourceLabels.has(l));
assert.ok(matched.length >= 1,
`宽屏侧栏项要带文字标签且命中源码 NAV_SIDEBAR_ITEMS(WebUI Sidebar 有 <span>{short}</span>);` +
`实际文字:${texts.join('、')}`);
}
}
/*
* ★ 走到这里 = 行为层**真的跑绿了** ⇒ 留一条"本机验过"的记录(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"这条就是空跑');
/*
* ⑤ 宽屏(左侧栏):**纯图标轨**—— 形状识别,且不要求文字。
*
* 加这一步的原因:`navItemsOf` 的形状判断在 2026-09-18 第一次真跑宽屏时
* 把它自己判红了(三折叠展开态 3184px,导航是左侧栏而不是底栏,
* "屏幕下 1/4" 找不到任何东西)。改成分模式之后,**宽屏那一支此前没有任何
* 自检覆盖** —— 而自检没覆盖的分支就是下次回归不会响的那一支。
*
* 合成树按实测形状造:3184×2232、侧栏项 `Column [28,985][201,1380]`、子树只有 Path。
*/
/*
* 侧栏项样本按**实测形状**造(密度 2.875、三折叠展开态):
* Column [45,312][183,450] = 138×138px,子树是 AmIcon(Path) + Text('通信')。
*/
const iconRail = (y0, label) => ({
attributes: { type: 'Column', bounds: `[45,${y0}][183,${y0 + 138}]`, clickable: 'true' },
children: [
{ attributes: { type: 'Stack', bounds: `[73,${y0 + 20}][146,${y0 + 93}]`, clickable: 'false' },
children: [{ attributes: { type: 'Path', bounds: `[75,${y0 + 22}][144,${y0 + 91}]`, clickable: 'false' }, children: [] }] },
text(label, `[85,${y0 + 100}][143,${y0 + 134}]`)
],
});
const wideRoot = {
attributes: { type: 'Row', bounds: '[0,0][3184,2232]', clickable: 'false' },
children: [iconRail(312, '通信'), iconRail(462, '日历'),
// 内容区一张宽卡片(可点、有文字)—— 它**不该**被当成导航项
{ attributes: { type: 'ListItem', bounds: '[263,212][1115,568]', clickable: 'true' },
children: [text('pi', '[309,245][360,280]')] }],
};
assert.equal(isWideLayout(wideRoot), true, '自检:3184×2232 要判成宽屏');
const wideItems = navItemsOf(wideRoot);
assert.equal(wideItems.length, 2,
`★ 自检失败:宽屏侧栏应当取到 2 个导航项(实际 ${wideItems.length})—— ` +
'取到 3 个说明内容卡片被误当导航项,"导航没挂"就永远判不出来');
for (const it of wideItems) {
assert.ok([...walk(it)].some(x => x.attributes?.type === 'Path'), '自检:侧栏项要含 Path(图标画出来了)');
assert.ok(textsUnder(it).some(l => ['通信','日历','联系人','我的'].includes(l)),
'自检:侧栏项要带命中清单的文字标签(WebUI Sidebar 有 <span>{short}</span>)');
}
// 窄屏样本不能被误判成宽屏(否则上面那套形状条件会去滤底栏项)
assert.equal(isWideLayout(good), false, '自检:1200×2800 要判成窄屏');
assert.equal(navItemsOf(good).length, 2, '自检:窄屏样本仍按底栏取到 2 项');
/*
* ④ 自洽的可点 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 });
}
});