Files
MailUI4Agents/client/electron/test/harmony-widescreen.test.mjs
JianFeeeee d9bb4f766b 跨端: 三条判据转绿(登记新令牌 + 把一条断言错实现的判据改成断言真不变式)
上一步(写邮件右栏)之后整套跑红 3 条,逐个查清:

══ ① cross-client-theme —— 判据是对的,我漏登记

新加的 `accentEdge` / `accentEdgeDark` 是**手写色**,而这个仓有一条硬规矩:
`Theme.ets` 里每个 `static readonly X: string = '#……'` 都必须在
`SELF_OWNED_COLORS` 名单里 + 在文件内的「手写色登记表」注释里写一行理由。
(这条规矩本身就是为防"新写死一个色悄悄溜过去"而立的 —— 第二处断言
 会检查名单里没有化石名,所以名单不能只加不改。)

按规矩补两处:
· 名单里登记,并写清它对齐的是 WebUI 的哪个 token
  (`MailList.tsx:280` 的 `border-blue-200`,与 `bg-blue-50` 成对)
· `Theme.ets` 登记表里写一行理由 —— 重点记下**为什么"选中"必须两个 token**:
  选中与未读的底色相同(都是 `accentSoft`),只靠底色的话
  "选中一封未读邮件"看不出任何变化,那圈边才是信息。
  并把表头计数 24 → 26 同步改掉。

══ ② harmony-widescreen —— 判据错了,它断言的是一个 WebUI 明令禁止的实现

