Files
MailUI4Agents/client/electron/test/harmony-nav.test.mjs
JianFeeeee 25ac19b343 跨端: P5 悬浮玻璃导航取代系统 TabBar —— 自绘浮动条 + 命中区 ≥44vp + 内容让位
(subject 原为「跨端(P5): …」—— 被自己的 commit-hygiene 判据判红:约定是 subject 里带
 `跨端:`,而 `跨端(P5):` 让字面 grep 找不到。判据是对的,改提交不改成判据。)
2026-09-14 15:34:32 +08:00

203 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { readFileSync } from 'node:fs';
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 => readFileSync(join(HARMONY_ETS, p), 'utf8');
/** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */
const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
/** 取某个 `@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 也能过。这里要求**每一项的点击目标与它的项下标配对**
* 点击处理器里赋的值,必须来自这一项自己的 indexForEach 的第二个参数),
* 并且过 `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否则 → ContactsTab
* 且两边都要收到 `bgActive`(背景开着时让出页面底,否则壁纸被内容盖住)。
*/
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 1 要挂联系人页');
/*
* 分派必须只有这两支,且必须**穷尽** NAV_ITEMS —— 现在只有两项,用 if/else 就够了;
* 一旦 NAV_ITEMS 变长,这条会红并提醒改成按清单挂载(否则第 3 项点了没内容)。
*/
assert.equal(N.NAV_ITEMS.length, 2,
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,而内容分派只有 if/else 两支 —— 加了项必须同时加分派`);
// 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到
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\)/, '条高取同一常量');
});
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');
/*
* 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值"
* 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。
* 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。
*/
assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(code(bar)),
`条上不许出现手写色值(深浅两套由系统给),实际:${(code(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 不解决遮挡)
const contentPad = main.slice(main.indexOf('Column() {\n if (this.currentIndex === 0)'));
assert.match(contentPad.slice(0, 400), /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/,
'让位要加在挂载内容的那层上');
});
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 = code(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(code(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
// 平级项仍是两项且顺序不变(信息架构这一期不动)
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.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效');
});
test('★ 玻璃只在两处、且这一处是"背后有可变内容"GLASS 登记的放行条件)', () => {
/*
* pi 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次;
* ②模糊该出现在"背后是可变内容"的层。悬浮条背后是**会滚动的内容** ✓,
* 而壁纸层背后什么都没有 → 壁纸层不许有材质/模糊。
* 这里钉的是这一期的分工,跨端的登记册在 `cross-client-theme.test.mjs`GLASS_REGISTRY
*/
const wallpaper = builderBody(main, 'WallpaperLayer() {');
assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)');
assert.ok(!/blur\(/i.test(code(wallpaper)), '壁纸层不许有任何模糊调用');
const bar = builderBody(main, 'NavBar() {');
assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)');
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里)
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/, '要指名登记册,后人查得到放行流程');
});