Files
MailUI4Agents/client/electron/test/harmony-nav.test.mjs
JianFeeeee c12744e8c3 跨端: 导航条材质选 (a) 固定档(推翻我的 (b))—— 并修掉"注释说 (a)、代码是 (b)"的自相矛盾
pi 2026-09-15 裁定:**推翻 (b),选 (a)**。我原先给 (b) 的理由不成立,他逐条驳了:

1. **WebUI 的导航条根本不读 `--bg-blur`**:`index.css:1114` 的 `.narrow-nav` 是硬编码
   `backdrop-filter: blur(18px) saturate(1.5)`。我引这条支持"两个量不同",
   而同一条也说明**它不由用户偏好驱动**。
2. **WebUI 那个滑杆的语义是"背景"**:`BackgroundPicker.tsx:183` —— `label="模糊"`、
   `hint="虚化细节,避免背景与正文抢注意力"`、`min=0 max=24`,只作用在
   `.app-backdrop{filter:blur(var(--bg-blur))}` 上。
3. WebUI 自己留了**分开的**令牌 `--bg-blur-panel`(`index.css:267`,注释写明
   "与壁纸自身的 `--bg-blur` 分开")—— 它的词汇表本身就拒绝把两者等同。
4. **★ 我给 (b) 的理由不成立**:我写"(a) 会让那个滑杆在导航条上变成死控件",
   可那个滑杆**已经**被壁纸消费了(`MainPage.ets` 壁纸层的 `.blur(bgPlan.blurPx)`)——
   它从来**不是**导航条的控件。(a) 之下它照样是活的。
5. §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。

产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换张壁纸而变厚变薄。

## 最该记的是:我的注释和代码**互相矛盾**

`MainPage.ets` 里那段注释论证的是 (a)、并明确写着"跟随是错的,pi 抓出来了",
而它下面那一行代码是 (b)。**下一个读者会照注释把代码改回去,并引我那句话当权威。**
这是这一路反复在消的形状(说的与做的不一致、而判据看不见),这次落在**注释**上 ——
而注释正是"理由要写清"那条纪律的证据源。已整段重写为真实的 (a) 版本,并把
"(a) 会让滑杆变死控件"这个**错的理由**连同它为什么错一起留在注释里。

## 做掉的东西

- `MainPage.ets`:导航条回到 `.backgroundBlurStyle(Theme.navMaterial)`;
  删掉 `NAV_MATERIAL_OF` 表与 `navMaterialFor` 的 import((a) 之下都是孤儿)。
- `model/Appearance.ts`:删 `navMaterialFor`(它存在的唯一理由就是方案 (b))。
  `blurStyleFor` 现在**没有任何调用点** —— 如实登记在它的文档注释里
  ("有测试"不等于"有人用",上一轮我刚因同形状被抓过),不假装它活着。
- **五处"整条字面表达式"断言改成语义断言**(pi §四):`cross-client-theme`、`harmony-appearance`、
  `harmony-nav`(3 处)此前都在钉
  `/\.backgroundBlurStyle\(NAV_MATERIAL_OF\[navMaterialFor\(…\)\] \?\? Theme\.navMaterial\)/`
  —— 字面换字面,正是 `CRITERIA.md` 不许的"对源码形状的匹配"。
  现在判:① 那一处的材质**来自 `Theme.navMaterial` 这个系统令牌**;
  ② **不许跟随** `bg_blur`(NavBar 真代码里不许出现 `blurPx`/`blurStyleFor`);
  ③ 令牌是 `BlurStyle` 枚举值、不是 `NONE`、且**成员名真实存在于 SDK 枚举**。
- "可达性"那条判据**随契约作废**(它守的是方案 (b)):换成判 (a) 的契约。
  **判据随契约走,不随实现走。**
- `harmony-appearance`:原先判"页面里那张表的键必须是 SDK 成员"。表删了,
  改成**枚举 `blurStyleFor` 的整个值域**(0..40 + 界外 + NaN),逐个核 SDK 成员 ——
  比原来只核表里那三行**更严**。

## 判据自己先错了一次,记下来

