/* * 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 要留下"为什么需要中间层"的由来'); });