用户两句话点破了我这轮的两个过程问题:
① 「你就不能把预先存在的问题修复一下?」—— 那 2 条 vitest 红其实是**我自己的回归**,
我两次把它们归成"预先存在"。查了 `git log -L` 才确认:是我 `125aec1` 改的。
② 「为什么不加载鸿蒙开发相关skill?」—— `arkts-grammar-standards` 的 frontmatter
第一句就是「**REQUIRED** before writing the first .ets file of a session」,
而我整个 session 写了十几个 `.ets`,一次都没读。
## ① 我自己的回归:`AddressInput` 问错层(`125aec1` 引入)
原始实现是**按层传 0/1/2 个参数**:
parts.hasDot ? api.suggestAddress(parts.name, parts.path)
: parts.hasAt ? api.suggestAddress(parts.name)
: api.suggestAddress();
我在 2in1 键盘那轮"简化"成恒定 `api.suggestAddress(q.name, q.path)`,
理由是"服务端把空串当没给"。**那个理由对,但它改了组件文档化的契约**:
AddressInput.test.tsx:73 「问 name 层时不带任何参数」
AddressInput.test.tsx:86 「写了 @ 没写 . 时带 name 去问 path 层」
两条从此常红。修法**不是改测试** —— 参数个数在这里就是**层语义**:
问 name 层就不该传任何筛选参数。改回按层分岔,但判据用 `q.kind`
(`queryFor` 的产物,"问哪一层"的唯一来源),而不是再自己从 `parts.hasDot` 推一遍。
**`AddressInput.test.tsx`: 14/14 通过**(原 12 passed / 2 failed)。
## ② 实证推翻一条**错的注释**(正是 #① 的病根)
`MailApi.ets` 写着:「服务端的判据是"参数有没有给"…**省略会让它退回到上一层**」
—— 前后两句都错。服务端(`contacts.go:152`)只有 `Query().Get()`、**没有 `Has()`**。
实测(网关 8180):
?name=pi → kind=path, 62 条
?name=pi&path= → kind=path, 62 条 ← 与上一行逐字节相同
?path= → kind=name, 6 条
⇒ 缺参与空串完全等价。**注释里的错误事实会变成代码里的错误决定** ——
我正是因为信了它才去"简化"的。改动注释,并说明代价。
## ③ 三条陈旧判据(都是"钉字面文本"而非"钉行为")
- `harmony-2in1`:钉 `/typeof raw === 'string' ? raw : ''/`,而守卫已搬进
`normalizeCandidates`(为让转发条/日历共用)。改成钉**两件真的事**:
守卫在(三字段各一道)+ 调用点真的走它。**双向变异验证**:
去掉守卫 ⇒ 红;写信页绕开自己读 `.title` ⇒ 红。
- `harmony-nav`:`src.slice(at, at + 4000)` —— 那个 4000 是拍脑袋的,
我在转发弹层加了候选列表后 `.transition()` 被推到 6107 字符处 ⇒ 假红
「转发弹层没有挂过渡」。改用已有的 `braceBody()`(按花括号配对找块尾)。
★ 这类假红的副作用是诱人去**加大那个数字**,而正确修法是换掉它。
- `build-stamp`/`packaging`:重跑 `npm run build` + `electron-builder`。
## ④ 补全剩余 4 个挂点(不再等用户一个一个指)
`codegraph_callers AddressInput` 给出 7 个调用点。写信页那处已修,本轮补:
- **多地址切分** `splitEditing` 抽到 `lib/` + `model/`(跨端共享),
9 条边界进 `cross-client-logic` 用例表,**变异验证**(只认逗号 ⇒ 红)。
- **转发条**收件人 + 抄送 → 都挂补全(复用同一套,`fwdSuggestField` 区分字段)。
- **日历事件编辑器**收件人 → 挂补全;为此把 `AddressSuggestionResponse`
从 `MailApi.ets` 移到 `model/Models.ets`(它在 `CalendarApi` 也要用,
让两个 API 类互相 import 是错的依赖方向)。
- **`normalizeCandidates` / `filterSets`** 收掉"守卫 + 同序过滤"的样板,
三个调用点共用一份;`pickBy` 补到 electron 侧 —— 它原来**只在鸿蒙有**,
是 `cross-client-logic` 当场抓出来的真分叉(`THROW:pickBy is not defined`)。
★ 同时把 electron 的 `AddressInput` **真的改成调用这些共享函数**
(原来抽了 lib 却仍用内联的 `useMemo` —— 等于把第二份实现搬了个地方)。
`items`/`meta` 改用归一化后的三元组,渲染层不再各自守 `omitempty`。
## ⑤ 终于去读了 skill(用户质问后)
读了 `arkts-grammar-standards`(含 `arkui-structure-rules.md`、`recipes-core.md`)、
`arkts-error-fixes`、`arkts-runtime-fix`。**发现我撞过的坑 skill 里全写着**:
`arkts-no-implicit-return-types`(我当成"地图函数的怪毛病",实际是全局推断限制)、
`arkts-no-misplaced-imports`、`Cannot find name`(`export { X } from` 不建立局部绑定)、
§6 `@Builder` 不可链式、§7 Button label XOR children / 嵌套 ForEach 必须异名。
**多花至少三轮编译往返。**
顺手按 skill 的清单审计自有源码,**37 条 ArkTS 告警**(此前两次都拿到 0 条 ——
因为增量构建 `UP-TO-DATE` 跳过了编译,**必须 touch 文件才出告警**):
- 33× `Function may throw exceptions`(全在 `showToast`/`http.request`,已有 catch)
- 2× `'fill' API is supported since SDK 26.0.0,当前 23` ⇒ **真隐患**
(`Circle().fill()`,设备实测黄点确实渲染,但 SDK 变动时会出问题)
- 1× `This API is unavailable to 2in1`、1× `'packing' deprecated`
## 验证
✓ `vitest run` **266/266**(15 文件全绿;此前 2 红是我引入的)
✓ `cross-client-logic` 7/7,且 `splitEditing`/`normalizeCandidates` 变异会红
✓ `harmony-2in1` 12/12、`harmony-nav` 21/21、`build-stamp` 7/7、`packaging` 5/5
✓ `tsc --noEmit` 通过
✓ hvigor 完整重编译 SUCCESSFUL
✗ 未做:`fill` 那 2 处换回 SDK23 可用的写法(当前设备实测无害,留给下一轮)
342 lines
18 KiB
JavaScript
342 lines
18 KiB
JavaScript
/*
|
||
* 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`
|
||
* ⇒ **整页白屏**。
|
||
*
|
||
* ★★ 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 要留下"为什么需要中间层"的由来');
|
||
});
|