Files
MailUI4Agents/client/electron/test/harmony-2in1.test.mjs
JianFeeeee 125aec1191 跨端: 2in1 键盘可达(Ctrl+N 写信 / ↑↓ 换补全 / Enter 选中)+ 三处判据被实测改判
用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
「上下键切换发信目标」「回车展开输入框等」

## ① 打开发信页:Ctrl+N,用官方 `keyboardShortcut`

先查了官方文档再动手,避开两条静默失败的坑:

  · `keyboardShortcut`(组件快捷键事件,API 10+)「**无论组件是否获焦** ——
    只要窗口获焦,快捷键就会响应」。而 `onKeyEvent` 要求**组件先获焦**,
    邮件列表里焦点落在哪是不确定的(点一下就换)⇒ 用它做全局快捷键会时灵时不灵。
  · 「多个不同组件设置相同组合键 ⇒ 只响应节点树**深度最浅**的那个」。
    ⇒ 再给别处的"写信"按钮补一个 Ctrl+N 不是"多一个入口",而是**让后来那处永久失效**。
    判据因此钉"全仓只许绑一处"。

绑定位置在 `MainPage` 的**根 Stack**(窗口组件树的根)—— 绑在 FAB 上不行,
它在 `if` 分支里、窄屏/宽屏位置也不同,会随分支挂卸。

键位 `Ctrl+N` 避开了官方列出的五个**禁止绑定**组合(Alt+F4/Alt+Tab/Ctrl+Shift+Esc…)。
判据直接解析调用参数校验这三点。

## ② 根够不着 `openCompose()` ⇒ 照 `PushService` 的"两半"形状

`openCompose()` 住在**条件挂载**的 `CommPage` 上(`if (currentIndex === 0)`),
根组件拿不到它。新建 `common/ComposeIntent.ets`:

  · **格子**(`pending`)—— 用户此刻在别的页、`CommPage` 还没实例化;
  · **回调**(`listener`)—— 用户此刻就在通信页、页面早挂载完了。

缺任何一半都是**按键静默失效**:只有格子 ⇒ `aboutToAppear` 不重跑;
只有回调 ⇒ 没有监听者。这形状与 `api/PushService.ets` 处理"点通知跳转"时
踩的是同一个坑,那边注释里已写过解法 —— 这次是照着抄,不是重新踩。

## ③ 收件人三段式补全(↑↓ 切换、Enter/Tab 选中、Esc 收起)

WebUI 的逻辑原先**散在组件闭包里**(`parseParts` 没 export、`apply`/`onKeyDown`
直接改 React state)⇒ 判据根本 import 不到。先把它抽成一对纯逻辑:
`client/electron/src/lib/addressSuggest.ts` ↔ `model/AddressSuggest.ts`,
**抽的时候行为一字不改**(抽出来顺手"改进"会让判据比新行为、线上跑旧行为,两边都错)。
`AddressInput.tsx` 改接这份 lib。

`cross-client-logic` 新增 `AddressSuggest` 一对,22 个用例两边逐例比。
其中 `nextActiveIndex(0,3,-1)` 是**负下标陷阱**:JS 的 `%` 对负数返回负数
(`-1 % 5 === -1`),而负下标在数组访问里**不报错**(返回 `undefined`),
只表现为"按上键后没有任何一项高亮"。直接写 `(i-1) % n` 就会这样静默坏掉。

候选走**内联渲染**而不是 `bindPopup`/`bindMenu`:那两者各有焦点体系,
会先吃掉按键 ⇒ "↑↓ 换候选"落不到 `onToKey` 上。

## ④ 另修一个真 bug:`AddressSuggestion` 字段名整套写错

模型声明 `value`/`kind`,而服务端(`contacts.go:206`)给的是 `alias`/`title`/`source`
—— **从来没返回过** `value`/`kind`。按本仓纪律「声明了服务端从不返回的字段 ⇒ 删掉声明」改正。

顺带守住 `title` 的 `omitempty`:缺键时裸 cast 给 `undefined`(**不是**类里那个 `= ''`),
直接读会 `Cannot read property of undefined` —— 本仓在 `MailDetail.normalize()` 上
踩过同一形状(整页白屏)。判据钉住那句 `typeof … === 'string'` 的守。