新判据第一版**没剥注释**就断言"NavBar 里不许出现 `blurPx`",当场红了 ——
而红的原因不是代码错,是 `NavBar` 的**文档注释**里正好写着
"我一度把档位接过用户偏好(`navMaterialFor(this.bgPlan.blurPx)`)"这句历史说明。
**注释说明禁令 ≠ 违反禁令**;不剥注释,这条判据就会变成"逼人删掉解释",
恰好与本仓库"理由要写清"的纪律相反。改成 `stripComments(bar)` 后再判,并加了一条
"注释里确实留着那处说明"的自检前提。

## 变异体:45 个全部被抓(含 3 个新判据的专属变异)

方案 (b) 落地时配的 13 个变异体**整体作废**(它们锚的代码被删了),标注 `retired`
并写清理由 —— 不是"没跑成"。另有 5 个锚点漂移(我在注释里逐字引用了被锚的那句代码,
污染了通用正则)的**重锚**到真代码;注释里那句逐字引用也一并去掉了
(**注释里逐字抄代码**正是让"按字面锚定"的变异体反复失效的根因)。
新增 3 个针对 (a) 契约的变异体(绕过令牌 / 又跟随 `bg_blur` / 令牌变 `NONE`),全部被抓。

## 未验

- **真机观感仍未验**:三档材质在真机上能不能看出差别、滑杆手感、管理页布局,
  只有真机能答。本机模拟器已起(`hdc list targets` 有目标),但
  `run-all.mjs` 的**设备闸已经到期**(8 个静态判据的前提成立)⇒ 套件现在会挡在
  那道闸上、不打 `RESULT`。**这不是本笔引入的**(前提是环境变了),已单独报给 pi。
- Go 侧 `debt_registry_test.go` 仍未在本机跑(无 Go 模块缓存);pi 已在别处跑过,绿。
2026-09-15 11:58:19 +08:00

