Files
MailUI4Agents/client/electron/test/harmony-widescreen.test.mjs
JianFeeeee 28e3da76d4 跨端: 左右滑动翻页(P6 第 3 步)+ 还 gesture-semantics 债 + 修跑不起来的判据基建
★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
  而是 `docs/DEBTS.json` 里挂着的一笔债 —— `gesture-semantics` 的原话是:

    「P6 第 3 步:鸿蒙侧出现滑动手势代码时**立即建**判据
      (此前建 = 只有一端存在的假判据)」

  也就是**先有手势、再钉语义**。WebUI 2026-09-14 就有滑动翻页(用户当时
  亲口提的),鸿蒙一直没有 ⇒ 之前建判据会是空真(∀x∈∅)。

## 一、手势本体(两端语义逐项对齐,数值各自定)

按 `HARMONY-ALIGN-PLAN.md:118-128` 显式选的 **(b) 口径**:
「手势的物理量本来就不该强求同值……该对齐的是**语义层**」。

· 判定逻辑放**纯逻辑层** `model/Calendar.ts` 的 `judgeSwipe`(可被 node 直跑,
  写在 .ets 里就跑不了判据,语义没法被单测钉住)。四道门:位移 / 纵向优先 /
  快滑窗口 / 方向。
· 阈值**不引用** WebUI 的 40 / 1.5 / 600(那是把巧合当契约),
  各自定为 56vp / 1.4× / 700ms,并在注释里写出取值依据。
· 接线在 `CalendarPage.ets`:`PanGesture({direction: Horizontal})` +
  `onActionStart`(记时 —— `GestureEvent` **没有时间戳字段**,我查了 SDK
  的 gesture.d.ts 确认)+ `onActionEnd`(读 offsetX/offsetY)。
· ★ 挂在**网格列**上而不是整页:右栏(日程/编辑器)里有输入框与可滚内容,
  整页挂会让「在表单里横划一下」变成翻月。
· ★ 翻页复用 `shiftRange()`(与 ‹ › 按钮**同一个来源**)—— WebUI 的注释
  专门交代过:各写一套的话,阈值、边界、三档行为迟早分叉。
  手势回调里**不准**直接改 year/month(锚点是唯一真相,年月只能由
  `shiftRange → syncYearMonthFrom` 派生)。

**有意差异(记录在案,不是漏做)**:WebUI 在周/日档会额外检查「触点是否落在
可横向滚动的区域里」,是则让给滚动条(用户 2026-09-14 报过这个冲突)。
鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该条件不适用。
哪天加了横滚必须同时补上它。

## 二、判据(8 条语义契约 + 行为层)

新增 `cross-client-gesture.test.mjs`:两端都真有手势 / 方向映射逐项相同 /
纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
**只比语义、不比数值**,并反向断言鸿蒙的阈值常量不得直接取 WebUI 的那三个数。

`harmony-logic.test.mjs` 加行为判据:真跑 `judgeSwipe`,把四道门各自验一遍
(只钉字符串的话,一个 return 写漏了照样全绿)。

## 三、顺手修掉的三处**判据基建**缺陷(不修就没法验证上面这些)

1. `run-all.mjs` 只认 `# pass N`,而 node v24 打的是 `ℹ pass N`
   ⇒ **21 个文件被记成"没自报条数"**、套件在 HEAD 就恒红(memory 里记过这条,
   修法也记过,今天终于落进代码:4 个正则加 `(?:#|ℹ)`)。修完 `unreported` 27 → 0。
   代价是暴露出一批此前被"没自报"掩盖的真实问题(下面 4~6 条)。
