/* * 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 靠
天然有,我们漏了)', () => { /* * ── 这条钉的是一个**真实漏掉的差异** ── * WebUI 登录页是 ``(`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\)/, '两条是**互补**的:这条管屏幕四边、装饰那条管窗口标题栏,删任一条都会露馅'); }); /* ──────────────────────────────────────────────────────────────── * ⑥ 右键菜单(2026-09-24 用户选「右键菜单」) * * ★ 这不是对齐项:WebUI **没有**右键菜单(`grep onContextMenu` 全仓为空)—— * 所以它是 2in1/PC 的新增桌面能力。写明,免得日后被当成缺失项去"补"。 * ──────────────────────────────────────────────────────────────── */ test('2in1|邮件行要有右键菜单,且用系统的 RightClick(不是自己接鼠标事件)', () => { /* * 钉两条: * ① 菜单挂上了(`bindContextMenu`)—— 删掉它菜单就没了; * ② 类型是 `ResponseType.RightClick` —— 官方枚举只有 * `RightClick` / `LongPress` 两项,用错就变成"长按出菜单", * 而长按在触屏上是另一套语义(且左键单击已被"打开邮件"占用)。 * * 改坏会红:删 bindContextMenu、或把 RightClick 换成 LongPress。 */ assert.match(MAIN, /\.bindContextMenu\(/, '邮件行要挂右键菜单(bindContextMenu)'); assert.match(MAIN, /ResponseType\.RightClick/, '必须是鼠标右键(RightClick)—— LongPress 是触屏长按,语义不同'); }); test('2in1|右键菜单项只用**列表层能当场完成**的动作(不放回复/转发)', () => { /* * ★ 这条钉的是一条**判断**,不是实现细节: * 菜单项应当"点了当场就有结果"。 * * 回复/转发需要详情页的表单(收件人、正文、附件)—— * 在列表行上做只能"先跳详情再操作",那还不如直接单击那一行。 * 凑一个"点了等于跳转"的菜单项比没有更坏(用户以为在这里能完成)。 * * 所以菜单里只有三个**当场能完成**的: * · 标记已读(`MailApi.markRead`) * · 归档会话(`MailApi.archiveContact`) * · 复制主题(`pasteboard`,纯本地) * * 改坏会红:往菜单里加"回复"/"转发"项。 */ const at = MAIN.indexOf('MailContextMenu(mail: MailLike)'); assert.ok(at > 0, '菜单 Builder 要存在'); const body = MAIN.slice(at, MAIN.indexOf('@Builder', at + 10)); assert.match(body, /标记已读/, '要有「标记已读」(markRead 在列表层可独立完成)'); assert.match(body, /归档会话/, '要有「归档会话」(archiveContact 同上)'); assert.match(body, /复制主题/, '要有「复制主题」(纯本地)'); assert.ok(!/回复|转发/.test(body), '菜单里**不放**回复/转发 —— 那两个要详情页的表单,在这里做只能"先跳详情",' + '那不是菜单项该有的语义(点了当场就该有结果)'); }); test('2in1|右键菜单的破坏性动作(归档)必须先确认', () => { /* * 归档是**破坏性**的(Agent 侧会话归档 + 邮箱界面同时移除), * 而右键菜单是"点一下就发生了"—— 误触代价太大。 * 联系人页那边的归档一直有确认(`ArchiveConfirm` 弹层 / `confirmArchive`), * 列表行这条也必须有,否则两处对同一件事的分寸不一致。 * * 改坏会红:把 showAlertDialog 那层确认删掉、直接发请求。 */ const at = MAIN.indexOf('private archiveRow(mail: MailLike)'); assert.ok(at > 0, 'archiveRow 要存在'); const body = MAIN.slice(at, at + 1200); assert.match(body, /showAlertDialog|AlertDialog/, '归档要先弹确认(与联系人页同一分寸)'); /* 真正的请求在确认回调之后(另一个方法里)——确保不是"点了就发" */ assert.match(body, /doArchiveRow/, '确认的回调里才发请求'); }); /* ──────────────────────────────────────────────────────────────── * ⑦ 顶栏文案(摘要 / 一言 / 签名轮播)—— 2026-09-25 * * 用户四条: * · 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」 * · 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」 * · 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」 * · 「我要的效果是在退出,最大化,最小化三个按键的左边」 * ──────────────────────────────────────────────────────────────── */ test('2in1|顶栏文案在三键左边,且不破坏窗口沉浸', () => { /* * ★ 这条钉的是**两个约束同时成立**,因为它们会互相顶: * 要"在三键左边"最省事的做法是官方 `setWindowTitle`(实测确实能显示), * 但那个**必须保持窗口装饰可见** —— 于是标题栏那条浅色横带回来, * 与用户先前要的「顶栏沉浸」直接冲突(那是刚修好的)。 * * 所以正确解法是:**装饰仍隐藏**(沉浸保留)+ 文字自绘在装饰带原位。 * 判据必须同时看到这两件事,只看一件就会放过错误的那版。 * * 改坏会红: * · 去掉 setWindowDecorVisible(false) ⇒ 第一段红(沉浸丢了) * · 去掉自绘的 position/padding ⇒ 第二段红(文案不在三键左边) */ const ABILITY = code(join(ETS, 'entryability/EntryAbility.ets')); const MAIN = code(join(ETS, 'pages/MainPage.ets')); /* ① 沉浸必须保留(装饰隐藏) */ assert.match(ABILITY, /win\.setWindowDecorVisible\(false\)/, '窗口装饰要保持隐藏 —— 否则标题栏横带回来,与「顶栏沉浸」冲突'); /* * ② 文案自绘,且在**左边**(与三键同一行、左右相称)。 * * ★★ 这里**改过一次**:第一版我断言的是"贴住三键左缘" * (右对齐 + TOPBAR_TRIPLE_BTN_WIDTH),用户纠正: * 「我要求的是与三键同行,但是在左边啊」 * ⇒ 断言随之改成靠左。判据要跟事实走,不是给错版背书。 */ assert.match(MAIN, /\.justifyContent\(FlexAlign\.Start\)/, '文案要**靠左**(与右边三键同一行、左右相称)'); assert.match(MAIN, /\.position\(\{ x: 0, y: 0 \}\)/, '文案要绝对定位到窗口顶部(整窗根 Stack 的最上层)'); assert.match(MAIN, /padding\(\{ left: TOPBAR_STRIP_LEFT \}\)/, '左侧留出与窗口边缘的距离'); /* * ★ 垂直对齐要**用装饰带自己的高度**,不许写死 y 偏移。 * * 我第一版写 `.position({ y: 8 })`(凭感觉给的 8vp), * 实测文案中心 309.5、三键中心 316.5 —— **差 7px**(用户看出「行没有对齐」)。 * 改成 `height(windowInsets.windowDecor)` + `VerticalAlign.Center` 后 * 实测中心差 **0.0px**。 * * ⇒ 判据钉"高度取自 windowDecor"这条做法,而不是某个具体数值: * 它同时保证"带高变了也不跑偏"。 */ assert.match(MAIN, /\.height\(this\.windowInsets\.windowDecor\)/, '垂直对齐要用装饰带自己的高度(写死 y 偏移会差几个 px —— 实测过)'); assert.match(MAIN, /\.alignItems\(VerticalAlign\.Center\)/, '带内垂直居中'); }); test('2in1|顶栏文案必须是纯文字(不加底、不吃事件)', () => { /* * 用户原话:「同时注意,是纯文字不要加底」。 * * ★ 为什么这条要单独钉:给它加个底是最容易的"看起来更清楚"的冲动, * 而那条会在沉浸顶栏上再压一块色 —— 正是用户点名不要的。 * 同理不能加玻璃材质(那也会形成一块可见的面)。 * * 改坏会红:给那个 Row 加 backgroundColor / backgroundBlurStyle。 */ const MAIN = code(join(ETS, 'pages/MainPage.ets')); /* * ★ 切片锚点取**使用点**(`.position({ x: 0, y: TOPBAR_STRIP_VPAD })`), * 不是那个常量的**名字**。 * * 我第一版就用了名字 —— 而它在**文件顶部的常量区**先出现一次, * 从那里往后切会一路包进 `InboxTab`(那里有 `backgroundColor`), * 于是判据报"文案加了底色",报的是**别人的代码**。 * 本仓纪律:判据匹配到的东西必须就是它声称的那个。 */ const at = MAIN.indexOf('.position({ x: 0, y: 0 })'); assert.ok(at > 0, '顶栏文案块要存在(找不到定位语句)'); /* 往前取到该块的起点、往后取到它的收口,覆盖整块 */ const start = Math.max(0, at - 1200); const body = MAIN.slice(start, at + 900); assert.ok(body.length > 0, '要能切出顶栏文案块'); assert.ok(!/\.backgroundColor\(/.test(body), '顶栏文案不许加底色(用户:「是纯文字不要加底」)'); assert.ok(!/\.backgroundBlurStyle\(/.test(body), '顶栏文案不许加玻璃材质(同样是"加了一层可见的面")'); assert.match(body, /hitTestBehavior\(HitTestMode\.None\)/, '文案是装饰性的,不许吃掉底下的点击(三键就在它右边)'); }); test('2in1|顶栏摘要"零"时也要有文案(否则整块消失)', () => { /* * ★ 这条钉的是一个**实测撞出来的逻辑漏洞**。 * * 我第一版写的是"三项计数都是 0 就返回空串"——想当然的"有信息才显示"。 * 实测:那封邮件已读 ⇒ 三项全 0 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**, * 顶栏右上什么都没有。 * * 而"没有未读"恰恰是**常态**(收件箱清干净了)。 * * ★★ 文案改过两轮,判据跟着改(用户裁定): * 第 0 版 空串 → 整块消失(顶栏右侧空着、与三键失衡) * 第 1 版 「暂无待办」→ 补上了"零也是信息",但这句话**信息量为 0 且会误导**: * 它读起来像"没事可做",实际只表示"三个计数是 0"。 * 第 2 版 「AgentMail」(品牌名)—— 不带任何判断。 * * ⇒ 判据钉**两条**:① 兜底必须存在(别退回空串让整块消失); * ② 兜底文案**不含判断词**("暂无/没有/无"开头都是在替用户下结论)。 * * 改坏会红:把 `TOPBAR_FALLBACK` 改回 `''`,或改回 `'暂无待办'`。 */ assert.match(MAIN, /const TOPBAR_FALLBACK: string = '[^']+';/, '兜底文案要存在(退回空串会让整块消失、顶栏右侧空着)'); const fallback = MAIN.match(/const TOPBAR_FALLBACK: string = '([^']+)';/); assert.ok(fallback, '要能取到兜底文案'); assert.doesNotMatch(fallback[1], /^暂无|^没有|^无/, '兜底文案不许下判断("暂无X"是在替用户下结论,而触发条件只是计数为零)'); /* * ③ 兜底**不占轮播位** —— 它只做"唯一候选"。 * * 实测:四张连拍(3s 一张)得到 `一言 → 兜底 → 兜底 → 一言`, * 轮播位有一半被废话吃掉(那时一言只有 2 条)。 * ⇒ 摘要有真计数才配占位;兜底只在**三类都空**时才作为唯一候选出现。 * * 结构上是两半,都要钉住(少一半就退回旧行为): * a) 摘要入口有 `summaryIsReal` 守卫(假摘要不进数组) * b) 数组为空时才 push 兜底(不是无条件 push) * * 改坏会红:删掉任一半。 */ assert.match(MAIN, /const summaryIsReal: boolean = this\.navUnread > 0 \|\| this\.navPending > 0 \|\| this\.navContacts > 0;/, '摘要要有"是否为真计数"的判断'); assert.match(MAIN, /if \(summaryIsReal\) \{\s*\n\s*out\.push\(summary\);\s*\n\s*\}/, '假摘要(兜底)不许进候选数组 —— 否则它会被轮播到、占掉一半时长'); assert.match(MAIN, /if \(out\.length === 0\) \{\s*\n\s*out\.push\(TOPBAR_FALLBACK\);\s*\n\s*\}/, '兜底只在数组为空时作为唯一候选进入'); /* * ④ 一言**只显示句子**,不带出处(用户裁定)。 * * 原形状 `quote + ' —— ' + source` 的问题: * 出处占近 1/3 宽度、把句子本身挤到省略号; * 且 hitokoto 的出处格式杂(动漫角色/诗词/网名),窄带上排起来脏。 * ⇒ 与签名对齐:两者都是"当前状态的一句话",都不带出处。 * * 判据钉**两半**:① 拼装里不含 `——` + source; * ② `topQuoteSource` 这个状态整个不存在了(留着就是只写不读的死字段)。 * * 改坏会红:把拼接改回去,或把 `topQuoteSource` 状态加回来。 */ /* * ★ 这里我第一版写漏了,记下来: * 正则写成 `/this\.topQuote \+ ' —+ ' \+ this\.topQuoteSource/` —— * **只匹配单引号**。而变异时我把拼接写成双引号 `" —— "`, * 正则没命中 ⇒ 判据**假绿**(变异验证的意义就在这:它当场抓到了)。 * * ⇒ 改成**正向断言**:必须存在"把 topQuote 原样推进数组"这一句。 * 正向比反向窄,且不依赖引号风格。 */ assert.match(MAIN, /@State topQuotes: string\[\] = \[\];/, '一言要存**整批**(单个字符串装不下一批,见下条)'); assert.match(MAIN, /this\.topQuotes\.push\(q\)|out\.push\(q\);/, '一言要**逐句**进候选数组'); assert.doesNotMatch(MAIN, /this\.topQuote\b(?!s)/, '不许再有单句形态的 topQuote(它是"只取第一句"那个 bug 的载体)'); assert.doesNotMatch(MAIN, /this\.topQuoteSource/, '出处不许出现在代码里(只写不读的死字段也要删)'); /* * ⑤ **整批都要存下来** —— 只取第一句会让轮播静默失效。 * * 这是本轮最值钱的一条判据,因为那个 bug **不报错、不崩溃**: * `this.topQuote = content.quotes[0].text` ⇒ 本地只有 1 句 ⇒ * `startTopbarRotation` 的 `texts.length <= 1` 守卫直接 return ⇒ 不轮播。 * 我连拍 6 张(18s)全是同一句才发现。 * * ★ 更坏的是它是**假象**:先前看着"在转",是因为兜底文案占了位 * (一言 ↔ 暂无待办 交替);等兜底改成不占轮播位,转的其实是兜底。 * ⇒ 判据必须钉"服务端给的那一批**全都进**本地状态"。 * * 改坏会红:把赋值改回 `quotes[0]`。 */ assert.match(MAIN, /for \(let i = 0; i < content\.quotes\.length; i\+\+\)/, '要遍历服务端返回的整批(只取 [0] 会让轮播静默失效)'); assert.match(MAIN, /this\.topQuotes = qs;/, '整批赋给本地状态'); assert.doesNotMatch(MAIN, /@State topQuoteSource/, '出处状态要删干净,不留只写不读的死字段');}); test('2in1|一言与签名要在客户端缓存(账号级),离线也能轮播', () => { /* * 用户:「同时 app 本地缓存一部分」。 * * ★ 缓存键必须**账号级**(`kind + accountId`)—— 本仓纪律。 * 共用一份会让换个账号看到上一个人的签名;`user_appearance` 当初就是 * 全局键,多账号互相覆盖。 * * ★ 还要验"拉失败不致命":装饰性内容不该成为失败点 * (服务端那半也有对称的一条判据)。 * * 改坏会红:去掉 prefKey 的 accountId 拼接、或让 refresh 把异常抛出去。 */ const STORE = code(join(ETS, 'common/TopbarStore.ets')); assert.match(STORE, /private prefKey\(accountId: string\)/, '缓存键要按账号拼(共用一份会让多账号串台)'); assert.match(STORE, /KEY_PREFIX \+ accountId/, '键的实际拼法要含 accountId'); assert.match(STORE, /catch \(e\)/, '拉取/缓存失败要吞掉 —— 顶栏是装饰性的,不该把异常抛给调用方'); assert.match(STORE, /'\/me\/topbar'/, '要打服务端那个端点(/me/topbar)'); }); /* * ★★ 2026-10-01 真机实测抓到的假判据(MatePad Pro / MRDI-W00,HarmonyOS NEXT API 26)。 * * 顶部那行文案(一言 + 签名,`TopbarStore`)的渲染条件原来写的是 * `this.windowInsets.windowDecor > 0` * 本意"有装饰带才显示",在模拟器(真 2in1)上一直成立。 * * ★ 真机平板把它证伪了 —— 实测日志: * `insets: statusBar=38.588235 navIndicator=27.764706 windowDecor=37` * `param get const.product.devicetype` = **tablet** * * `getWindowDecorHeight()` 在平板上**照样返回 37vp**(官方给的是"全屏悬浮态 * 固定 37vp"这个下限值),但平板**根本没有三键区** ⇒ 条件成立、文案渲染, * 位置却按"三键在右边"算,于是浮在**系统状态栏**上(用户:与时钟/电量重叠)。 * * ⇒ `windowDecor > 0` 在平板上是个**恒为真的假信号**,不能当形态判据用。 * 形态的真值是 `deviceInfo.deviceType === '2in1'`。 * * 本条钉的就是这个:**不许再用 windowDecor 的高度/正负当"是不是 2in1"。** */ test('★★ 形态判据必须用 deviceInfo.deviceType,不许拿 windowDecor 当 2in1 的信号', () => { // ★ 用 `code()`(读时剥注释)而不是 `prose`:判据的锚要落在**代码**上, // 不是落在代码对自己的描述上 —— 否则我写进注释里的旧写法会被自己判红。 const code_ = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'MainPage.ets')); // 那行文案必须挂在 2in1 判据上。 const guard = /if \(this\.topbarTexts\(\)\.length > 0 && deviceInfo\.deviceType === '2in1'\)/.test(code_); assert.ok(guard, '顶部文案必须只在 2in1 出现:`if (topbarTexts().length > 0 && deviceInfo.deviceType === \'2in1\')`\n' + '真机实测(MatePad Pro,API 26):windowDecor=37(**非 0**)但 devicetype=tablet,\n' + '旧条件 windowDecor > 0 在平板上恒成立 ⇒ 文案浮在系统状态栏上。'); // 反向:条件里不得再用 windowDecor 的正负/高度判形态。 const bad = /windowInsets\.windowDecor\s*(>|!=|===|!==|==)\s*0/.exec(code_); assert.equal(bad, null, `别再用 ${bad ? bad[0] : 'windowDecor 与 0 比较'} 判「是不是 2in1」——\n` + '真机平板上它恒为 37(官方"全屏悬浮态固定值"),不代表有三键区。\n' + '用 deviceInfo.deviceType === \'2in1\'(与 EntryAbility 同一来源)。'); });