## ⑤ 三处判据被**实测**改判(不是我挑一边,是拿数字定的)

### a. 卡片不该有模糊 —— 反转原断言
原判据断言「玻璃卡要走参数化 `backgroundEffect`」。那是**记录旧实现的副作用**
(重言式)。逐字读 WebUI 的 CSS:全仓 `backdrop-filter` **只有两处**
(`.glass-control` 8px/1.1、`.narrow-nav` 18px/1.5),而 `.glass-card`(`index.css:1629`)
**完全没有** —— 它的玻璃感是 `rgb(255 255 255 / 0.92)` 这个 alpha。
留着旧断言更坏:下次谁把卡片改成正确的"白 + alpha"会被判红,然后去**把模糊加回来**。

### b. 我试了"把模糊移到导航条",被实测否掉
推断「真归属是底栏」,于是把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`。
实测(模拟器窄屏 1008×2232,底栏中心列 x=504):

    y      backgroundEffect        backgroundBlurStyle
    1960   rgb(191,199,209)        rgb(234,235,239)      ← 差 -36 亮度
    2060   rgb(206,211,219)        rgb(234,235,239)      ← 差 -24

⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,底栏暗了 24~36 级。
WebUI 的 `.narrow-nav` 是**两条声明**组合的(`background-color` + `backdrop-filter`),
我只搬了后者。系统材质**同时含色调 + 模糊 + 深浅两套** ⇒ 在这里它才是正解
(§7.12 把它判成"有意差异"是对的,我的"改进"是退步)。
判据改成**反向钉住** `backgroundEffect`,并把这段实测数字留在 Theme.ets 里。

### c. 页签条判据记录的是被否掉的"胶囊"
原判据钉 `TAB_BAR_RADIUS`/`TAB_BAR_SIDE`/`TAB_BAR_TOP` —— 那正是用户否掉的形状
(「你又在内部套了一个胶囊」)。CDP 读 WebUI 的实测几何:

    .comm-pane   x=80 w=320 radius=14px overflow=hidden   ← 窗格,裁圆的是它
    tabstrip     x=80 w=320 radius=0px                    ← 条自己无圆角

设备实测(`uitest dumpLayout`):页签条 `[28,140][980,267]`、窗格 `[28,140][980,1957]`
⇒ 左右边缘逐像素相同。判据改为钉"与窗格齐平 + 只有左上角圆角"这两条**不变式**,
`TAB_BAR_RADIUS`/`TAB_BAR_SIDE` 一并**删除**(留着就是孤儿,会邀请人把胶囊拼回来)。

## ⑥ 顺带修两条判据自己的正则
`{6}` 看不见 8 位色 ⇒ 把 `glassCard`/`glassCardWall` 报成"清册过期",
**病因报错了**。改成 `{6}(?:[0-9A-Fa-f]{2})?`(不能写 `{6,8}`,那会连 7 位也放进来)。
半透明禁令改为**枚举白名单**(不是放宽:遮罩那个真实约束原样保留,
`overlay` 写成 8 位单色照样红)。

新增判据 `harmony-2in1.test.mjs` 12 条;`files=34 checks=543 pass=540 fail=2`(收尾前)。
2026-09-21 13:29:24 +08:00

323 lines
17 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` 上(菜单收不到、输入框也收不到)。
*
* 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。
*
* 改坏会红:把候选改成 `bindPopup(...)`。
*/
assert.ok(!/\.bindPopup\(/.test(COMPOSE), '不得用 bindPopup(会吃掉按键)');
assert.ok(!/\.bindMenu\(/.test(COMPOSE), '不得用 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`
* ⇒ **整页白屏**。
*
* 改坏会红:把 `typeof raw === 'string' ? raw : ''` 换回 `cands[i].title`。
*/
assert.match(
COMPOSE,
/typeof raw === 'string' \? raw : ''/,
'读 title 前必须守一道(omitempty 缺键时是 undefined,不是空串)'
);
/* 而且模型里的字段名要跟服务端一致(服务端给的是 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 要留下"为什么需要中间层"的由来');
});