Files
MailUI4Agents/client/electron/test/harmony-2in1.test.mjs
JianFeeeee e54dc39f8f 跨端: 鸿蒙 2in1 键盘可达 + 悬停 + 沉浸顶栏 + 三键避让 + 修叠栈
用户四条:
①「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)。
- 登录页回车:同一限制。
  沉浸顶栏与三键避让是**截图硬证过**的(不依赖键盘)。
2026-09-25 12:21:00 +08:00

498 lines
27 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.

/*
* 2in1(平板/PC 形态)键盘可达性判据 —— 用户 2026-09-21:
* · 「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
* · 「上下键切换发信目标」
* · 「回车展开输入框等」
*
* # 为什么单独一个文件
*
* 这三条属于**同一条能力线**(键盘可达),而它们各自跨了两个文件
* (根组件 + 条件挂载的子组件 / 纯逻辑 + 界面接线)。混进 `harmony-nav`
* (管悬浮玻璃导航条)或 `harmony-logic`(管纯函数)都会让那个文件的
* "这一期在钉什么"变得含糊。
*
* # 判据分寸:钉"接上了"和"边界对",不钉"好不好用"
*
* 键盘交互在模拟器上**没法端到端验**(没有真实键盘事件的注入通道,
* `uitest uiInput` 只有 click/longClick/swipe/fling,没有 key)。
* ⇒ 所以分两层:
* · **可执行层**:纯逻辑用真跑(`model/AddressSuggest.ts` 已被
* `cross-client-logic.test.mjs` 与 electron 逐例比对,这里不重复跑,
* 只钉"界面接线确实调了它");
* · **接线层**:钉"快捷键绑在根上、意图有接收方、键位不与系统冲突"。
*
* # 每个断言都要在**改坏时变红**(本仓纪律)
*
* 下面每条都写了"改什么会红"。写不出这句话的断言就是装饰。
*/
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { code, prose } from './lib/read.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const MAIN = code(join(ETS, 'pages/MainPage.ets'));
const COMPOSE = code(join(ETS, 'pages/ComposePage.ets'));
const INTENT = code(join(ETS, 'common/ComposeIntent.ets'));
const SUGGEST_UI = code(join(ETS, 'model/AddressSuggest.ts'));
const PROSE_INTENT = prose(join(ETS, 'common/ComposeIntent.ets'));
const mainProse = prose(join(ETS, 'pages/MainPage.ets'));
const composeProse = prose(join(ETS, 'pages/ComposePage.ets'));
/** 取 `from` 处第一个 `{` 到配对 `}` 之间的正文(按花括号配对,不用窗口) */
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} 的花括号没有闭合`);
}
/* ────────────────────────────────────────────────────────────────
* ① 打开发信页的快捷键
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|Ctrl+N 绑在**根**组件树上(不是某个会卸载的分支)', () => {
/*
* 官方 `keyboardShortcut` 文档:「即使组件未获焦或是在所在页面未展示,
* 只要已经挂载到**获焦窗口**的组件树上就会响应自定义组合键」。
* ⇒ 只有绑在窗口组件树的根上,"窗口在就生效"才成立。
*
* 改坏会红:把 `.keyboardShortcut(...)` 挪到 FAB 上(FAB 在 `if` 分支里、
* 窄屏/宽屏位置也不同)—— 换个 tab 就失效,而那时用户按 Ctrl+N 什么都没发生。
*/
const at = MAIN.indexOf(".keyboardShortcut('n'");
assert.ok(at > 0, 'MainPage 里要有 Ctrl+N 绑定');
const structAt = MAIN.lastIndexOf('struct MainPage', at);
const prevStruct = MAIN.lastIndexOf('\nstruct ', at);
assert.ok(structAt > 0, 'Ctrl+N 绑定的位置要在 MainPage 之前有 struct 声明');
assert.ok(
structAt > prevStruct,
`Ctrl+N 必须绑在 MainPage(根)里,而不是更早的 ${MAIN.slice(prevStruct + 1, prevStruct + 40).split('\n')[0]}`
);
});
test('2in1 快捷键|键位不与官方禁止绑定的系统组合冲突', () => {
/*
* 官方文档「禁止绑定的系统快捷键」列了五个:Alt+F4、Alt+Shift+F4、
* Alt+TAB、Alt+Shift+TAB、Ctrl+Shift+ESC。
*
* 这五个绑上去**不生效**(且没有报错)—— 表现为"按了没反应",
* 极容易被误判成代码接错了。
*
* 改坏会红:把 Ctrl+N 改成 Alt+Tab 之类。
*/
const at = MAIN.indexOf('.keyboardShortcut(');
assert.ok(at > 0);
const call = MAIN.slice(at, MAIN.indexOf(')', MAIN.indexOf('[', at)) + 1);
const banned = [
["Alt", "F4"], ["Alt", "TAB"], ["Ctrl", "Shift", "ESC"]
];
for (const combo of banned) {
const all = combo.every(k => call.includes(`ModifierKey.${k.toUpperCase()}`));
assert.ok(!all, `不得绑定被系统占用的 ${combo.join('+')}`);
}
});
test('2in1 快捷键|组合键用 ModifierKey 枚举,且热键是单字符', () => {
/*
* 官方约束(「快捷键使用注意事项」表):
* · 「控制键 Ctrl、Shift、Alt 及它们的组合加上热键的单个字符」
* · 「value 有多个字符时**不绑定**组合键」(静默失败)
* · 「keys 有重复的控制键时**不绑定**」(静默失败)
*
* 改坏会红:写成 `.keyboardShortcut('open', ...)`(多字符)或
* `[ModifierKey.CTRL, ModifierKey.CTRL]`(重复)。
*/
const m = MAIN.match(/\.keyboardShortcut\(\s*'([^']*)'\s*,\s*\[([^\]]*)\]/);
assert.ok(m, '要能解析出 keyboardShortcut 的 value 与 keys');
const [, value, keysRaw] = m;
assert.equal(value.length, 1, `热键必须是单个字符,实际是 '${value}'`);
const keys = keysRaw.split(',').map(s => s.trim()).filter(Boolean);
assert.ok(keys.length > 0, 'keys 不能为空');
assert.equal(new Set(keys).size, keys.length, `keys 不得重复:${keysRaw}`);
for (const k of keys) {
assert.match(k, /^ModifierKey\.(CTRL|SHIFT|ALT)$/, `keys 只允许 ModifierKey.CTRL/SHIFT/ALT,实际 ${k}`);
}
});
test('2in1 快捷键|同一组合只允许绑一处(浅的赢,第二处等于失效)', () => {
/*
* 官方文档:「多个不同组件设置相同组合键 ⇒ 只响应节点树上的
* **深度最浅**的组件,其它组件不响应快捷键」。
*
* ⇒ 再给别处的"写信"按钮补一个 Ctrl+N,不是"多一个入口",
* 而是让后来那处**永远收不到**。这类"多写一份反而坏掉"的坑
* 必须由判据挡住 —— 它不会报错,只会静默失效。
*
* 改坏会红:在 ComposePage / 任何别处再加一个 `.keyboardShortcut('n', [CTRL])`。
*/
const hits = [];
for (const [name, src] of [['MainPage', MAIN], ['ComposePage', COMPOSE]]) {
const re = /keyboardShortcut\(\s*'n'\s*,\s*\[\s*ModifierKey\.CTRL\s*\]/g;
const n = (src.match(re) ?? []).length;
if (n > 0) hits.push(`${name}×${n}`);
}
assert.deepEqual(hits, ['MainPage×1'], `Ctrl+N 只能绑一处,实际:${hits.join(' ')}`);
});
/* ────────────────────────────────────────────────────────────────
* ② 意图的"两半"必须都在(缺一半 = 按键静默失效)
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|意图有"存住"与"当场交付"两半,且发起方先切到通信页', () => {
/*
* 根(MainPage)够不着 `openCompose()` —— 它住在**条件挂载**的 CommPage 上
* (`if (this.currentIndex === 0)`)。这正是 `PushService` 处理"点通知跳转"
* 时踩过的同一个坑,解法在它的注释里:**两半都要有**。
*
* · 只有"存住" ⇒ 用户此刻就在通信页、页面早挂载完了,
* `aboutToAppear` 不重跑 ⇒ **按了没反应**;
* · 只有"当场交付" ⇒ 用户此刻在日历页、CommPage 还没实例化、
* 没有监听者 ⇒ **同样没反应**。
*
* 改坏会红:删掉 `ComposeIntent.setListener(...)`(通信页内按无效);
* 删掉 `ComposeIntent.consume()`(跨页按无效);
* 删掉发起方的 `this.currentIndex = 0`(在日历页按了,意图存着却没人挂载它)。
*/
assert.match(INTENT, /static pending: boolean/, '要有"存住"的格子');
assert.match(INTENT, /static setListener\(/, '要有登记监听的入口');
assert.match(INTENT, /static consume\(\): boolean/, '要有取走待处理的入口');
/* 发起方:先切通信页,再提意图 */
const callAt = MAIN.indexOf('ComposeIntent.request()');
assert.ok(callAt > 0, '根上要提意图');
const before = MAIN.slice(Math.max(0, callAt - 400), callAt);
assert.match(
before,
/this\.currentIndex\s*=\s*0/,
'提意图之前必须先把 currentIndex 拨到 0(否则 CommPage 可能还没挂载)'
);
/* 接手方:两半都接上 */
const commAt = MAIN.indexOf('struct CommPage');
const commEnd = MAIN.indexOf('\nstruct ', commAt + 1);
const commBody = MAIN.slice(commAt, commEnd > 0 ? commEnd : MAIN.length);
assert.match(commBody, /ComposeIntent\.setListener\(/, 'CommPage 要登记监听');
assert.match(commBody, /ComposeIntent\.consume\(\)/, 'CommPage 挂载后要取走积压的请求');
assert.match(commBody, /ComposeIntent\.clearListener\(\)/, 'CommPage 卸载时要摘掉监听');
});
test('2in1 快捷键|摘监听发生在 aboutToDisappear(否则唤醒已销毁组件)', () => {
/*
* `clearListener` 若不在 `aboutToDisappear` 里,用户切走之后
* 键盘事件仍会调到那个已卸载组件的 `openCompose()` ——
* 轻则不响应,重则对已释放的 `navPathStack` 推路由。
*
* 改坏会红:把 `clearListener()` 挪到 `aboutToAppear`,或整个删掉。
*/
const commAt = MAIN.indexOf('struct CommPage');
const commEnd = MAIN.indexOf('\nstruct ', commAt + 1);
const commBody = MAIN.slice(commAt, commEnd > 0 ? commEnd : MAIN.length);
const disAt = commBody.indexOf('aboutToDisappear');
assert.ok(disAt > 0, 'CommPage 要有 aboutToDisappear');
const disBody = braceBody(commBody, 'aboutToDisappear');
assert.match(disBody, /ComposeIntent\.clearListener\(\)/, '摘监听要在 aboutToDisappear 里');
});
/* ────────────────────────────────────────────────────────────────
* ③ 收件人补全:上下键 / 回车 —— 接线用的必须是那份**已比对过**的纯逻辑
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|收件人键盘处理走纯逻辑(接循环下标,不自己写 %)', () => {
/*
* `nextActiveIndex` 里那个 `+ n) % n` 是负下标陷阱的解药
* (JS 的 `%` 对负数返回负数,`-1 % 5 === -1`;而负下标在数组访问里
* **不报错**,只表现为"按上键后没有任何一项高亮")。
* 该函数已被 `cross-client-logic.test.mjs` 与 electron 逐例比对。
*
* ⇒ 界面必须**调它**,不能就地写 `(i - 1) % n` —— 后者看起来等价、
* 实际在边界上会静默坏掉,而这样的坏不会让任何判据变红。
*
* 改坏会红:把 `nextActiveIndex(...)` 换回 `(this.suggestActive + 1) % len`。
*/
assert.match(COMPOSE, /nextActiveIndex\(/, '收件人键盘处理要调 nextActiveIndex');
assert.ok(
!/suggestActive\s*[-+]\s*1\)\s*%/.test(COMPOSE),
'不得自己就地写 %(负下标陷阱)'
);
assert.match(SUGGEST_UI, /\(\(current \+ delta\) % count \+ count\) % count/, '纯逻辑里才是那条公式');
});
test('2in1 快捷键|↑↓ 换候选、Enter/Tab 选中、Esc 收起 —— 四个键都在', () => {
/*
* 对齐 WebUI `AddressInput.tsx:143-155`:
* ArrowDown → active+1 ;ArrowUp → active-1
* Enter | Tab → 选中当前项 ;Escape → 收起
*
* 改坏会红:删掉任一个 `else if` 分支 —— 用户按那个键就毫无反应。
*/
const at = COMPOSE.indexOf('onToKey');
assert.ok(at > 0, '要有 onToKey');
const body = braceBody(COMPOSE, 'private onToKey');
assert.match(body, /KEYCODE_DPAD_DOWN/, '↓ 要有');
assert.match(body, /KEYCODE_DPAD_UP/, '↑ 要有');
assert.match(body, /KEYCODE_ENTER/, 'Enter 要有');
assert.match(body, /KEYCODE_TAB/, 'Tab 要有');
assert.match(body, /KEYCODE_ESCAPE/, 'Esc 要有');
});
test('2in1 快捷键|只响应 KeyType.Down(Down/Up 都处理会让一次按键走两步)', () => {
/*
* `KeyEvent` 对一次按键会派发 Down 与 Up 两个事件。两个都处理 ⇒
* 按一次 ↓ 下标移动 **2** 格,用户看到的是"跳着走"。
*
* 改坏会红:删掉 `if (e.type !== KeyType.Down) return;`。
*/
const body = braceBody(COMPOSE, 'private onToKey');
assert.match(body, /e\.type\s*!==\s*KeyType\.Down/, '要先挡掉非 Down 的按键事件');
});
test('2in1 快捷键|地址候选列表内联渲染,不用 bindPopup/bindMenu', () => {
/*
* 这两者各有自己的焦点体系 —— 用户的按键会先被它们吃掉,
* "↑↓ 切换候选"就落不到 `onToKey` 上(菜单收不到、输入框也收不到)。
*
* 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。
*
* ★★ 2026-09-21 改:原来这两条断言扫的是**整个文件**
* (`!/\.bindMenu\(/.test(COMPOSE)`)—— 那把**不属于候选列表**的菜单
* 也一并禁了。我在账号选择器上用了官方 `bindMenu`(那是正确的:
* 它是个"点开选一个"的菜单,没有 ↑↓ 候选导航),结果被判据误报。
*
* 两者性质完全不同:
* · 地址候选(`suggestOpen`):**要 ↑↓/Enter 导航** ⇒ 必须内联,
* 这是无障碍与键盘可达的硬要求;
* · 账号选择器(`showAccountPicker`):**没有键盘导航**,就是个下拉
* ⇒ 官方 `bindMenu` 正合适(还自带点外部关闭/边缘避让)。
*
* ⇒ 断言改为**只看候选那一段**(从 `suggestOpen` 条件挂载处取到下一段结束)。
* 这样它仍然拦得住"把候选改成 bindMenu"(真正要防的那件事)。
*/
const suggestAt = COMPOSE.indexOf('if (this.suggestOpen && this.suggestItems.length > 0)');
assert.ok(suggestAt > 0, '要能找到地址候选的挂载点(它必须内联渲染)');
/*
* ★ 窗口要**从候选的开头往前多取一段**,不能从 `if` 那个位置开始。
*
* 实测(变异验证时发现的):我第一版从 `suggestAt` 开始切,
* 而"把候选改成 `bindMenu`"最自然的写法是把 `.bindMenu(...)` 写在
* **那个 `if` 之前**(作为一个链式修饰符)—— 于是它落在窗口之外,
* 变异**测不出来**(实测确实绿)。
*
* 判据自己对变异不敏感 = 它没在守卫那件事。
* ⇒ 往前取 600 字符(够包住同一节点上的修饰符链),往后到该块结束。
*/
const from = Math.max(0, suggestAt - 600);
const tail = COMPOSE.slice(suggestAt);
const cut = tail.indexOf('Divider()');
const suggestBlock = COMPOSE.slice(from, suggestAt + (cut > 0 ? cut : 3000));
assert.ok(!/\.bindPopup\(/.test(suggestBlock), '地址候选不得用 bindPopup(会吃掉按键)');
assert.ok(!/\.bindMenu\(/.test(suggestBlock), '地址候选不得用 bindMenu(会吃掉按键)');
assert.match(COMPOSE, /if \(this\.suggestOpen && this\.suggestItems\.length > 0\)/, '候选要在布局里内联挂载');
});
test('2in1 快捷键|候选标题读 title 时守住 omitempty 缺键', () => {
/*
* `SessionCandidate.Title` 带 `json:"title,omitempty"` ⇒ **整个键可能不存在**。
* ArkTS 的裸 cast(`JSON.parse(raw) as T`)在缺键时给 `undefined`,
* **不会**应用类里那个 `= ''` 默认值。
*
* 本仓已踩过同一个坑的另一个实例:`MailDetail.normalize()` 之前直接读
* `m.to.trim()`,服务端 omit 时就 `Cannot read property trim of undefined`
* ⇒ **整页白屏**。
*
* ★★ 2026-09-21 改:守卫从那三个字段的**内联读取**搬进了
* `model/AddressSuggest.ts` 的 `normalizeCandidates`
* (因为转发条、日历事件也要同一套,内联三份必然漂移)。
*
* 本判据原来钉的是**内联那行的字面文本**
* (`/typeof raw === 'string' ? raw : ''/`)——
* 那是判据的**实现细节**,不是它的**意图**。搬了家就假红。
*
* ⇒ 改钉两件真的事:
* ① 守卫本身在(三个字段各一道);
* ② **调用点真的走那道守卫**(而不是绕回去自己读 `.title`)。
* 只钉①会被"守卫在库里、调用点旁路"绕过;
* 只钉②会被"调用了却传未守卫的原始值"绕过。两件都要。
*/
const MODEL = code(join(ETS, 'model/AddressSuggest.ts'));
assert.match(MODEL, /typeof t === 'string' \? t : ''/, 'title 缺键要守成空串');
assert.match(MODEL, /typeof s === 'string' \? s : ''/, 'source 缺键要守成空串');
assert.match(MODEL, /typeof u === 'number' \? u : 0/, 'unread 缺键要守成 0');
/* 调用点必须经过 `normalizeCandidates`,不得自己读 `c.title` 当字符串用 */
assert.match(COMPOSE, /normalizeCandidates\(/, '写信页要经共享归一化,不得内联重写一份守卫');
assert.ok(
!/\.suggestTitles\s*=\s*[^;]*\.title\b/.test(COMPOSE),
'不得把候选的 title 直接赋给 suggestTitles(绕过守卫)'
);
/* 而且模型里的字段名要跟服务端一致(服务端给的是 alias/title/source) */
const M = code(join(ETS, 'model/Models.ets'));
const cls = M.slice(M.indexOf('export class AddressSuggestion'));
assert.match(cls, /alias: string/, 'AddressSuggestion 要有 alias(服务端字段名)');
assert.ok(!/value: string/.test(cls.slice(0, 600)), '不得保留服务端从不返回的 value 字段');
});
/* ────────────────────────────────────────────────────────────────
* ④ 文档化:为什么用 keyboardShortcut 而不是 onKeyEvent
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|选 keyboardShortcut 的理由写在源码里(否则后人会"顺手改成 onKeyEvent")', () => {
/*
* 这条判据钉的不是代码,是**理由**。
*
* `onKeyEvent` 看着更"底层可控",但官方文档写明:它要求**组件获焦**才触发
* (「按键事件是指组件与键盘、遥控器等按键设备交互时触发的事件,
* 适用于所有可获焦组件」)。而邮件列表里焦点落在哪是不确定的(点一下就换),
* 用 onKeyEvent 做全局快捷键会时灵时不灵。
*
* 而 `keyboardShortcut` 是「无论组件是否获焦 —— 只要窗口获焦,快捷键就会响应」。
*
* 改坏会红:把这段理由删了(后人看不到就会"顺手改成 onKeyEvent")。
*/
assert.match(mainProse, /keyboardShortcut/, '注释里要写明用的是 keyboardShortcut');
assert.match(mainProse, /未获焦|获焦窗口/, '要写明"不依赖焦点"这条理由');
assert.match(PROSE_INTENT, /两半|当初|坑/, 'ComposeIntent 要留下"为什么需要中间层"的由来');
});
/* ────────────────────────────────────────────────────────────────
* ⑤ 键盘可达的**其余三个页面**(2026-09-24 用户逐条点名)
*
* 用户原话:「还有快捷键,比如主页回车打开写信。邮件详情页回车打开回复。
* esc返回上一级」+「你在登陆页是不是没有做 enter 等键的监听」。
* ──────────────────────────────────────────────────────────────── */
test('2in1 快捷键|三个页面都用同一套键判定(不许各写一遍 KeyCode 比较)', () => {
/*
* 钉的是**单一出处**这条纪律。
*
* 三处(主页 / 详情 / 登录)都要判"是不是回车、是不是按下"。
* 各写一遍 `e.keyCode === KeyCode.KEYCODE_ENTER` 的后果早就见过:
* 本仓 `SentRow` 与 `MailRow` 那对就是"同一件事两处各写一遍、然后慢慢分叉"。
* 所以规则集中在 `model/KeyboardShortcuts.ts`,三个页面只 import。
*
* 改坏会红:任一页面改成自己比较 KeyCode(而不是走 isEnterKey)。
*/
const LOGIN = code(join(ETS, 'pages/LoginPage.ets'));
const DETAIL = code(join(ETS, 'pages/MailDetailPage.ets'));
const SHORTCUTS = code(join(ETS, 'model/KeyboardShortcuts.ts'));
assert.match(SHORTCUTS, /export function isEnterKey/, '键判定要有一个集中出处');
assert.match(SHORTCUTS, /export function isKeyDown/, '按下/抬起的判定同样要集中');
for (const [name, src] of [['主页', MAIN], ['详情页', DETAIL], ['登录页', LOGIN]]) {
assert.match(src, /from '\.\.\/model\/KeyboardShortcuts'/,
`${name} 要从 KeyboardShortcuts 引键判定(不是自己写 KeyCode 比较)`);
}
/*
* 页面级的键处理不许自己比较 ENTER —— 那是分叉的起点。
*
* ★ 只查**页面级 onKeyEvent 那一处**,不查全文件:
* 详情页里还有**输入框内的候选导航**(`onFwdKey`/`onToKey`,
* 与 ComposePage 同款),那里的 `KEYCODE_ENTER` 是"选中候选",
* 与"页面级回车=打开回复"是**两件事**,必须分开判。
* (第一版我查了全文件、判红了自己 —— 判据比事实严格也是错的。)
*/
for (const [name, src] of [['主页', MAIN], ['详情页', DETAIL], ['登录页', LOGIN]]) {
const at = src.indexOf('.onKeyEvent((e: KeyEvent)');
assert.ok(at > 0, `${name} 要有页面级 onKeyEvent`);
/* 取到该回调结束(下一个 `})` 收口)—— 页面级处理的范围 */
const body = src.slice(at, src.indexOf('})', at) + 2);
assert.ok(!/KEYCODE_ENTER/.test(body),
`${name} 的页面级键处理不许自己比较 KEYCODE_ENTER(要经 isEnterKey)`);
}
});
test('2in1 快捷键|登录页要有回车提交(WebUI 靠 <form> 天然有,我们漏了)', () => {
/*
* ── 这条钉的是一个**真实漏掉的差异** ──
* WebUI 登录页是 `<form onSubmit={submit}>`(`LoginPage.tsx:92`)——
* 浏览器里在输入框按回车就会提交表单。
* 而鸿蒙登录页**一行键盘监听都没有**(2026-09-24 用户点出来的)。
*
* 改坏会红:删掉登录页的 `.onKeyEvent`。
*/
const LOGIN = code(join(ETS, 'pages/LoginPage.ets'));
assert.match(LOGIN, /\.onKeyEvent\(/, '登录页要挂键盘监听(回车直接登录)');
assert.match(LOGIN, /this\.doLogin\(\)/,
'回车要走**已有的** doLogin(不另写一条登录路 —— 那会与按钮的条件分叉)');
assert.match(LOGIN, /isEnterKey/, '用集中的键判定,不自己比 KeyCode');
});
test('2in1 快捷键|详情页 Enter=回复 / Esc=返回,且弹层开着时 Esc 先关弹层', () => {
/*
* 用户:「邮件详情页回车打开回复。esc返回上一级」。
*
* ★ Esc 那条有个容易漏的分寸:回复条/转发条/对话树开着时,
* Esc 应当**先关弹层**,再按一次才返回 ——
* 否则用户想关掉回复框,结果被直接踢回列表,输入到一半的内容全没了。
*
* 改坏会红:把 showReplyBox/showForwardBox/showThread 那几道先关弹层的门删掉。
*/
const DETAIL = code(join(ETS, 'pages/MailDetailPage.ets'));
assert.match(DETAIL, /\.onKeyEvent\(/, '详情页要挂键盘监听');
assert.match(DETAIL, /isEscapeKey/, '要用 Esc 判定');
assert.match(DETAIL, /isEnterKey/, '要用回车判定');
assert.match(DETAIL, /openReplyWithMorph/,
'回车要走**已有的** openReplyWithMorph(与回复球同一个入口,连动画都不另开一条)');
/*
* 先关弹层:三个 @State 都要在 Esc 分支里出现。
*
* ★ 切片起点要取**页面级 onKeyEvent 内部**那个 `isEscapeKey`
* (DETAIL 里 `isEscapeKey` 先在 import 行出现 —— 从那里切会取到注释,
* 第一版就是这么判红的)。
*/
const evAt = DETAIL.indexOf('.onKeyEvent((e: KeyEvent)');
assert.ok(evAt > 0, '要有页面级 onKeyEvent');
const escAt = DETAIL.indexOf('isEscapeKey', evAt);
assert.ok(escAt > 0, '要有 Esc 判定');
const escBody = DETAIL.slice(escAt, escAt + 900);
for (const state of ['showThread', 'showReplyBox', 'showForwardBox']) {
assert.match(escBody, new RegExp(state),
`Esc 分支要先关 ${state}(弹层开着时 Esc 先关它,再按才返回)`);
}
});
test('2in1|2in1/PC 上窗口装饰必须隐掉(否则标题栏下多一条"假顶栏"=不沉浸)', () => {
/*
* ── 用户报的问题 ──
* 「app 顶栏为什么不沉浸」。实测(2in1 3120×2080 截图硬证):
* 窗口标题栏(`AgentMailHarmony`)下面**还有一条白条**,内容从第二条下面才开始。
* 对比同一台机器上的系统通知面板:标题栏与内容融为一体。
*
* ── 根因 ──
* 从未调过装饰接口 ⇒ 用系统默认(PC 形态带标题栏)。
* `setWindowLayoutFullScreen(true)` 管的是"内容铺到**屏幕**四边",
* **不包含"窗口自己的标题栏是否隐藏"** —— 两件不同的事。
*
* 改坏会红:删掉 `setWindowDecorVisible(false)`。
*/
const ABILITY = code(join(ETS, 'entryability/EntryAbility.ets'));
/*
* ★ 匹配**调用形态** `win.setWindowDecorVisible(false)`,不是裸方法名。
* 第一版写的是 `/setWindowDecorVisible\(false\)/` —— 而紧邻的两行日志
* ('setWindowDecorVisible(false) ok')也含这个串 ⇒ 删掉真正的调用后
* 判据照样绿(变异实测:没红)。这正是"判据在骗自己"的形状:
* 它匹配到的是**关于这件事的文字**,不是**这件事**。
*/
assert.match(ABILITY, /win\.setWindowDecorVisible\(false\)/,
'要显式隐掉窗口装饰(不调就是系统默认带标题栏)');
assert.match(ABILITY, /win\.setWindowLayoutFullScreen\(true\)/,
'两条是**互补**的:这条管屏幕四边、装饰那条管窗口标题栏,删任一条都会露馅');
});