// 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 也能过。这里要求**每一项的点击目标与它的项下标配对**: * 点击处理器里赋的值,必须来自这一项自己的 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,否则 → 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\)/, '条高取同一常量'); }); /* * ③ 的**来源**那一半(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 = readFileSync(NAV_TS, 'utf8'); 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'); /* * 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值", * 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。 * 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。 */ 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/, '要指名登记册,后人查得到放行流程'); });