// 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(/(? m[1].trim()); assert.equal(assigns.length, 1, `导航项里应当恰好一次 currentIndex 赋值(实际 ${assigns.length} 次)—— 外壳入口(route 非空)走路由,不赋 currentIndex`); assert.match(assigns[0], /normalizeNavIndex\(\s*index\s*\)/, `点击要落到本项自己的 index 并过归一化,现在赋的是:${assigns[0]}`); // 外壳入口(route 非空)走路由、内容窗格才赋 currentIndex —— 两者都走在本项的 onClick 里 assert.match(item, /item\.route.*pushUrl|pushUrl.*item\.route|if \(item\.route[\s\S]{0,80}pushUrl/, '外壳入口(route 非空)要路由到 @Entry 页(与 WideSidebar 的设置按钮同一行为)'); // 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_CONTENT_COUNT` = 3:通信/日历/联系人),但 * **不**把外壳入口(第 4 项「我的」)算进去 —— 它是 `@Entry` 页,点击走路由, * 不在 currentIndex 分派里挂内容。2026-09-14 日历入口上架时这条**如约变红** * (当时是 `length === 2` + 两支 if/else)—— 加**窗格**必须同时加分派。 * 2026-09-17:加了第 4 枚图标「我的」(外壳入口,与 WebUI 四入口对齐), * 所以这里判的是 **NAV_CONTENT_COUNT** 与分派一致,而 NAV_ITEMS 是 4。 */ assert.equal(N.NAV_CONTENT_COUNT, 3, `内容窗格应为 3(通信/日历/联系人),实际 ${N.NAV_CONTENT_COUNT}`); assert.equal(N.NAV_ITEMS.length, N.NAV_CONTENT_COUNT + 1, `NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项;应为 3 个内容窗格 + 1 个外壳入口(我的)`); // 第 4 项必须是外壳入口(带 route),否则它会被 currentIndex 分派静默漏掉 const shell = N.NAV_ITEMS[N.NAV_CONTENT_COUNT]; assert.equal(shell.key, 'me', '第 4 项应是「我的」'); assert.equal(shell.route, N.NAV_SETTINGS_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 的样子)。 */ 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})`); /* * ★ 2026-09-15:padding 现在是**条件式**(宽屏变 0、窄屏取 NAV_CONTENT_RESERVE)。 * 宽屏有侧栏图标轨、没有底部条,所以内容不再需要让位 —— 但窄屏那条老规矩仍然在。 * 断言匹配两种形态:旧的直接取值、新的三元式(宽屏取 0)。 */ const padOk = /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main) || /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main); assert.ok(padOk, '内容底部要让出这段高度(窄屏取 NAV_CONTENT_RESERVE;宽屏可变 0)'); /* * 让位要生效在**内容**上,不是条上(条自己 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, '内容层的花括号要闭合'); const chain = main.slice(end, main.indexOf('this.NavBar()', end)); assert.match(chain, /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/, '让位要加在挂载内容的那层上(链在那个 Column 的 } 之后),宽屏变 0、窄屏取 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, '第三项(联系人)也必须归一到自己'); // 外壳入口(index 3)不归一到自己 —— 它不进 currentIndex 分派,normalize 越界即回 0 assert.equal(N.normalizeNavIndex(3), 0, '外壳入口(我的)不该被当作内容窗格下标'); 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); /* * 2026-09-16 用户:「底部导航栏不允许有文字」⇒ 导航项是**纯图标**, * 只有 1 处选中三元式(AmIcon 的 iconColor)。文字那一半被去掉。 */ const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g; const hits = itemCode.match(active) || []; assert.equal(hits.length, 1, `纯图标导航只应有 1 处选中三元式(现在 ${hits.length} 处)— 若有 2 处是文字还留着`); // 图标上色必须存在(AmIcon 的 iconColor 跟着选中态走) const icon = itemCode.slice(itemCode.indexOf('AmIcon({'), itemCode.indexOf('AmIcon({') + 160); assert.match(icon, /iconColor:\s*this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/, '图标的 iconColor 必须跟着选中态走'); // 图标不能太小(用户:22 太小) assert.match(icon, /iconSize: 28/, '底部导航图标要 28vp(用户嫌 22 太小)'); // 无文字:导航项里不该有 label 的 Text assert.ok(!/Text\(item\.label\)/.test(itemCode), '底部导航是纯图标:不允许有文字 label(2026-09-16 用户明确要求)'); // 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素) 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 项退回带文字或 22 尺寸或图标不上色必须被判红', () => { // 变异 1:带文字(Text(item.label))⇒ ⑤ 的"无文字"会命中它 const withLabel = `Column() {\n AmIcon({ iconName: item.iconKey, iconSize: 28 })\n Text(item.label)\n .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)\n }`; assert.ok(/Text\(item\.label\)/.test(withLabel), '变异 1 自检失败:样本里必须有 label 文字'); // 变异 2:图标 22(太小)⇒ ⑤ 的"iconSize: 28"会命中它 const smallIcon = `Column() {\n AmIcon({ iconName: item.iconKey, iconSize: 22, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg })\n }`; assert.ok(/iconSize: 22/.test(smallIcon) && !/iconSize: 28/.test(smallIcon), '变异 2 自检失败:样本里图标必须是 22 且不是 28'); // 变异 3:图标不上色(没有 iconColor)⇒ ⑤ 的"iconColor 跟着选中态"会命中它 const noColor = `Column() {\n AmIcon({ iconName: item.iconKey, iconSize: 28 })\n }`; assert.ok(!/iconColor/.test(noColor), '变异 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'); }); /* * ★ 悬浮加号必须待在 Navigation 的**列表侧**,不能当 Navigation 的兄弟。 * * 症状(2026-09-17 模拟器截图硬证):窄屏 Stack 模式下点开邮件详情,右下角 * **同时**出现两个圆形按钮 —— 详情自己的「回复」(蓝色胶囊)与外壳的 * compose 加号,后者悬空压在详情上。 * * 根因:加号原先是 `Navigation` 的**兄弟**,平级放在外层 Stack 里。 * 详情画在 `Navigation` 内部,而兄弟节点画在 `Navigation` 之上 ⇒ 永远压住详情。 * * WebUI 的对应物是 `.comm-pane`(`App.tsx`):`{listBody}` —— * 加号在**列表窗格内部**,窄屏滑上来的详情层把列表整块(含加号)盖住, * 详情自己的回复按钮才露得出来。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 加号 —— 那等于又放回了兄弟位置'); });