Files
MailUI4Agents/client/electron/test/harmony-widescreen.test.mjs
JianFeeeee 36ef15a8f2 跨端: 顶栏不再自己铺白条 + 日历改左右两栏 + 常驻窗格动画真的会播(用户三处实测指出)
用户三条反馈,逐条对应:

① 「底栏数字为什么显示在图标下面?」
   WebUI 的徽标是 `absolute top-1 right-[22%]`(脱离文档流、浮在图标右上角),
   我写成了 `Column` 的第三个子节点 ⇒ 参与竖向布局、掉到文字下面。
   改用 `Stack({ alignContent: Alignment.TopEnd })` 锚在**图标**上。
   (顺带撞了 skill 里明写的坑:Stack 没有 `.justifyContent()`。)

② 「一个横着过去的白条,我真的服了」/「期望:融进背景」
   WebUI 的顶栏**自身没有底色** —— 只有 `border-b border-gray-200`
   (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
   底色由所在面板给;壁纸开启时那层面板是玻璃色(`index.css:876`)。
   鸿蒙三个窗格顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
   顶上都是一条不通明白带。改成与**页面底**同一口径
   (`bgActive ? Transparent : surface`)+ 补下边框。

③ 「日历页面和webui布局完全不同」
   WebUI 是**左右两栏**(`CalendarView.tsx:452-457`):左 `flex-1` 网格、
   右 `400px` 常驻面板(日程 / 编辑器 / 小时网格三态互斥)。
   鸿蒙原来是**单栏竖堆**。重搭为两栏,`paneWide` 由 `.onAreaChange`
   量本页**自己的**宽度(不是屏幕宽度 —— 宽屏下这一页已被侧栏占掉一截);
   编辑器改占右栏位置(不再整页盖掉正在看的那个月)。

④ 「最严重的动画问题你一点也不该改」
   日历是**常驻挂载**(`visibility` 控制,因为它里面 today 要随时间重算、
   也要保住"正在看哪个月"),而 `.transition()` 只在**挂载/卸载**时触发
   (SDK 原话 \"when it **appears and disappears**\")⇒ 挂在它上面的
   `.transition(paneRiseIn())` **一帧也不会播**,切过去是硬弹。
   WebUI 踩过同一个坑并把错法写进了 `index.css:1305-1320`
   (「只挂了类,却没让触发窗口出现 ⇒ 类挂着、动画永远不播」),
   它的解法是 `html.view-switch` 重放窗口。ArkUI 对应物是
   `animateTo` + 显式 `calPaneIn` 属性(`opacity` + `translate`)。
   ★ reset 必须在 `animateTo` **外**:写进回调里会与同帧的 1 相抵,
     渲染层只看得见最终值 ⇒ 动画退化成一个瞬移。

顺带修:
· 宽屏侧栏 = 3 项(通信/日历/**联系**)+ 底部一簇(头像/主题/退出),
  与底栏的四项(含「我的」)**不是同一份清单** —— WebUI 的 Sidebar 与
  NarrowNav 本就不同(`Sidebar.tsx:26-44` vs `NarrowNav.tsx:37-40`+143)。
  新增 `NAV_SIDEBAR_ITEMS` / `ME_PANE_INDEX` / `SIDEBAR_ITEM_*`。
· 退出登录抽成 `api/Logout.ets` 的 `performLogout()`:侧栏底簇与「我的」页
  两个入口必须做同一件事(尤其"先注销推送 token"那一步),复制一份就会不一致。
· 徽标 `'plain'` 档底色:WebUI 是石板灰 `--c-chrome-600`(#475569),
  我写成与未读共用红色 ⇒ 「联系」的徽标看起来像"有未读"。
· 主题快捷开关(侧栏底簇):对齐 `ThemeToggleButton` —— 从 `system` 翻转时
  落到**当前生效值的反面**(不是回 system;那可能毫无变化、让按钮看起来坏了)。
· 「浓度」→「压暗」+ 数值带单位(原先屏上印 `56.000000`;WebUI 是 `suffix="%"`)。

判据(8 个套件全绿:logic 28 / nav 18 / widescreen 7 / window 9 /
arkts 5 / contacts 5 / calendar 30 / system-api 5):
· `harmony-widescreen` ② 重写:**回读 `Sidebar.tsx` 数 `short:` 的个数**
  要求鸿蒙同数,并断言底部一簇三键真的被调用(`this.onToggleTheme()` ——
  第一版写成 `/onToggleTheme/`,变异测试当场证明它不咬:属性**声明**还在,
  正则照样匹上)。新增 ⑧:三档色调各自底色,期望值从 `--c-chrome-600` 读出。
· `harmony-nav` 动画条重写:改判**机制真的存在且被驱动**
  (旧断言 `.visibility(...).transition(...)` 锁的正是那个 bug ——
  判据引自己写的注释当依据,就会把错误锁死)。新增 reset-在-animateTo-外
  这条断言(否则动画退化成瞬移)。
· `harmony-nav` 设备条:`navItemsOf` 的宽屏过滤从"左边缘靠左 1/6"
  改成"**整个盒子在侧栏轨道内**" —— 旧条件把日历网格的格子
  (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
  新增 `navRailItemsOf`:导航轨贴顶、底部簇在屏底,按位置切一刀。
· `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS`
  **各自的数组**里取 label —— 原先把全文件 `label:` 一网打尽,
  得到 7 个(4+3 混在一起),任何一边改对了它都会红。

设备实测(HATriple 三折叠,3184×2232):侧栏 3 项 + 底簇 / 底栏徽标回到
图标右上角 / 日历左右两栏与 WebUI 并排同构。

server/go.mod:补 2da38bb 漏提交的 lunar-go 依赖。
2026-09-19 12:30:05 +08:00

369 lines
22 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 { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
/** WebUI 源码根:判据要**回读**它取值,而不是引注释里的话 */
const WEBUI_SRC = join(ROOT, 'client/electron/src');
const read = p => prose(join(HARMONY_ETS, p));
/** 读 WebUI 的源文件(**不去注释**:注释里就有真实理由) */
const readWeb = p => readFileSync(join(WEBUI_SRC, p), 'utf8');
/** 取某个成员/方法正文(按行切到下一个成员声明) */
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');
for (const [status, hex] of [['connected', '#22C55E'], ['connecting', '#FACC15'], ['reconnecting', '#FB923C'], ['disconnected', '#F87171']]) {
/* WebUI 用的是 tailwind 类名,这里判的是"四档都有"这个结构,不是具体色值 */
assert.ok(new RegExp(`${status}:`).test(webInd), `WebUI 的连接指示器要有 ${status} 档`);
assert.ok(sidebar.includes(hex), `鸿蒙的连接点要有 ${status} 对应的色值 ${hex}`);
}
/* 「我的」由**头像**进入(不是导航轨里的一项) */
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 = readFileSync(
join(ROOT, 'client', 'electron', 'src', 'index.css'), 'utf8');
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 = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
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');
assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪(clip 宽屏才开)');
// 容器 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 = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
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' 档不能跟未读共用红色)`);
}
});