2. `harmony-nav` 的设备判据 `navItemsOf`:宽屏过滤条件从「左边缘靠左 1/6」
   改成「**整个盒子在侧栏轨道内**」。旧条件把**日历网格的格子**
   (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
   这是设备实测抓出来的 —— 我第一版还以为是"底部簇混进来了",
   打印真实数据才发现是隔壁页面的格子。
3. `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取
   label。原先把全文件 `label:` 一网打尽 ⇒ 得到 7 个(4+3 混在一起),
   任何一边改对了它都会红。

## 四、被判据拦住后的正经修法(每条都按判据自己给的方向改,不改判据迁就代码)

· `cross-client-theme` A2 拦住我:新增的 `navActiveBg`/`navBrandFg`/`badgePlain`
  未登记;又拦住我:`sseColorOf` 里四个裸色值。→ 抽成 `Theme.sseConnected` 等
  四个令牌(取值对齐 WebUI 的 Tailwind 类)+ 登记 + 在 Theme.ets 的表里写理由。
· 同一条判据的"死令牌"检出:`Theme.durBase` 声明了却从没人读。
  **查 WebUI 才发现壁纸淡入是真有的动效**(`index.css:742-752` 的
  `.app-backdrop` 从 opacity:0 → 1,180ms)⇒ 补上而不是删令牌
  (删掉等于把差异抹平、还说成"清理")。reset 在 `animateTo` **外**,
  与 `calPaneIn` 同一条纪律。
· 玻璃登记:`NavItemBuilder` → `SidebarItem`(重构后按最近的 @Builder 命名),
  登记同步跟上。
· `harmony-admin` 的退出判据:退出逻辑抽成 `api/Logout.ets` 的 `performLogout()`
  (两个入口——「我的」页与侧栏底簇——必须做同一件事,尤其"先注销推送 token"
  那一步)。判据相应改成**追到实际执行处**(两半都断:按钮调了 + 函数真清了全部),
  并写明"别再退回直接匹配 SETTINGS_PAGE 的写法"(那会随重构假红,
  下一个人只会去改判据而不看行为)。
· `harmony-widescreen` 的连接点色值:色值搬进 Theme 后,判据改成断
  「令牌定义对了 + 侧栏真的用了它」两半(只断任一半都有假绿形态)。
· `align-refs`:`CalendarView.tsx` 变了,按判据要求**读一遍差异**再更新登记
  (差异只有农历小字的灰阶档位 gray-300→gray-400/500,**骨架未变**)。
· `criteria-hygiene`:`harmony-contacts` 自造了一个 `code()`、`harmony-widescreen`
  裸用 `readFileSync` ⇒ 都改用 `lib/read.mjs` 的共享入口。
  途中撞出一个**判据自己的 bug**:`code()` 的块注释正则
  `/\*[\s\S]*?\*\//` 会把注释里出现的 `/*`(如 `/」**` 这种中文夹星号)
  当成块注释起点,一路吃到几十行后的 `*/`,把中间的 import 全吞掉 ——
  于是 hygiene 判据假红"没 import"。改掉那处写法后正常。

## 五、验证

判据面:`run-all.mjs` → `files=32 ran=32 checks=497 pass=497 fail=0
skip=0 red=0 broken=0 unreported=0`。
其中新/改判据:gesture 8、nav 18、widescreen 7、logic 30、admin 27、
cross-client-theme 15、criteria-hygiene 6。
构建:`hvigorw assembleHap` 成功;前端 `npm run build` + 重新打 AppImage/deb
(`build-stamp` 7/7、`packaging` 5/5)。

**设备实测(HATriple 三折叠 3184×2232,hdc 连 127.0.0.1:5555)**:
· 农历在格子里真的显示(1=二十 / 7=廿六 / 19=**初九** / 11=八月),与 WebUI 一致;
  这条同时验证了**服务端农历路由已部署**(之前线上是 404)。
· 日历左右两栏:编辑器出现在**右栏**、左栏月份仍可见(单栏模式下编辑器会整页盖掉它)。
· 侧栏 3 项 + 底部一簇;徽标回到图标右上角。

**仍未验(如实标注)**:滑动翻页的**手感**(阈值 56vp/1.4×/700ms 是否合适)
只能真人滑过才知道;我只验了判定逻辑与接线形态。动画同理 ——
机制已验证(`animateTo` 驱动 + reset 在窗口外),但"看起来顺不顺"未做取样验证。

docs:`DEBTS.json` 销掉 `gesture-semantics`(并记结算说明)、
`HARMONY-ALIGN-PLAN.md` P6 从「✅(滑动翻页除外)」改为 ✅。
2026-09-19 14:01:21 +08:00

391 lines
24 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');
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 = 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' 档不能跟未读共用红色)`);
}
});