用户四条:
①「2in1 手势」(选了 悬停/右键菜单/触控板 + 快捷键:主页回车写信、
详情页回车回复、Esc 返回)
②「宽屏状态一个邮件被反复点击会被多次填充到右侧」
③「你在登陆页是不是没有做 enter 等键的监听」——**确实漏了**
④「app 顶栏为什么不沉浸」+「右侧三键应当有独立避让」
② 叠栈(实测复现 → 修 → 实测通过)
根因是框架语义用错:pushPath 默认 LaunchMode.STANDARD 每次入栈 ⇒
重建详情组件 + 重拉数据 + 重放入场动画;返回还要按多次。
而 WebUI 是 `set({currentMail})` 幂等赋值(mailStore.ts:115)。
改用 LaunchMode.MOVE_TO_TOP_SINGLETON(官方:同名已在栈里就移上去、
不新建),MainPage + ContactsTab 两处 push 点都改(只改一边=换栏点
又不正常)。
实测:连点同一封 3 次 → **点一次返回就回占位**(修复前要按 3 次)。
③ 登录页回车(用户点出来的真实缺失)
WebUI 是 `<form onSubmit={submit}>`(LoginPage.tsx:92)——浏览器里
输入框按回车就提交;鸿蒙登录页**一行键盘监听都没有**。
补上,走**已有的** doLogin()(不另写一条登录路,免得与按钮的条件分叉)。
① 快捷键:新增 model/KeyboardShortcuts.ts(规则集中一处,三页共用)
· 主页根 Stack:Enter → 写信(与 Ctrl+N 同一个 ComposeIntent.request)
· 详情页:Enter → 开回复(复用 openReplyWithMorph,连动画都不另开);
Esc → 返回,且**弹层开着时先关弹层**再按才返回(否则用户想关回复框
却被踢回列表,输入到一半的内容全没)
· 用键事件**冒泡**:子组件先拿到、未消费才到页面根 ⇒ "详情优先、
主页兜底"由框架保证,不是我自己排的优先级
★ 为何不用 keyboardShortcut:它只收组合键;不带修饰键时只认 FunctionKey,
而 FunctionKey 枚举(enums.d.ts:3444)**没有 Enter**(只有 ESC/F1-F12/
TAB/方向键)⇒ 单按回车表达不出来。
① 悬停反馈:MailRow/SentRow 挂 onHover + Theme.surfaceMuted
(该令牌此前**零使用**,注释本就写着"列表行 hover",正好归位)。
不用 .hoverEffect():系统叠层会与选中/未读的 accentSoft 叠成第三种颜色。
★ 状态存 mail_id 而不是布尔:行本体是 @Builder(无自身状态),
布尔会变成"悬停一行、同栏全亮",所以状态放栏上、存"是哪一封"。
④ 沉浸顶栏:EntryAbility 加 setWindowDecorVisible(false)
实测(2in1 截图硬证):标题栏(AgentMailHarmony)下面**还有一条白条**,
内容从第二条下面才开始。根因是**从未调过装饰接口**⇒用系统默认(PC 带标题栏)。
setWindowLayoutFullScreen(true) 管的是"内容铺到**屏幕**四边",
**不包含**"窗口自己的标题栏是否隐藏"——两件不同的事。
④ 三键避让:Insets 加 windowDecor + getWindowDecorHeight()
隐掉标题栏白条后,系统仍在右上角**浮着**三键(官方:全屏悬浮态固定 37vp)。
而 2in1 **没有状态栏** ⇒ TYPE_SYSTEM.topRect 是 0 ⇒ 只看 statusBar 就
认定"顶部无需避让",内容(右上是「授权」页签)被三键压住。
AvoidAreaType 六种里**没有**"标题栏/三键"这一类,只能单独读
getWindowDecorHeight()(它直接返回 vp)。
避让取**较大者**不加:两者互斥(有状态栏的形态没装饰,反之亦然)。
实测日志:`statusBar=0 navIndicator=0 windowDecor=37`,页签条下移。
判据(13 条新增/改,全部变异验证过)
- 新增 5 条「2in1 快捷键」:单一出处(三页都不得自己比 KEYCODE_ENTER)、
登录页回车、详情页 Enter/Esc + 先关弹层、窗口装饰必须隐掉。
★ 两条第一版是**我自己判红了自己**,都是判据比事实严格:
① 详情页确实有 KEYCODE_ENTER —— 那是**输入框内的候选导航**(onFwdKey),
与页面级快捷键是两件事 ⇒ 改成只查页面级 onKeyEvent 那一段;
② isEscapeKey 先在 import 行出现,从那里切片取到的是注释 ⇒ 改成
从页面级 onKeyEvent 内部起切。
★ 窗口装饰那条第一版写 `/setWindowDecorVisible\(false\)/` —— 紧邻两行**日志**
也含这个串,删掉真正的调用后判据**照样绿**(变异实测没红)。
改成匹配调用形态 `win.setWindowDecorVisible(false)` 后才真会红。
这是"判据匹配到的是关于这件事的文字、不是这件事"的形状。
- harmony-widescreen ⑥ 原来钉精确串
`pushPath({ name: MAIL_DETAIL_ROUTE, param: params })`,加了 launchMode
参数后判红 —— 那是**判据写死了写法**。改成按结构匹配(不变式:选中邮件
要经 navPathStack.pushPath 进 MAIL_DETAIL_ROUTE,带不带 options 是实现细节)。
- harmony-2in1 登记数 12 → 16。
环境(这次为了真验 2in1 专门搭的)
- 下载 2in1 镜像 HarmonyOS 6.1.0(23)(与 target 一致),建实例 HA2in1
(3120×2080,14.2" 笔记本),设备 127.0.0.1:5557,形态确认为 `2in1`。
- 带窗口启动要 Qt xcb:补了 5 个 xcb 库 + Xvfb :99(`-noWindow` 下 2in1 起不了 App)。
- ★ 多设备并存时设备判据会自己挑目标 ⇒ 必须 `AGENTMAIL_HARMONY_TARGET=127.0.0.1:5555`
才跑手机那台;不指定时判据连到未登录的 2in1 上会假红。
这正是 harmony-device.mjs 里 targetKey() 注释写明的已知行为。
★ 未验(要说清楚,不能算过)
- Enter/Esc/Ctrl+N 三个快捷键**仍未在设备上端到端验过**:2in1 模拟器 + Xvfb 下
键盘事件送不进 App(xdotool 的文本能进 TextInput 走输入法通道,但键事件不达;
hdc 的 uinput/uitest keyEvent 同样不生效)。日志显示 SubscribeKeyEvent
被调用 ⇒ 订阅注册成功,纯粹是键送达不了。属环境限制。
- 悬停同理(要有鼠标 hover 事件注入,xdotool mousemove 到窗口不一定转成
ArkUI 的 onHover)。
- 登录页回车:同一限制。
沉浸顶栏与三键避让是**截图硬证过**的(不依赖键盘)。
484 lines
30 KiB
JavaScript
484 lines
30 KiB
JavaScript
// 宽屏侧栏(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 断点计算');
|
||
/*
|
||
* ★★ 2026-09-24 改:原来钉的是**精确串**
|
||
* `pushPath({ name: MAIL_DETAIL_ROUTE, param: params })` ——
|
||
* 而当天给这个调用加了第二个参数(`{ launchMode: MOVE_TO_TOP_SINGLETON }`,
|
||
* 修"反复点同一封会叠栈"),判据立刻判红。
|
||
*
|
||
* 那是**判据写死了写法**(钉形状而不是不变式)——同一类错本仓见过多次。
|
||
* 真正的不变式是:**选中邮件要经 `navPathStack.pushPath` 进 MAIL_DETAIL_ROUTE**,
|
||
* 至于后面带不带 options 是实现细节。改成按**结构**匹配。
|
||
*
|
||
* 改坏会红:把 pushPath 换回 `router.pushUrl`(或改成别的路由名)。
|
||
*/
|
||
assert.match(code_, /this\.navPathStack\.pushPath\(\{\s*name:\s*MAIL_DETAIL_ROUTE,/,
|
||
'选中邮件必须进入 NavPathStack(MAIL_DETAIL_ROUTE),而不是绕回 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 同值)');
|
||
/*
|
||
* ★★ 2026-09-21 反转:原断言是
|
||
* `borderRadius(this.isWide ? Theme.glassRadius : 0)` —— 「宽屏 14 / 窄屏 0」。
|
||
*
|
||
* 那是**误读 WebUI**:它的两条 shell 规则**都给圆角**,差别只在留白 ——
|
||
*
|
||
* .app-shell > * { border-radius: var(--radius-card); } ← 宽屏
|
||
* .narrow-shell > * { border-radius: var(--radius-card); } ← 窄屏
|
||
* .app-shell { padding: var(--pane-gap); }
|
||
* .narrow-shell { padding: var(--pane-gap) var(--pane-gap) 0; } ← 只差底边
|
||
*
|
||
* 我当初把"窄屏底边留白为 0"顺手推广成了"窄屏什么都不留(含圆角)",
|
||
* 于是窄屏四个窗格全是直角 —— 用户看出来的原话:「你的邮件的圆角呢?日历的圆角呢?」
|
||
*
|
||
* 新不变式(这才是 WebUI 的真正契约):
|
||
* · 面板**必须有**圆角(两端都有);
|
||
* · 且**不得**用 `isWide ? … : 0` 这种把圆角绑到宽窄上的写法。
|
||
*/
|
||
assert.match(code_, /borderRadius\(Theme\.glassRadius\)/,
|
||
'内容面板两端都要圆角(WebUI 的 `.app-shell > *` 与 `.narrow-shell > *` 都给 `--radius-card`)');
|
||
assert.ok(!/borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/.test(code_),
|
||
'★ 圆角不得绑在宽窄上(`isWide ? glassRadius : 0`)—— ' +
|
||
'WebUI 的两条 shell 规则**都给圆角**,差的只是留白;' +
|
||
'写成窄屏 0 会让四个窗格变直角,正是用户 2026-09-21 报的那个问题。');
|
||
/*
|
||
* 留白才是两端有别的那一处:宽屏四边都是 paneGap,窄屏底边为 0
|
||
* (底栏自己带 margin,内容要从它下面穿过 —— 见 `navReserve` 那段)。
|
||
*/
|
||
assert.match(code_, /left: Theme\.paneGap,[\s\S]{0,80}?right: Theme\.paneGap,/,
|
||
'左右留白两端都要(WebUI 两条 shell 的 padding 左右都是 --pane-gap)');
|
||
/*
|
||
* ★★ 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` 不在。
|
||
*/
|
||
/*
|
||
* 圆角:两端都开。
|
||
*
|
||
* ★★ 2026-09-21 反转(原句是 `borderRadius(this.isWide ? Theme.glassRadius : 0)`)。
|
||
* 见上面那条断言的说明:WebUI 的两条 shell 规则**都给圆角**,
|
||
* 差的只是留白。写成"窄屏 0"是我把"底边留白为 0"错推广成了"什么都不留"。
|
||
* 用户原话:「你的邮件的圆角呢?日历的圆角呢?」。
|
||
*/
|
||
assert.match(code_, /borderRadius\(Theme\.glassRadius\)/,
|
||
'内容面板两端都要圆角 —— 与 WebUI `.app-shell > *` / `.narrow-shell > *` 同值(都是 `--radius-card`)');
|
||
assert.ok(!/borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/.test(code_),
|
||
'★ 圆角不得绑宽窄(`isWide ? glassRadius : 0`)—— 窄屏会变直角');
|
||
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 用户实测"无法上下滑动")');
|
||
/*
|
||
* 容器左右留白 —— 两端都要 `paneGap`。
|
||
*
|
||
* ★★ 2026-09-21 反转:原句是 `left: this.isWide ? Theme.paneGap : 0`
|
||
* (「窄屏 0 贴合全屏」)。那是我把 WebUI 的
|
||
* .narrow-shell { padding: var(--pane-gap) var(--pane-gap) 0; }
|
||
* 读成了"窄屏不留白" —— 它**左右上三边都留 gap**,只有**底边**为 0
|
||
* (底栏自带 margin,内容要从它下面穿过)。
|
||
* 后果:窄屏面板贴死屏幕两侧,既没圆角的余地也没投影的余地,
|
||
* 看起来是一块被屏幕裁掉的白板 —— 用户:「邮箱页面丑死了,跟 WebUI 没法比」。
|
||
*/
|
||
assert.match(code_, /left: Theme\.paneGap,/,
|
||
'容器左右留白两端都要(WebUI `.narrow-shell` 的 padding 左右也是 `--pane-gap`)');
|
||
assert.ok(!/left: this\.isWide \? Theme\.paneGap : 0/.test(code_),
|
||
'★ 左右留白不得绑宽窄(窄屏会贴死屏幕两侧)');
|
||
/* 真正两端有别的只有**底边**:宽屏补 paneGap,窄屏 0(底栏自己带 margin) */
|
||
assert.match(code_, /bottom: this\.isWide \? Theme\.paneGap : 0/,
|
||
'底边留白才是两端有别的那一处:宽屏 paneGap / 窄屏 0(底栏自带 margin)');
|
||
});
|
||
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' 档不能跟未读共用红色)`);
|
||
}
|
||
});
|