// 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(/(? 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 —— 参照物变了,要重核两端`); /* * ★★ 2026-09-20 改:不再逐字要求鸿蒙 == WebUI 的那个数,改为**有依据的区间**。 * * 用户裁定(原话:「点击回复按键与新建邮件部分的动画与 webui 不一致,动画不符合 * 鸿蒙视觉要求」),三个选项里选了 `compromise_200` = **200ms**。 * 两端各自的依据都成立: * · WebUI rise-in 实测 150ms —— 它是 Web,150ms 是 web 常规档; * · 鸿蒙官方「元素淡入/位移进入」建议 **200-300ms** —— 150ms 比下界还低 25%, * 在 60/120Hz 的移动端会读成"一瞬间跳出来",这正是用户说的"不符合鸿蒙视觉要求"。 * ⇒ 判据不再钉死某一个数,而是钉**区间**:既拦住退回 150(低于鸿蒙下界), * 也拦住乱写(>300 会显得拖沓)。参照物仍从 WebUI 源码实时读出,不写第二份。 */ const HARMONY_ENTER_MIN_MS = 200; const HARMONY_ENTER_MAX_MS = 300; assert.equal(riseMs, '150', `WebUI rise-in 实测是 150ms,读到 ${riseMs}ms —— 参照物变了,要重核两端`); const riseToken = /durRise:\s*number\s*=\s*(\d+)\b/.exec(theme); assert.ok(riseToken, '★ 要有 `durRise` 这个入场时长令牌(`paneRiseIn()` 的时长来源)'); const riseVal = Number(riseToken[1]); assert.ok(riseVal >= HARMONY_ENTER_MIN_MS && riseVal <= HARMONY_ENTER_MAX_MS, `★ 入场时长要在鸿蒙官方建议区间 ${HARMONY_ENTER_MIN_MS}-${HARMONY_ENTER_MAX_MS}ms 内,实际 ${riseVal}ms。\n` + ` 写 ${riseMs}(照抄 WebUI)低于鸿蒙下界 —— 用户 2026-09-20 原话「动画不符合鸿蒙视觉要求」。\n` + ' 写 180(=--dur-base)则是拿"壁纸淡入的时长"当"面板入场的时长",那是两块不同的东西。'); if (riseVal !== Number(riseMs)) { /* * 偏离 WebUI 数值时必须写明理由。用 `prose()` 读**原文**(含注释)—— * `theme` 是 `code()` 读的(注释已被剥掉),拿它查注释必然失配。 */ const themeRaw = prose(join(HARMONY_ETS, 'common', 'Theme.ets')); assert.match(themeRaw, /durRise[\s\S]{0,900}?(WebUI|rise-in)/, '★ 偏离 WebUI 数值时,`durRise` 附近必须有一段注释写明为什么偏离' + '(否则下一个人只会当它是随手写的数)。'); } // 曲线:必须与 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]; /* * ★★ 允许外面再包一层 `Motion.dur(...)`(dsh 2026-09-20,pi 报的假红)。 * * 原来这里是 `/duration:\s*Theme\.durRise/` —— 要求 `duration:` 与 `Theme.durRise` * **紧邻**。而 `Theme.paneRiseIn()` 现在写成 * .animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) * `Motion.dur(x) = reduced() ? 0 : x`(无障碍:系统开了"减少动效"就折成 0ms)。 * ⇒ **语义上 `durRise` 仍被引用**(只是多了一层),而正则失配 ⇒ **假红**。 * 实测(隔离 worktree,同刻对照):干净 HEAD `19 tests / 18 pass / 1 fail`(这条 **not ok 8**), * 加上那份未提交的无障碍改动后 `17 pass / 2 fail` —— 多出来的正是这条。 * * ★ 放宽的**只是"中间能不能多一层卷绕"**,判据的**区分力一点没动**: * · 仍要求字面量 `Theme.durRise` 出现(`durBase` 依旧不匹配); * · 下面 `!/Theme\.durBase/` 那条**逐字仍在**,且它扫的是**整个函数体** * ⇒ `Motion.dur(Theme.durBase)` 这种"包一层来蒙混"**照样红**。 * (即:放宽的是**包装**,不是**令牌**。混起来的写法不会因为这次放宽而漏。) */ assert.match(body, /duration:\s*(?:Motion\.dur\()?Theme\.durRise/, '★ `paneRiseIn` 里入场时长必须引用 `Theme.durRise`(= WebUI rise-in 的 150ms)。' + '写成 `Theme.durBase` 是拿“壁纸淡入的时长”当“面板入场的时长” —— 正是 2026-09-18 修的那个 bug。' + '(外面允许包一层 `Motion.dur(...)`:那是无障碍动效开关,包了也仍是在引用 `durRise`。)'); 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 构造器存在,且**入场/出场对称**。 * * ★★ 2026-09-20 反转(原断言要求 `asymmetric`,现要求**不得** asymmetric)。 * * 用户原话:「点击回复按键与新建邮件部分的动画与 webui 不一致」。 * 而 WebUI 那边**本来就是对称**的 —— `index.css:1326/1331/1336` 三条 * `.rise-in`/`.pane-rise` 全部是 `animation: rise-in 150ms ... both`, * `both` = 进出都走同一条关键帧。所以"不对称"这件事是**鸿蒙单方面的发明**, * 它正是"与 webui 不一致"的来源之一。 * * 原先写 asymmetric 的理由(注释里记着)是「同长会让新旧两层半透明叠着, * 看起来像闪一下」。那个担心对**换窗格**成立(两层同时存在), * 但对**回复框/转发条**不成立 —— 它出现时底下是一直存在的正文,不是"正在消失的另一半"。 * 用同一个过渡同时服务两种场景,是当初把两件事混了。 * ⇒ 按 WebUI 对齐:对称。(换窗格若将来发现"闪",应该用各自更合适的过渡去解。) */ assert.match(theme, /paneRiseIn\(\):\s*TransitionEffect/, '要有 paneRiseIn() 这个过渡构造器'); assert.ok(!/TransitionEffect\.asymmetric\(/.test(body), '★ `paneRiseIn` 不得用 `asymmetric` —— WebUI 的 `.rise-in`/`.pane-rise` 是 `both`(进出同一条),\n' + ' 不对称是鸿蒙单方面的发明,正是用户说的"与 webui 不一致"。\n' + ' (若某处**换窗格**确实需要出场更快,给那一处单独的过渡,不要改这个通用构造器。)'); // ② 关键: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('★ 出现/消失的那类元素真的挂了过渡(`if` 包的弹层不能硬弹)', () => { /* * 用户 2026-09-19:「元素的出现消失动画呢?」 * * 这条补的是上一条判据**判不到的那一类**:上一条管"切窗格"(整棵子树换掉), * 而**弹层/下拉/就地展开**是另一回事 —— 它们挂在 `if (cond)` 上, * 条件一变就整块出现/消失。这类位置最容易漏,因为: * * · 代码上"看着像是挂了动画"(外面的页面有 transition), * 但那个 transition 作用在**别的地方**; * · 漏掉时没有任何静态信号 —— 页面照样能跑、判据全绿,只是硬弹。 * * 实测漏掉的三处(本次修的): * · `MainPage` 账号选择器下拉(点一下,列表凭空冒出来) * · `AdminUsersPage` 新建用户表单(就地展开) * · `SettingsPage` 新增账号弹层(底部弹上来) * * ★ 判据的形状:**枚举所有"会被条件挂载的浮层/展开块",要求每个都有 transition**。 * 不是数数("至少 N 处"),因为数数挡不住"新增第四处又漏了"。 * 名单显式列出,加了新的浮层就要一起改 —— 与 GLASS_REGISTRY 同一套纪律。 */ const targets = [ { file: 'pages/MainPage.ets', needle: 'if (this.showAccountPicker && this.accountList.length > 1) {', why: '账号选择器下拉' }, { file: 'pages/SettingsPage.ets', needle: 'if (this.showAddDialog) {', why: '新增账号弹层' }, { file: 'pages/AdminUsersPage.ets', needle: 'if (this.showCreate) {', why: '新建用户表单' }, { file: 'pages/MailDetailPage.ets', needle: 'if (this.showReplyBox) {', why: '回复弹层' }, { file: 'pages/MailDetailPage.ets', needle: 'if (this.showForwardBox) {', why: '转发弹层' } ]; const missing = []; for (const t of targets) { const src = code(join(HARMONY_ETS, t.file)); const at = src.indexOf(t.needle); assert.ok(at >= 0, `${t.file} 里找不到「${t.why}」的挂载条件(${t.needle})—— 改名字要一起改判据`); /* * ★★ 2026-09-21 改:不再用"往后扫固定 N 字符",改成**按花括号配对找块尾**。 * * 原来这里写的是 `src.slice(at, at + 4000)`。 * 那个 4000 是个**拍脑袋的数字**,而且它会随时间失效: * 我在转发弹层里加了地址补全(约 2000 字符的候选列表)后, * `.transition(Theme.paneRiseIn())` 被推到距挂载点 **6107** 字符处 * ⇒ 判据报「转发弹层没有挂过渡」——而它**明明是挂着的**。 * * 这种假红比漏报更危险的副作用是:它会诱人去**加大那个数字**, * 而正确的修法是把它换成"这块到哪里结束"——即配对。 * 数字每改一次就多一次"下次又不够"的机会。 */ const window = braceBody(src, t.needle); assert.match(window, /\.transition\(Theme\.(paneRiseIn|menuIn)\(\)\)/, `${t.file} 的「${t.why}」没有挂过渡 —— 它会硬弹出来。` + '就地展开用 `paneRiseIn()`,浮层/下拉用 `menuIn()`(取值出处见 Theme 的注释)'); if (!/\.transition\(Theme\.(paneRiseIn|menuIn)\(\)\)/.test(window)) missing.push(t.why); } assert.deepEqual(missing, [], `这些位置会硬弹:${missing.join('、')}`); }); test('★ 顶部页签条与底部导航条同一族(都是悬浮玻璃)—— 一块实心白就是"割裂"', () => { /* * 用户 2026-09-20:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」 * * 那条页签条当时是**整页唯一一块实心白**:上一轮把列表容器、卡片、 * 底部导航条都玻璃化了,唯独它还写着 `backgroundColor(Theme.surface)`(系统卡片色=实体) * ⇒ 壁纸从四周透出来,只有它在顶上白着一横条。 * * ★ 这条判据要抓的形状:**"同一页里少数几处忘了跟着改"** —— * 逐处修的时候最容易漏的就是"不是卡片、也不是容器"的条状 chrome。 * 所以判据盯的是"页签条有没有和导航条**用同一族**的三种属性", * 而不是"有没有某个字符串": * ① 圆角不是自己拍的值(必须引 `TAB_BAR_RADIUS`,它 = `NAV_BAR_RADIUS`); * ② 左右留白引 `TAB_BAR_SIDE`(它 = `NAV_BAR_SIDE`)⇒ 两条轴线对齐; * ③ 材质走 `Theme.navMaterial`(与底部条同一档次),不是 `Theme.surface`。 */ const page = stripComments(main); const at = page.indexOf('CommTabBar()'); assert.ok(at > 0, '通信页要有页签条(收件箱/发件箱/授权)'); const body = page.slice(at, at + 3000); assert.ok(!/\.backgroundColor\(Theme\.surface\)/.test(body), '页签条不许铺实体面(Theme.surface)—— 那是壁纸模式下唯一一块实心白,"割裂"就是这么来的'); assert.match(body, /\.backgroundBlurStyle\(this\.bgActive \? Theme\.navMaterial : BlurStyle\.NONE\)/, '页签条要吃系统材质(与底部导航条同档),否则它和玻璃卡片拼在一起不是一套东西'); /* * ══════════════════════════════════════════════════════════════════════ * ★★ 2026-09-21 **改判**:页签条不再自己成条,而是与窗格**齐平** * ══════════════════════════════════════════════════════════════════════ * * 用户当天连指三次,最后一句是裁定: * 「你看看顶栏右边那个圆角,你不觉得奇怪吗?」 * 「你右边改成没圆角不就行了,还 better,你好好看看,**你又在内部套了一个胶囊**」 * 「鸿蒙布局还略有不同的,你直接改成右边没圆角就行了」 * * ── 我原来错在哪(用 CDP 读 WebUI 的实测几何才发现)── * * .comm-pane x=80 w=320 radius=14px overflow=hidden ← 窗格,**裁圆的是它** * tabstrip x=80 w=320 radius=0px ← 页签条,自己无圆角 * * 两者**横向完全齐平**。而我们原来是"自己缩进 16vp + 自己带满圆角"⇒ * 页签条成了一个**嵌在窗格里的胶囊**,上下两条弧各画一遍,中间留出一弯月牙。 * 判据当时钉的 `TAB_BAR_RADIUS` / `TAB_BAR_SIDE` / `TAB_BAR_TOP` * **正是那个被否掉的形状**——它记录的是旧实现,不是不变式。 * * ── 设备实测(模拟器窄屏 1008px,`uitest dumpLayout`)── * * 页签条 Row bounds=[28,140][980,267] * 所在窗格 bounds=[28,140][980,1957] * ⇒ 左右边缘**逐像素相同**(28 / 980),与 WebUI 一致。 * * ── 现在钉的三条(换成不变式,而不是"等于某个常量")── * * ① 与窗格齐平:`width('100%')`,**不许**再有左右内缩 * (`margin`/`calc(100% - N)` 那两种写法都不行 —— 那会把胶囊加回来); * ② **右上角无圆角**(用户明确要求):只给左上角,与窗格左上角那道弧**重合**成一道; * ③ 依旧要有玻璃(与底部导航条同档),不许退回实体面。 * * ★ `TAB_BAR_RADIUS` / `TAB_BAR_SIDE` / `TAB_BAR_TOP` 已随之**删除** * (`model/NavItems.ts`)—— 留着它们就是**孤儿常量**: * 下一个人会照着名字把胶囊拼回来。`TAB_BAR_HEIGHT` 保留(仍在用: * 它决定条高,而条高是真实几何)。 */ assert.match(body, /\.width\('100%'\)/, '页签条要与窗格齐平(`width(\'100%\')`)—— WebUI 实测 .comm-pane 与 tabstrip 横向逐像素相同'); assert.ok(!/TAB_BAR_SIDE/.test(body), '页签条不得再有左右内缩(TAB_BAR_SIDE 那个胶囊形状已被用户否掉)'); assert.match(body, /\.borderRadius\(\{\s*topLeft:\s*Theme\.glassRadius,\s*topRight:\s*0\s*\}\)/, '页签条只许有**左上**圆角(右上 0)—— 用户:「你直接改成右边没圆角就行了」;' + '右上那道弧会与窗格右上角的弧叠成两道'); assert.ok(!/\.borderRadius\(TAB_BAR_RADIUS\)/.test(body), '页签条不得用 TAB_BAR_RADIUS(那是"胶囊"形状,已被否)'); /* * 自检:把页签条改回实心面,上面第一条必须判红。 * (不写自检的话,这条判据可能因为 `body` 切片取错位置而**恒绿** —— * 本仓撞过"判据自己在骗自己",所以凡按位置切片的都验一次。) */ const broken = 'CommTabBar() {\n Row() {}\n .width(\'100%\')\n .backgroundColor(Theme.surface)\n}'; const bAt = broken.indexOf('CommTabBar()'); assert.ok(/\.backgroundColor\(Theme\.surface\)/.test(broken.slice(bAt, bAt + 3000)), '自检:实心面这个写法必须能被命中(否则上面的断言是假的)'); }); 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` * 里面 `` 之后就是 `{short}` * —— 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}` —— * 加号在**列表窗格内部**,窄屏滑上来的详情层把列表整块(含加号)盖住, * 详情自己的回复按钮才露得出来。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` 有 `{short}` * —— 侧栏**一直有**文字标签(通信/日历/联系)。 * 我据此把鸿蒙侧栏做成了纯图标,还写了判据把它锁死 —— 判据锁住的是我的错误。 * * 现在按两侧的**共同事实**判:侧栏项 = 图标(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 有 {short});` + `实际文字:${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 有 {short})'); } // 窄屏样本不能被误判成宽屏(否则上面那套形状条件会去滤底栏项) 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 }); } }); test('★ 登录/退出必须用同一个原语(`replaceUrl`)—— 只改对一半会以"怪现象"回来', () => { /* * ★★ 2026-09-19 修的真 bug(用户报「在主页返回为什么会直接回到登陆页」): * * `LoginPage` 用 **`pushUrl`** 去主界面 ⇒ 路由栈是 `[LoginPage, MainPage]` * ⇒ 在主页按返回,弹掉 MainPage,**回到登录页**。 * * 而 `api/Logout.ets` 那一半**早就写对了**,注释也写了理由: * 「④ replaceUrl 而不是 pushUrl:退出后不该还能"返回"到已登出的页」 * * 两件事是同一条不变式的两端: * · 退出 ⇒ 不该能返回到已登出的页 ⇒ `replaceUrl` ✓(早就对) * · 登录 ⇒ 不该能返回到已登录的登录页 ⇒ `replaceUrl`(原来错着) * * ⇒ 这条判据的形状是**成对检查**,不是逐处检查 —— * 因为它要防的不是"某一处写错",而是"**只改对了一半**"。 * 逐处判据在那个形状下必然漏(另一半当时全绿)。 * * ★ 为什么不判"必须有 replaceUrl"这么简单:登录要走快速路径(已有账号)、 * doLogin、tryRestore 三条,逐个写死数量会在加第四条时假红。 * 所以判"**不许有** pushUrl 到 MainPage" —— 那是唯一会破坏不变式的写法。 */ const login = code(join(HARMONY_ETS, 'pages/LoginPage.ets')); const logout = code(join(HARMONY_ETS, 'api/Logout.ets')); /* ① 登录侧:不许 pushUrl 到主界面 */ const badLogin = [...login.matchAll(/pushUrl\(\{[^}]*MainPage[^}]*\}/g)].map((m) => m[0]); assert.deepEqual(badLogin, [], '★ `LoginPage` 里不许用 `pushUrl` 去 `MainPage` —— 那会把主界面**压在登录页之上**,\n' + ' 于是在主页按返回会**回到登录页**(用户 2026-09-19 报的就是这个)。\n' + ' 登录是"到达"不是"进入下一层",要用 `replaceUrl`。\n' + ' 违规处:\n ' + badLogin.join('\n ')); /* ② 登录侧:至少要有一次 replaceUrl 到主界面(否则上面那条可能被"全删掉"满足) */ assert.match(login, /replaceUrl\(\{[^}]*MainPage[^}]*\}/, '`LoginPage` 要有 `replaceUrl` 去 `MainPage`(上一条只是"不许 pushUrl",' + '光删不写也能满足它 —— 这条补上"必须真的用对的那个")'); /* ③ 退出侧:同样的口径(早就对了,钉住别退化) */ assert.match(logout, /replaceUrl\(\{[^}]*LoginPage[^}]*\}/, '`api/Logout.ets` 要用 `replaceUrl` 回登录页 —— 退出后不该还能"返回"到已登出的页'); assert.ok(!/pushUrl/.test(logout), '`Logout` 里不该出现 `pushUrl`:与 LoginPage 是**同一不变式的两端**,' + '两边都要 replaceUrl(一边对一边错就是本条要防的形状)'); /* * ★★ 自检:**造一个违规样本喂给①那条正则**,确认它真的抓得到。 * * 为什么必须做这一步(本仓反复记录过):形状判据最常见的失效方式是 * 正则写歪了 ⇒ **恒绿**。而恒绿的判据看起来和"代码是对的"一模一样。 * 这里主动构造 `pushUrl({ url: 'pages/MainPage' })` —— * 正是修复前真实存在的那一行 —— 要求正则命中它。 */ const violation = "this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' });"; const re = /pushUrl\(\{[^}]*MainPage[^}]*\}/g; assert.ok(re.test(violation), '★ 自检失败:①的正则抓不到修复前那行真实代码 —— 说明正则是歪的,' + '那样本判据**恒绿**(看起来和"代码正确"一样)。样本:' + violation); /* 反过来:正确写法不该被误报 */ const ok = "this.getUIContext().getRouter().replaceUrl({ url: 'pages/MainPage' });"; assert.ok(!/pushUrl\(\{[^}]*MainPage[^}]*\}/.test(ok), '★ 自检失败:正确写法(replaceUrl)被误判为违规'); });