428 lines
29 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` 挂载,两个平级项仍是 通信 / 联系人。
// 所以判据分四类:
// ① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"存在 onClick");
// ② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人,不是挂了但没变);
// ③ 命中区:≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜;
// ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质),
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
import { code, prose, stripComments } from './lib/read.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, '要能取到导航项的正文');
/*
* 点击处理器是 `() => { this.currentIndex = normalizeNavIndex(index); }`。
* 取赋值语句并断言它**同时**用到 `index` 与归一化函数 ——
* 只看"出现过 normalizeNavIndex"不够(可以写 `normalizeNavIndex(0)` 骗过去),
* 所以要求括号里的实参就是 `index`。
*/
const assigns = [...item.matchAll(/this\.currentIndex\s*=\s*([^;]+);/g)].map(m => m[1].trim());
assert.equal(assigns.length, 1, `导航项里应当恰好有一次点击赋值(实际 ${assigns.length} 次)`);
assert.match(assigns[0], /normalizeNavIndex\(\s*index\s*\)/,
`点击要落到本项自己的 index 并过归一化,现在赋的是:${assigns[0]}`);
// 处理器必须挂在**这一项**上(不是别的成员上)
assert.match(item, /\.onClick\(\(\)\s*=>\s*\{[\s\S]{0,120}?this\.currentIndex/, '点击处理器要挂在本项上');
// 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 \}\)/, 'index 0 要挂通信页');
assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive \}\)/, 'index 2 要挂联系人页');
/*
* 联系人那一支必须**显式绑在 index 2**。注意条件在 `else if (...)` 里,
* 不在 `else` 的**花括号正文**里 —— braceBody 只取正文,所以这条要看 root 原文。
* 写成"否则就挂联系人"的后果:以后再加一项,新索引会被它静默接住(点了显示联系人)。
*/
assert.match(root, /else if \(this\.currentIndex === 2\)/, '联系人必须显式绑在 index 2,不许用 else 兜底');
/*
* 日历(index 1):**常驻挂载 + visible 由索引驱动 + Visibility 开关**,三样缺一不可。
* · `visible: this.currentIndex === 1` 是 `@Watch` 的触发源 —— 不带它,today 就不会在
* pane 变可见时重算(DEBTS 的 calendar-today-recompute);
* · `visibility(...=== 1 ? Visible : None)` 是显示开关;常驻 + 不隐藏 = 三个 pane 叠在一起。
*/
assert.match(root, /CalendarPage\(\{ bgActive: this\.bgActive, visible: this\.currentIndex === 1 \}\)/,
'index 1 要挂日历页,并把"是否可见"传下去(它是 today 重算的触发源)');
assert.match(root, /\.visibility\(this\.currentIndex === 1 \? Visibility\.Visible : Visibility\.None\)/,
'常驻挂载就要用 visibility 控制显示');
assert.ok(!/if \(this\.currentIndex === 1\)/.test(root),
'日历不该写成 if/else 的一支:它是常驻 pane(卸载重挂会丢掉"在看哪个月",且 today 只能靠挂载重算)');
/*
* 分派必须**穷尽** NAV_ITEMS。2026-09-14 日历入口上架时这条**如约变红**
* (当时是 `length === 2` + 两支 if/else)—— 加项必须同时加分派,否则第 3 项点了没内容。
* 现在的形状与"三项"绑定:通信(0) / 日历(1) / 联系人(2),日历那一支**常驻挂载**。
*/
assert.equal(N.NAV_ITEMS.length, 3,
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,与分派(通信/日历/联系人)不匹配 —— 加了项必须同时加分派`);
// 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到
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 的样子)。
*/
assert.match(bar, /\.margin\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/,
'四周要留白(左右 + 离底),贴边就不是悬浮');
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})`);
assert.match(main, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/, '内容底部要让出这段高度');
/*
* 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)。
*
* 这里原来是 `main.slice(定位).slice(0, 400)` 的**窗口式**断言 —— 2026-09-14 加日历
* 那一支时它红了:内容层里多了一个 pane,`.padding(...)` 落到了 400 字符之外。
* 判的是"同一件事",红的原因却只是"变长了",正是本仓库反复记的窗口式判据。
* 改成**按花括号配对取正文**:内容层那个 Column 的 `{...}` 里必须出现让位。
*/
const contentOpen = main.indexOf('Column() {\n if (this.currentIndex === 0)');
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, '内容层的花括号要闭合');
const chain = main.slice(end, main.indexOf('this.NavBar()', end));
assert.match(chain, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/,
'让位要加在挂载内容的那层上(链在那个 Column 的 } 之后)');
});
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 取代,不该留在文件里');
// 平级项:通信/日历/联系人(日历是 P6 第 1 步做完后上架的,顺序与 WebUI 一致)
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)');
assert.deepEqual(N.NAV_ITEMS.map(i => N.navKeyAt(N.NAV_ITEMS.indexOf(i))), N.NAV_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, '第三项(联系人)也必须归一到自己');
assert.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效');
});
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);
const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
const hits = itemCode.match(active) || [];
assert.equal(hits.length, 2,
`图标与文字都要过同一个选中三元式(现在 ${hits.length} 处)—— 只给文字上色就是"选中了一半"`);
// 图标(item.icon)与文字(item.label)各自紧跟着那个三元式
const icon = itemCode.slice(itemCode.indexOf('Text(item.icon)'), itemCode.indexOf('Text(item.label)'));
assert.match(icon, /fontColor\(this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg\)/,
'图标的 fontColor 必须跟着选中态走');
// 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素)
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('★ ⑤ 变异自检:图标不上色(改造前的写法)必须被判红', () => {
const oldIconLine = 'Text(item.icon).fontSize(20)';
const itemCode = `Column() {\n ${oldIconLine}\n Text(item.label)\n .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)\n }`;
const hits = itemCode.match(/this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g) || [];
assert.equal(hits.length, 1, '旧写法只有 label 上色 ⇒ ⑤ 的"必须两处"会命中它');
/*
* 判定窗口必须与 ⑤ **完全同一条规则**:从 `Text(item.icon)` 切到下一个 `Text(`。
* 我第一版在这里写的是 `Text\(item\.icon\)[\s\S]{0,80}fontColor` —— 那个窗口
* 把**下一行 label 的** fontColor 也圈进来了,于是"旧写法没有图标上色"这条
* 自检自己绿了(正是本仓库反复记的"窗口式判据")。判据被它咬了一次。
*/
const icon = itemCode.slice(itemCode.indexOf('Text(item.icon)'), itemCode.indexOf('Text(', itemCode.indexOf('Text(item.icon)') + 1));
assert.ok(!/fontColor/.test(icon), `旧写法里图标没有 fontColor ⇒ ⑤ 的图标那一半会命中它(切片=${JSON.stringify(icon)})`);
});
/**
* ★ 导航条材质:**固定系统档**,且**不随 `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('、')})`);
});