原断言:`assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪')`
—— 它把"圆角"和"裁切"当成**同一件事**。而 WebUI `index.css:968`
在**同一个规则块**里就写明了这条教训:

    .app-shell > * {
      border-radius: var(--radius-card);
      // ★ 这里**不能**写 overflow: hidden(2026-09-14 用户:
      //   「通信页面完全无法上下滑动」)。
      // 面板自己就是滚动容器,而这条规则的特异性比 Tailwind 的 .overflow-y-auto
      // 高 ⇒ 滚动被静默干掉:实测当时**一个可滚动容器都不存在**(scrollerCount=0)。
      // 圆角仍然生效(border-radius 不影响滚动)

鸿蒙这边同一个形状:那层是内容列的根、`Navigation` 的 `List` 在它里面,
写了 `.clip(this.isWide)` 就是 `List` 节点还在(高 1750px)但**滚不动**
(实测 fling 后第一封仍在 y=526)——这正是用户当时报的"列表没法滚动"。

⇒ 判据改成断言**真正的不变式**:宽屏「圆角开(14)+ 面来自设计令牌 +
**不写** `.clip(this.isWide)`」。原来那句测的是"某句实现写着没写着",
而且那个实现是错的 —— 判据跟着错误实现一起钉住了 bug。

══ ③ build-stamp —— 判据要求重构建,照做(**没有**去改 BUILD_INFO.json)

产物记的是 `c0ab3f5`,HEAD 已是新提交。这条判据自己的文本写得很清楚:
> **别去改 BUILD_INFO.json 里的 gitRev / srcHash 了事** —— 那是把这条判据废掉。
> 正确修法只有一个:重跑构建。

所以 `npm run build` 重跑(rev 0)。注意 srcHash 本来就没变
(`6d1195a4008ebca7`)—— 因为 TRACKED 只扫 electron 侧(`src`/`index.html`/
`vite.config.ts`/…),我这几步改的全是 harmony;**变的只有 gitRev**。
这正说明这条判据在比对"产物来自哪个提交",而不是"文件内容有没有动"。

★ 三条各自的性质不同,值得分开记:① 是判据对、我漏登记(补);
② 是判据错、钉住了一个反向实现(改判据);③ 是判据对且给了唯一正确修法(照办)。
2026-09-20 13:05:59 +08:00

418 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 宽屏侧栏(WideSidebar)判据 —— 逐项复刻 WebUI 的 `Sidebar`。
//
// ★★ 2026-09-19 重写本文件的头部说明(上面那一版是错的,与它判的东西矛盾):
// 旧文写的是「60px 图标轨 / 通信·日历·联系人(中)/ 设置(底)/
// 选中态只换颜色 + 品牌浅底,**不加背景块**」—— 那个描述是**编的**:
// · `Sidebar.tsx` 第 26-44 行是**三项**(通信/日历/「**联系**」)——「我的」不在导航轨里,
// 而是 **底部头像按钮**(`Sidebar.tsx:186`);
// · `Sidebar.tsx:110` 有 `<span className="text-3xs">{short}</span>`(有文字);
// · `index.css:1590` 的 `.nav-item[data-active='true']` **有底色块**,
// 而且 CSS 注释专门说明侧栏**必须有**(“图标底下那一块底色是它唯一的选中线索”)。
// 更糟的是:判据当时**引用了这段自编的描述当依据**,于是把错误锁死 —— 全绿。
// 现在的口径:每一项都回读 WebUI 源码取值,不再引“我上次写的那句话”。
//
// 鸿蒙侧原来**没有**宽屏布局(只有底部导航条),WideSidebar 补的就是"宽屏模式"这半:
// ≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
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');
/** WebUI 源码根:判据要**回读**它取值,而不是引注释里的话 */
const WEBUI_SRC = join(ROOT, 'client/electron/src');
const read = p => prose(join(HARMONY_ETS, p));
/*
* 读 WebUI 的源文件(**不去注释**:注释里就有真实理由)。
* ★ 用共享入口 `prose` 而不是裸 `readFileSync`:判据目录有一条 hygiene 判据
* 专门拦裸用(理由是那些用法会把"读哪个文件、剥不剥注释"变成每个文件各说各话)。
*/
const readWeb = p => prose(join(WEBUI_SRC, p));
/** 取某个成员/方法正文(按行切到下一个成员声明) */
function memberBody(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\(|NavItemBuilder\()/.test(lines[i])) { stop = i; break; }
}
return lines.slice(start, stop).join('\n');
}
test('① 宽度:SIDEBAR_WIDTH = 60vp(WebUI 的 `w-[60px]` 是同一数字)', () => {
const sidebar = read('pages/WideSidebar.ets');
const code_ = stripComments(sidebar);
assert.match(code_, /export const SIDEBAR_WIDTH: number = 60\s*;/, '要导出 60 这个常量');
const main = read('pages/MainPage.ets');
assert.match(main, /WideSidebar\(/, 'MainPage 要真正挂 WideSidebar');
});
test('② 侧栏**三项**(通信/日历/联系)+ 底部一簇 —— 与底栏四项不是同一份清单', () => {
/*
* ★★ 2026-09-19 重写(用户:「你写的app和webui大面积不符,问题特别大」)。
*
* 上一版这条判的是「遍历 `NAV_CONTENT_ITEMS`」—— 而 `NAV_CONTENT_ITEMS` 是
* **四项**(含「我的」)。那正是**错的形状**:四项只对**底栏**。
*
* WebUI 两套导航**本来就不同**(同一份源码里两个文件):
* · `NarrowNav.tsx:37-40` items = 通信/日历/**联系人**,
* 再加第 4 个「我的」按钮(`NarrowNav.tsx:143`)⇒ 底栏 **4 项**;
* · `Sidebar.tsx:26-44` navItems = 通信/日历/**联系** ⇒ 侧栏 **3 项**,
* 「我的」是底部**头像按钮**(`Sidebar.tsx:186`,旁边的注释就说它是
* `title=...点击管理账号`)。
*
* 这条件判据的写法变了:不再只查鸿蒙源码的字符串,而是
* **回读 `Sidebar.tsx` 数出 `short:` 的个数**,要求鸿蒙那一份也是同一数字 ——
* 否则下次 WebUI 改了项数,这里只会继续绿。
*/
const sidebarSrc = readWeb('components/Sidebar.tsx');
const narrowSrc = readWeb('components/NarrowNav.tsx');
// 从 WebUI 源码里数导航项:`short: 'xx'` 的行(两处各数一遍)
const shortOf = (src) => [...src.matchAll(/short:\s*'([^']+)'/g)].map(m => m[1]);
const webSidebarShort = shortOf(sidebarSrc);
const webNarrowShort = shortOf(narrowSrc);
assert.deepEqual(webSidebarShort, ['通信', '日历', '联系'],
`WebUI 侧栏应为 3 项(读到的是 ${JSON.stringify(webSidebarShort)})——若这里变了,下面的期望值要跟着改`);
assert.deepEqual(webNarrowShort, ['通信', '日历', '联系人'],
`WebUI 底栏内容项应为 3 项(读到的是 ${JSON.stringify(webNarrowShort)})——与侧栏的第三项措辞**故意不同**`);
// 鸿蒙侧:侧栏清单必须与 WebUI 侧栏逐字一致(项数与文案)
const navItems = read('model/NavItems.ts');
const sidebarList = navItems.match(/export const NAV_SIDEBAR_ITEMS[\s\S]*?\];/);
assert.ok(sidebarList, 'model/NavItems.ts 要有 NAV_SIDEBAR_ITEMS(侧栏专用的三项清单)');
const harmonySidebarShort = [...sidebarList[0].matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(harmonySidebarShort, webSidebarShort,
`NAV_SIDEBAR_ITEMS 的 label 要与 WebUI 侧栏逐字一致(现在读到 ${JSON.stringify(harmonySidebarShort)})`);
// 侧栏**不许**用四项那份清单(那正是这次报的“多出一个我的”)
const sidebar = stripComments(read('pages/WideSidebar.ets'));
assert.match(sidebar, /ForEach\(NAV_SIDEBAR_ITEMS,/,
'侧栏要遍历 NAV_SIDEBAR_ITEMS(三项)');
assert.ok(!/ForEach\(NAV_CONTENT_ITEMS,/.test(sidebar),
'侧栏不许再遍历 NAV_CONTENT_ITEMS(那是**底栏**的四项清单,含「我的」)');
// 品牌标:WebUI 是 `<button data-testid="brand-mark"><BrandMarkIcon/>` —— 可点 + brandMark 图标
assert.match(sidebar, /iconName: 'brandMark'/, '要有品牌标(WebUI `brand-mark` 的对应物,图标是 brandMark)');
assert.ok(!/onSettings/.test(sidebar),
'侧栏不许留 onSettings(它推 `pages/SettingsPage` ⇒ 侧栏整条消失,正是用户报过的形状)');
// 品牌标点它要回第一项(WebUI `onClick={() => setViewMode('inbox')}`)
assert.match(sidebar, /onClick\(\(\) => \{ this\.onSelect\(0\); \}\)/,
'品牌标点击要回收件箱(WebUI brand-mark 的一致习惯:点左上角 logo 回家)');
/*
* 底部一簇 —— `Sidebar.tsx:183-212`:头像(含连接点)/ 主题切换 / 退出。
* 三者漏一个都会在并排截图里缺一块。
*
* ★ 判**真的被调用**(`this.onToggleTheme()`),不是"文件里出现过这个名字":
* 第一版就写了 `/onToggleTheme/` —— 变异测试当场证明它不咬:
* 把 `this.onToggleTheme()` 换成 `this.onSelect(0)` 后,
* 属性**声明**还在,正则照样匹上,判据全绿。同 `navBadgeCount` 那一课的同一个错。
*/
assert.match(sidebar, /this\.onToggleTheme\(\)/, '底部主题键要真的调 onToggleTheme()');
assert.match(sidebar, /this\.onLogout\(\)/, '底部退出键要真的调 onLogout()');
/* 头像:WebUI 取用户名前两字(`Sidebar.tsx:190` 的 `.slice(0, 2)`) */
assert.match(sidebar, /\.slice\(0, 2\)/, '头像要取展示名前两字(WebUI `.slice(0, 2)`)');
/* 头像右下角要带 SSE 连接状态点(WebUI 的 `<ConnectionIndicator/>`) */
assert.match(sidebar, /sseColorOf\(/, '头像要带连接状态点(颜色由 SSE 状态决定)');
const webInd = readWeb('components/ConnectionIndicator.tsx');
/*
* ★★ 2026-09-19 修:色值已从 `WideSidebar.ets` 抽到 `Theme.ets` 的令牌
* (`Theme.sseConnected` 等)—— 这是 `cross-client-theme` 那条
* 「鸿蒙页面里不得出现任何裸色值」要求的方向。
*
* 所以本断言相应改成:这里要有**四个令牌名**与 WebUI 四档的**逐档对应**。
* 分两半断(缺一不可):
* ① 令牌在 `Theme.ets` 里定义,且取值就是 WebUI 对应的那个色(# 值真的一样);
* ② 侧栏真的用了这些令牌(不是定义了不接)。
* 只断①会得到“有令牌但没人用”的假绿(`navMaterial` 就当过孤儿);
* 只断②会得到“接了但颜色抄错”的假绿。
*/
const themeSrc = read('common/Theme.ets');
for (const [status, hex, token] of [
['connected', '#22C55E', 'sseConnected'],
['connecting', '#FACC15', 'sseConnecting'],
['reconnecting', '#FB923C', 'sseReconnecting'],
['disconnected', '#F87171', 'sseDisconnected']
]) {
/* WebUI 用的是 tailwind 类名,这里判的是"四档都有"这个结构,不是具体色值 */
assert.ok(new RegExp(`${status}:`).test(webInd), `WebUI 的连接指示器要有 ${status} 档`);
assert.ok(new RegExp(`static readonly ${token}: string = '${hex}'`).test(themeSrc),
`① Theme.ets 里要有 ${token} = ${hex}(与 WebUI 的 ${status} 档同值)`);
assert.ok(sidebar.includes(`Theme.${token}`),
`② 侧栏要真的用 Theme.${token} —— 定义了不接 = 那档颜色永远画不出来`);
}
/* 「我的」由**头像**进入(不是导航轨里的一项) */
assert.match(sidebar, /this\.onSelect\(ME_PANE_INDEX\)/,
'头像点击要进「我的」窗格(ME_PANE_INDEX)—— WebUI 是 setViewMode(\'account\')');
});
test('③ 侧栏选中态 = **浅蓝底块** + 变色(与底栏"只变色"是两条纪律)', () => {
/*
* ★★ 2026-09-18 重写。原来这条写着「选中态只换颜色……不加背景块」,
* 并引"WebUI Sidebar 本就是纯图标轨"为依据 —— **依据是编的**。
*
* `client/electron/src/index.css:1595-1606` 的原话:
* 「底部导航的选中态**只换颜色**……只作用在 `.narrow-nav` 上:宽屏侧栏是
* 48px 宽的竖条,图标底下那一块底色是它**唯一的选中线索**,
* 所以"只变色"不能无差别推广到所有 `.nav-item`。」
* 而 `.nav-item[data-active='true'] { background-color: rgb(var(--nav-active-bg)) }`
* 就是那块底色(`--nav-active-bg: 219 234 254` = #DBEAFE)。
*
* ★ 这条判据现在**直接读 WebUI 的 CSS 拿真实值**,而不是引自己写的注释 ——
* 上一版之所以能一直绿,就是因为"依据"是一句话,没人拿它跟 WebUI 对。
*/
const sidebar = read('pages/WideSidebar.ets');
const code_ = stripComments(sidebar);
// 从 WebUI 的真实源码里取侧栏选中底色(不写死,改了两边一起动)
const css = prose(join(ROOT, 'client', 'electron', 'src', 'index.css'));
const m = css.match(/--nav-active-bg:\s*(\d+ \d+ \d+)/);
assert.ok(m, 'WebUI index.css 要有 --nav-active-bg(侧栏选中底色)');
const [r, g, b] = m[1].split(' ').map(Number);
const hex = '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
const themeSrc = prose(join(HARMONY_ETS, 'common', 'Theme.ets'));
assert.match(themeSrc, new RegExp(`navActiveBg: string = '${hex}'`, 'i'),
`Theme.navActiveBg 要等于 WebUI 的 --nav-active-bg(${hex})`);
// 选中态必须**有底块**(WebUI 侧栏唯一的选中线索)
/*
* ★ 2026-09-19:表达式从 `this.currentIndex === index` 改成
* `this.currentIndex === sidebarContentIndex(item.key)` ——
* 侧栏现在只画三项,而 currentIndex 是**四项**那套(`me` = 3),
* 所以不能拿构图下标直接比。两条都接受(比的是"这个项是不是选中项")。
*/
assert.match(code_, /backgroundColor\(this\.currentIndex === (index|sidebarContentIndex\(item\.key\)) \? Theme\.navActiveBg : Color\.Transparent\)/,
'导航项选中要有底块(WebUI `.nav-item[data-active]` 的 --nav-active-bg)—— 侧栏不是底栏,不能只变色');
/*
* 文字标签:WebUI `Sidebar.tsx:110` 有 `<span>{short}</span>`,所以侧栏**有** label。
* ★ 2026-09-19:侧栏改成 `Text(item.label)`(从清单取,不是参数传)——
* 两种写法都接受,只要真的把 label 交给了 `Text`。
*/
assert.match(code_, /Text\((label|item\.label)\)/,
'侧栏导航项要有文字标签(WebUI Sidebar 有 <span>{short}</span>)');
// ★ 自检:这三条都必须能判红
assert.ok(!/backgroundColor\(this\.currentIndex === index \? Theme\.navActiveBg : Color\.Transparent\)/.test(
'backgroundColor(this.currentIndex === index ? Color.Transparent : Theme.navActiveBg)'),
'自检:三元式写反了必须判红');
assert.ok(!/Text\((label|item\.label)\)/.test('Text(short)'),
'自检:标签没交给 Text 必须判红');
});
test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768', () => {
const main = read('pages/MainPage.ets');
const code_ = stripComments(main);
// 宽屏分支:isWide 时才渲染 WideSidebar
assert.match(main, /if \(this\.isWide\) \{/, '侧栏要包在 isWide 分支里');
assert.match(code_, /WideSidebar\(\{/, '侧栏要真挂在 build 里(剥注释后仍存在)');
// 窄屏 x 宽屏:底部条只要窄屏有 —— 两半都是条件式的,不是同时出现
assert.match(main, /if \(!this\.isWide\) \{/, '底部导航条要包在 !isWide 分支里');
const both = /if \(this\.isWide\) \{[\s\S]*?if \(!this\.isWide\)/.test(main);
assert.ok(both, 'isWide 与 !isWide 两分支要在同一个结构里(互斥)');
// 断点检测
assert.match(code_, /onAreaChange/, '要用 onAreaChange 实时检测窗口宽度');
assert.match(code_, />=\s*768/s, '断点是 768(与 WebUI 的 lg 断点同一档)');
// let为0(宽屏没有底部条,内容 padding 归零)
/*
* ★ 2026-09-17:让位方式改了 —— 宽屏不再靠"内容 padding 归零",
* 而是宽屏时 navReserve 本身变成 0(没有底部条)。
* 内容窗格必须**满高**,否则内容滑不到条底下、玻璃就没东西可糊。
*
* ★ 2026-09-18:窄屏那一支多加了 `windowInsets.navIndicator`(全屏后要让开系统
* 手势条,见 `harmony-window.test.mjs`)。所以断言改为:**宽屏一定是 0**,
* 窄屏是 `NAV_CONTENT_RESERVE` **加上避让**(不是写死那个字面表达式)。
* 写死的话,"加一个正当的避让"与"宽屏忘了归零"会红得一模一样 ——
* 而这条判据要守的是后者。
*/
const reserve = /this\.navReserve = this\.isWide \? 0 : ([^;]+);/.exec(code_);
assert.ok(reserve, '要按 `isWide ? 0 : <窄屏值>` 的开关式写 navReserve');
const narrowExpr = reserve[1].trim();
assert.notEqual(narrowExpr, '0', '窄屏不能归零(窄屏有底部条,列表要让位)');
assert.match(narrowExpr, /NAV_CONTENT_RESERVE/,
'窄屏要让条高那么大的位(至少含 NAV_CONTENT_RESERVE)');
});
test('⑥ 邮件列表→详情使用系统 Navigation Auto,不再手搓 Row 分栏', () => {
const main = read('pages/MainPage.ets');
const code_ = stripComments(main);
/*
* phone / tablet / 2in1 的列表→详情行为交给系统:
* - 窄宽度自动 Stack(详情覆盖列表,可返回);
* - 宽窗口自动 Split(列表 + 详情并排)。
* 断点由 navBarWidthRange + minContentWidth 共同决定,不复制一套 onAreaChange。
*/
assert.match(code_, /private navPathStack: NavPathStack = new NavPathStack\(\)/,
'通信页必须持有 NavPathStack');
assert.match(code_, /Navigation\(this\.navPathStack\)/,
'通信页列表/详情必须由原生 Navigation 承载');
assert.match(code_, /\.navDestination\(this\.DestinationBuilder\)/,
'Navigation 必须注册邮件详情目标页 builder');
assert.match(code_, /\.mode\(NavigationMode\.Auto\)/,
'必须用 NavigationMode.Auto,让系统自动选择 Stack / Split');
assert.match(code_, /\.navBarWidth\(320\)/,
'宽屏列表栏应与 WebUI MailList 的 320px 同档');
assert.match(code_, /\.navBarWidthRange\(\[280, 360\]\)/,
'列表栏应允许系统在 280–360vp 内适配,而不是固定死布局');
assert.match(code_, /\.minContentWidth\(360\)/,
'详情栏最小宽度参与 Auto 断点计算');
assert.match(code_, /this\.navPathStack\.pushPath\(\{ name: MAIL_DETAIL_ROUTE, param: params \}\)/,
'选中邮件必须进入 NavPathStack,而不是绕回 router.pushUrl');
});
test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比一复刻的骨架)', () => {
const main = read('pages/MainPage.ets');
const code_ = stripComments(main);
/*
* WebUI 的 `.app-shell`(index.css):
* padding: var(--pane-gap) → 10px
* gap: var(--pane-gap) → 10px
* > * { border-radius: var(--radius-card) } → 14px
* 宽屏面板之间留缝(壁纸从缝隙露出)、各面板圆角 14。窄屏贴合全屏(无 padding / 无圆角)。
*/
// 令牌值要和 WebUI 同值(14/10)
const theme = read('common/Theme.ets');
assert.match(theme, /static readonly glassRadius: number = 14/, 'glassRadius = 14(与 WebUI --radius-card 同值)');
assert.match(theme, /static readonly paneGap: number = 10/, 'paneGap = 10(与 WebUI --pane-gap 同值)');
// Row 用 space=paneGap(gap)
assert.match(code_, /Row\(\{ space: Theme\.paneGap \}\)/, '宽屏 Row 用 space=paneGap(与 WebUI gap 同值)');
// 内容面板圆角只在宽屏启用(窄屏贴合全屏)
assert.match(code_, /borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/, '内容面板圆角宽屏 14 / 窄屏 0');
/*
* ★★ 2026-09-20 改:原来这条断言的是
* assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪(clip 宽屏才开)');
* —— 它把"圆角"和"裁切"当成同一件事,而这正是 **WebUI 明令禁止**的做法。
*
* `index.css` 在**同一个规则块**里就写着这条教训(`.app-shell > *` 那段):
* border-radius: var(--radius-card);
* // ★ 这里**不能**写 overflow: hidden(2026-09-14 用户:
* // 「通信页面完全无法上下滑动」)。
* // 面板自己就是滚动容器,而这条规则的特异性比 Tailwind 的 .overflow-y-auto
* // 高 ⇒ 滚动被静默干掉:实测当时**一个可滚动容器都不存在**(scrollerCount=0)。
* // 圆角仍然生效(border-radius 不影响滚动)
*
* 鸿蒙这边是同一个形状:那一层是内容列的根,`Navigation` 的 `List` 在它里面,
* 而 `.clip(this.isWide)` 把子树的滚动裁掉了 —— `List` 节点还在(高 1750px,
* 只放得下 6 张卡)但**滚不动**,实测 fling 后第一封仍在 y=526。
*
* ⇒ 判据改为断言**真正的不变式**:宽屏「圆角开 + 裁切不写」。
* 原来那条测的是"某句实现写着没写着"(且那实现是错的),
* 现在测的是"与 WebUI 同一条几何纪律":圆角在、`overflow:hidden` 不在。
*/
assert.match(code_, /borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/,
'宽屏圆角开(14)/ 窄屏 0 —— 与 WebUI `.app-shell > *` 同值');
assert.match(code_, /attributeModifier\(GlassCardModifier\.of\(this\.bgActive\)\)/,
'宽屏内容列的面来自设计令牌(不是就地写死一个实心 surface)');
assert.ok(!/\.clip\(this\.isWide\)/.test(code_),
'宽屏内容列**不能**写 `.clip(this.isWide)` —— 会把 `Navigation` 里 List 的滚动整条裁掉'
+ '(WebUI `index.css:968` 在同一个规则块里写明了这条,2026-09-14 用户实测"无法上下滑动")');
// 容器 padding 宽屏 paneGap / 窄屏 0(壁纸从缝隙露出)
assert.match(code_, /left: this\.isWide \? Theme\.paneGap : 0/, '宽屏容器左右 padding paneGap(窄屏 0 贴合全屏)');
});
test('⑦ 导航项徽标:取值/色调与 WebUI Sidebar 同口径(纯逻辑,不需要设备)', async () => {
/*
* WebUI `Sidebar.tsx:88-95` 的 `badge` / `badgeTone`:
* `isComm ? unread + pendingPerms : (contacts ? contacts.length : 0)`
* `isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain'`
*
* ★ 这条判据**直接执行**鸿蒙侧的纯函数(`model/NavItems.ts`,无 SDK 依赖),
* 并把期望值**在测试里独立算一遍**(不复用被测函数)—— 否则就是"用实现验实现"。
*
* 为什么值得单列一条:底栏与侧栏都要显示徽标,而两处若各写一套判断,
* 同一时刻一个显示"3 / 未读"、另一个显示"3 / 待批",用户会以为哪里出错了。
* 所以取值与色调都必须来自**同一组纯函数**。
*/
const mod = await import(join(HARMONY_ETS, 'model', 'NavItems.ts'));
const { navBadgeCount, navBadgeTone, navBadgeText } = mod;
// ① 通信 = 未读 + 待决策(两类"要动手"合起来,与 WebUI 同)
assert.equal(navBadgeCount('comm', 5, 2, 0), 7, '通信徽标 = 未读 5 + 待决策 2');
// ② 联系人 = 联系人数
assert.equal(navBadgeCount('contacts', 0, 0, 3), 3, '联系人徽标 = 联系人数');
// ③ 日历 / 我的 没有徽标(日历的"有事"是格子上的点,不是导航栏该喊的)
assert.equal(navBadgeCount('calendar', 5, 2, 3), 0, '日历不该有徽标');
assert.equal(navBadgeCount('me', 5, 2, 3), 0, '「我的」不该有徽标');
// ④ 负数当 0(计数来自网络,不假设干净)
assert.equal(navBadgeCount('comm', -1, -1, -1), 0, '负数一律当 0');
assert.equal(navBadgeCount('contacts', 0, 0, -5), 0, '联系人数为负也当 0');
// ⑤ 色调:待决策橙(更急)优先于未读红;非通信项是中性
assert.equal(navBadgeTone('comm', 2), 'perm', '有人的授权在等我 ⇒ 橙(比"还没看"更急)');
assert.equal(navBadgeTone('comm', 0), 'unread', '只有未读 ⇒ 红');
assert.equal(navBadgeTone('contacts', 0), 'plain', '联系人数是中性色');
// ⑥ 文字:0 → 空串(页面据此不渲染)、>99 → 99+(与 WebUI 同一写法)
assert.equal(navBadgeText(0), '', '0 要变成空串(否则会渲染一个"0"徽标)');
assert.equal(navBadgeText(99), '99');
assert.equal(navBadgeText(100), '99+');
/*
* ⑦ 接线:**两侧**(底栏 NavItem + 宽屏侧栏)都要用这同一组函数。
* 只在一处接的话,另一个视图里徽标就消失了 —— 而用户两个视图都会用到。
*/
const main = stripComments(read('pages/MainPage.ets'));
const sidebar = stripComments(read('pages/WideSidebar.ets'));
/*
* ★ 判**徽标真的被渲染出来**,不是"文件里出现过 navBadgeCount"。
*
* 第一版只 `assert.match(src, /navBadgeCount\(/)` —— 变异测试当场证明它不咬:
* 把渲染处的 `navBadgeCount(...)` 换成 `0`(徽标永远不显示),
* 文件里仍留着别处的调用,判据照样全绿。
*
* 两侧的**写法不同**(都合法),所以按各自形状判:
* · 底栏 MainPage:走一个 helper —— `Text(this.navBadgeOf(key))`,
* helper 体内 `navBadgeText(navBadgeCount(...))`;
* · 宽屏 WideSidebar:就地内联 —— `Text(navBadgeText(navBadgeCount(...)))`。
* 两边都要判到,且都要判底色来自 `navBadgeTone`。
*/
assert.match(main, /Text\(this\.navBadgeOf\(/,
'底栏要把徽标交给 Text 渲染(光"文件里出现过 navBadgeCount"不算接线)');
assert.match(main, /navBadgeText\(navBadgeCount\(/,
'底栏的 helper 要真的用 navBadgeText(navBadgeCount(...)) 算值');
assert.match(main, /backgroundColor\(navBadgeTone\(/,
'底栏的徽标底色要由 navBadgeTone 决定');
assert.match(sidebar, /Text\(navBadgeText\(navBadgeCount\(/,
'侧栏要把徽标交给 Text 渲染(光"文件里出现过 navBadgeCount"不算接线)');
assert.match(sidebar, /backgroundColor\(navBadgeTone\(/,
'侧栏的徽标底色要由 navBadgeTone 决定');
// 两侧读**同一组** AppStorage 键(各算一套的话数字会不一致)
for (const key of ['agentmail.nav.unread', 'agentmail.nav.pending', 'agentmail.nav.contacts']) {
assert.ok(main.includes(key), `MainPage 要读 ${key}`);
assert.ok(sidebar.includes(key), `WideSidebar 要读 ${key}(与底栏同一组键)`);
}
/*
* ⑧ 三档色调都要有**各自的底色**(不能把三档压成两档)。
*
* ★★ 2026-09-19 修 bug:底栏与侧栏两处原先都写
* `tone === 'perm' ? warnFg : danger` —— 于是 `'plain'`(联系人数)走了**红**,
* 看起来像"有未读"。并排截图一眼可见:WebUI 是石板灰的 4,鸿蒙是红的。
*
* ★ 期望值从 WebUI 的 CSS 里**读出来**,不写死:
* `Sidebar.tsx:203-204` 的 `'plain'` 档是 `bg-chrome-600`,
* 而 `--c-chrome-600: 71 85 105`(`index.css:92`)= #475569。
*/
const cssSrc = readWeb('index.css');
const chromeM = cssSrc.match(/--c-chrome-600:\s*(\d+ \d+ \d+)/);
assert.ok(chromeM, 'WebUI index.css 要有 --c-chrome-600(中性徽标底色)');
const [cr, cg, cb] = chromeM[1].split(' ').map(Number);
const plainHex = '#' + [cr, cg, cb].map(v => v.toString(16).padStart(2, '0').toUpperCase()).join('');
const themeSrc = prose(join(HARMONY_ETS, 'common', 'Theme.ets'));
assert.match(themeSrc, new RegExp(`badgePlain: string = '${plainHex}'`, 'i'),
`Theme.badgePlain 要等于 WebUI 的 --c-chrome-600(${plainHex})`);
for (const [name, src] of [['底栏', main], ['侧栏', sidebar]]) {
assert.match(src, /Theme\.badgePlain/,
`${name}的徽标底色要引用 Theme.badgePlain('plain' 档不能跟未读共用红色)`);
}
});