Files
MailUI4Agents/client/electron/test/harmony-2in1.test.mjs
JianFeeeee 65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00

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