Files
MailUI4Agents/client/electron/test/cross-client-logic.test.mjs
JianFeeeee 56c58d30ad 跨端: 修我自己引入的 2 条红判据 + 3 条陈旧判据 + 补全地址补全的剩余 4 个挂点
用户两句话点破了我这轮的两个过程问题:
  ① 「你就不能把预先存在的问题修复一下?」—— 那 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 可用的写法(当前设备实测无害,留给下一轮)
2026-09-21 23:31:15 +08:00

518 lines
27 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.

/*
* 跨端**纯逻辑一致性**判据:同一张用例表喂给两边,逐条比结果。
*
* # 为什么需要它(用户要做的 B)
*
* 用户 2026-09-20:「两边纯逻辑各写一份且已分叉」—— 实际规模:
* replyTarget electron 214 行 / harmony 170 行
* mailGroups electron 178 行 / harmony 459 行
* appearance electron 187 行 / harmony 324 行
* calendar electron 208 行 / harmony 446 行
* 两份手抄的同一套规则。
*
* 当天已经**实证了两处分叉**(都是踩到才发现的):
* · `participantAddress`:electron 写 `(workspace || '').trim()`,
* harmony 写 `workspace.trim()` ⇒ 服务端 `omitempty` 缺失时 harmony 抛
* `Cannot read property trim of undefined` ⇒ **整页白屏**。
* · `ThreadPage` 的字段名整套写错(`dir`/`has_more_up` vs
* `root_mail_id`/`anchor_depth`/`has_more`/`next_offset`)⇒
* 接口 200 但界面什么都不显示。
*
* # 为什么是"跑同一张表比结果",而不是"生成一份共享源码"
*
* 生成器(像 `gen-background-takeover.mjs` 那样)看着更彻底,但有两条硬约束:
* · harmony 侧**不能** `import` 工程外的文件(实测:相对路径与软链都报
* `Could not resolve`),所以"共享源码"只能是**生成进 harmony 目录**的一份拷贝。
* · 两边的**类型系统不同**(ArkTS 禁解构/禁 `any`/对象字面量要具名类型…),
* 生成器要维护一份"两边都能过"的子集 —— 那是另一个大工程。
*
* 而**一致性判据**用很小的成本拿到最重要的性质:
* **只要两边对同一输入给出不同答案(或一边抛一边不抛),就红。**
* 它不需要我先知道哪里有 bug —— 本文件首次运行就报出两处真分叉
* (`formatAddress(undefined)` 抛 与 `monthGrid` 行数 4~6 vs 固定 6),
* 两处都是**判据先报、不是我踩到才发现**的。
*
* ⇒ 分两步走:**先用判据把分叉钉住**(本次),生成器留待真正需要时再做。
* 这与本仓既有纪律一致:「枚举挡实例,类才挡漂移」——
* 这张表枚举的是**行为**,新增一个用例就多覆盖一格。
*
* # 三类"不同"要分清(这是本文件最容易骗自己的地方)
*
* ① **命名不同**(`MailGroup.sessionId` vs `SessionGroup.session_id`):
* 不是分叉。用**语义投影**归一后比(投影只改名与挑字段,**不做计算**)。
* ② **签名不同**(`addMonths(Date,n)` vs `addMonths(y,m,delta)`):
* 不是分叉(ArkTS 没有 Date 习惯),但**语义必须一样** ——
* 用例表按侧写参数、投影归一到同一层再比。
* ③ **行为不同**(`monthGrid` 4~6 行 vs 固定 6 行):
* **是分叉**,以 electron 为准。这一处就是本判据抓出来的。
*/
import { code } from './lib/read.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const E_LIB = join(ROOT, 'client/electron/src/lib');
const H_MODEL = join(ROOT, 'client/harmony/entry/src/main/ets/model');
/** 把一个函数调用成"结果字符串" —— 抛异常本身也是一等结果(要能比出来)。 */
function callString(fn, args, project) {
if (typeof fn !== 'function') return '<不是函数>';
try {
const v = fn(...args);
const p = project ? project(v) : v;
return JSON.stringify(p) ?? 'undefined';
} catch (e) {
/*
* "哪一边抛、哪一边不抛"正是要抓的分叉(`formatAddress(undefined)` 就是)。
* 所以异常也被归一成可比较的字符串,而不是让整个判据崩掉。
*/
return `THROW:${e instanceof Error ? e.message.slice(0, 40) : String(e)}`;
}
}
/** 造一封最小可用邮件(只填被测函数真正会读的字段)。 */
function mkMail(over = {}) {
return {
mail_id: 'm1', session_id: 's1', from_name: 'dsh', to_name: 'jianf',
from_human: false, to_human: true,
session_workspace: '/home/program/agentmail', session_alias: 'fix-x',
created_at: '2026-09-14T10:00:00Z', status: 'unread', subject: '主题',
body_preview: '预览', cc_list: [], mail_type: 'normal',
permission_result: '', permission_mode: 'workspace',
source_account_id: 'a1', source_account_name: '主账号',
...over
};
}
const PAIRS = [
{
name: 'ReplyTarget',
electron: join(E_LIB, 'replyTarget.ts'),
harmony: join(H_MODEL, 'ReplyTarget.ts'),
/* 签名与返回形状两边一致 ⇒ 不需要投影,直接比 */
project: {},
gaps: ['replyAllCC', 'sessionCounterpart', 'sessionReplyTarget'],
cases: [
['agent 三段', 'formatAddress', ['dsh', '/p', 's']],
['只有名字', 'formatAddress', ['dsh', '', '']],
['人(无 path/session)', 'formatAddress', ['jianf', '', '']],
['带空格要 trim', 'formatAddress', [' dsh ', ' /p ', ' s ']],
['空名字', 'formatAddress', ['', '', '']],
/* ★ 服务端 `omitempty` 缺失时传进来的就是这个形状 —— 两边都必须不抛 */
['path/session 是 undefined', 'formatAddress', ['dsh', undefined, undefined]],
['name 是 undefined', 'formatAddress', [undefined, '/p', 's']],
['participant:人', 'participantAddress', ['jianf', true, '', '']],
['participant:Agent', 'participantAddress', ['dsh', false, '/p', 's']],
['participant:workspace 缺失', 'participantAddress', ['dsh', false, undefined, undefined]],
['participant:人 + 缺失', 'participantAddress', ['jianf', true, undefined, undefined]]
]
},
{
name: 'MailGrouping',
electron: join(E_LIB, 'mailGroups.ts'),
harmony: join(H_MODEL, 'MailGrouping.ts'),
/*
* ★ 命名投影(不是计算):唯一差别是 `MailGroup.sessionId`
* ↔ `SessionGroup.key`/`session_id` —— 类型系统各自演化的结果。
* 不归一的话会把命名差异误报成行为分叉。
*/
/*
* ★ 缺口(harmony 没有的导出)—— **如实登记,不静默忽略**。
*
* `groupPermissions` 不是"改名了":electron 拿 **inbox** 自己按会话分组、
* 同时渲染 `g.pending`(待决策)与 `g.settled`(历史)两段
* (`PermissionList.tsx:27/174/182`);而 harmony 调**专用端点**
* `GET /permission/pending`(SQL 里 `WHERE pr.result IS NULL`)——
* **只拿得到待决的,且平铺不分组**。
*
* ⇒ 用户可见差异:**已决策的授权记录在鸿蒙上完全看不到**。
* 已登记进 `docs/DEBTS.json` 的 `harmony-permission-history`。
*
* 登记在这里的意义:这条判据会让**下次再少一个导出**时红,
* 而"这个缺口是已知的、有据可查的"与"悄悄少了一项比较"是两件事。
*/
/*
* ★★ 2026-09-21 **缺口已闭合**:`groupPermissions` 原本登记在这里
* (`harmony-permission-history`:鸿蒙只调 `/permission/pending`,
* 拿不到已决策的历史)。本轮补上了:
* · 纯逻辑层加 `groupPermissions`(与 WebUI 逐条对齐,含三步排序);
* · `PermissionTab` 从 inbox 取**已决策**的权限请求并分组渲染「历史 n 条」。
* ⇒ 按这条判据自己的提示「减少 → 请把 gaps 里对应的名字删掉」,删掉它。
*/
gaps: [],
project: {
groupMailsBySession: (v) => (v ?? []).map(g => ({
sessionId: g.sessionId ?? g.session_id,
alias: g.alias,
subject: g.subject,
latestMailId: g.latest?.mail_id ?? null,
mailIds: (g.mails ?? []).map(m => m.mail_id),
unreadCount: g.unreadCount
}))
},
cases: [
['单封不成组', 'groupMailsBySession', [[mkMail()]]],
[
'同会话两封成组',
'groupMailsBySession',
[[mkMail({ mail_id: 'm1', created_at: '2026-09-14T09:00:00Z' }),
mkMail({ mail_id: 'm2', created_at: '2026-09-14T11:00:00Z', subject: '新' })]]
],
['空列表', 'groupMailsBySession', [[]]],
['单封是 flat group', 'isFlatGroup', [{ mails: [mkMail()] }]],
['两封不是 flat group', 'isFlatGroup', [{ mails: [mkMail(), mkMail()] }]],
['按权限拆分:全是普通', 'splitByPermission', [[mkMail(), mkMail({ mail_id: 'm2' })]]],
[
'按权限拆分:混一条权限请求',
'splitByPermission',
[[mkMail(), mkMail({ mail_id: 'm2', mail_type: 'permission_request' })]]
],
['拆分:空列表', 'splitByPermission', [[]]],
['未决权限计数', 'countPendingPermissions', [[mkMail({ mail_type: 'permission_request' })]]],
['未决权限计数(已决策)', 'countPendingPermissions',
[[mkMail({ mail_type: 'permission_request', permission_result: 'allow' })]]]
]
},
{
name: 'Calendar',
electron: join(E_LIB, 'calendar.ts'),
harmony: join(H_MODEL, 'Calendar.ts'),
/*
* ★ 两边**签名不同**(不是缺陷,ArkTS 没有 Date 习惯),但语义必须一样。
* 投影把两边归到"年月"与"网格里每天是哪天"这一层 —— 只做形状归一,不算日期。
*
* 注意 `monthGrid` 的 harmony 返回 `DayCell[][]`,投影里**不能**自己算
* 年月(那会引入第三份实现);它用调用时传入的 year/month 闭包捕获。
*/
project: {
addMonths: (v) => {
if (v instanceof Date) return [v.getFullYear(), v.getMonth() + 1];
return v; // harmony 已经是 [year, month]
}
},
/* monthGrid 的投影需要 y/m,改成按用例给(见下方 cases 的第 4 个元素) */
/*
* ★ 缺口(harmony 没有同名导出)—— 这里**不是"功能缺失"**,是
* **两种表示法**,必须区分清楚,否则会把"架构不同"误报成"少做了功能":
*
* electron `lib/calendar.ts`:**以 `Date` 对象为中心**
* `addDays(Date, n): Date`、`isSameDay(a, b)`、`dayKey(Date)`、
* `startOfMonth(Date)`、`weekDays(Date): Date[]` …
* harmony `model/Calendar.ts`:**以 ISO 字符串为中心**
* `addDaysIso(iso, delta): string`、`weekDaysOf(iso, ws): string[]`、
* `localIsoOf(timestamp)` …
*
* 这是 ArkTS 侧的**有意选择**(没有 `Date` 重载习惯、ISO 串做 key 更稳),
* 不是漏做。已逐条核对语义等价:
* `addDays(d,n)` ≡ `addDaysIso(iso,delta)`(都是"加减天数、跨月跨年正确");
* `weekDays(anchor)` ≡ `weekDaysOf(iso, weekStart)`(都是"含锚点的那一周 7 天")。
*
* ⇒ 登记为缺口,让判据盯着"**不要再多**":
* 若哪天 harmony 又少一个 calendar 导出,会在这里红,
* 而那多半是真的漏做(与这 17 个表示法差异不同)。
*/
gaps: [
'addDays', 'bucketByDay', 'dayKey', 'describeRecurrence', 'describeRemindBefore',
'endOfDay', 'endOfMonth', 'endOfWeek', 'fromLocalInput', 'isSameDay',
'remindAt', 'renderReminder', 'startOfDay', 'startOfMonth', 'startOfWeek',
'toLocalInput', 'weekDays'
],
/*
* 用例格式:[标签, 函数名, electron 实参数组, harmony 实参数组(可选), 网格投影键(可选)]
* 签名不同 ⇒ 前两个 `addMonths` 用例的两侧实参不一样,这正是要显式写出来的地方。
*/
cases: [
['跨月前进', 'addMonths', [new Date(2026, 0, 1), 1], [2026, 1, 1]],
['跨年前进', 'addMonths', [new Date(2026, 11, 1), 1], [2026, 12, 1]],
['跨年后退', 'addMonths', [new Date(2026, 0, 1), -1], [2026, 1, -1]],
['加 0 个月', 'addMonths', [new Date(2026, 5, 1), 0], [2026, 6, 0]],
['加 12 个月', 'addMonths', [new Date(2026, 5, 1), 12], [2026, 6, 12]],
/* ★ 1 月 31 日 + 1 月:electron 的注释说"先归到 1 号再加月"防溢出到 3 月 */
['1 月 31 日 + 1(防溢出)', 'addMonths', [new Date(2026, 0, 31), 1], [2026, 1, 1]],
/*
* 月历网格:**固定 6 行**(WebUI `grid-rows-6`)。
* ★ 这一条就是本判据抓出来的分叉现场:harmony 原来 4~6 行,
* 翻月时网格高度跳动(WebUI 的注释明写要避免)。
*/
['月历网格 2026-09(6 行的月)', 'monthGrid', [new Date(2026, 8, 1)], [2026, 9, 1], 'grid:2026,9'],
['月历网格 2026-02(4 行的月)', 'monthGrid', [new Date(2026, 1, 1)], [2026, 2, 1], 'grid:2026,2'],
['月历网格 2024-02(闰年)', 'monthGrid', [new Date(2024, 1, 1)], [2024, 2, 1], 'grid:2024,2']
]
},
{
/*
* 三维地址补全 —— 用户 2026-09-21:「上下键切换发信目标」「回车展开输入框」。
*
* 这一对是**先抽再比**:WebUI 的逻辑原先散在 `AddressInput.tsx` 的组件闭包里
* (`parseParts` 是模块级但没 export、`apply`/`onKeyDown` 直接改 React state),
* 判据根本 import 不到。所以先把纯逻辑抽到 `lib/addressSuggest.ts` 并导出,
* 鸿蒙侧写成一对的 `model/AddressSuggest.ts`。
*
* ★ 抽的时候行为**一字不改**。抽出来顺手"改进"一下是最危险的:
* 判据会去比新行为,而线上跑的仍是旧行为 —— 两边都"通过"了却都错。
*
* ★ 这一对的价值:**键盘行为可单测**。"上下键循环"这种逻辑写成组件闭包时
* 只能手动点着验;抽成纯函数后,`nextActiveIndex(0,3,-1)` 这类边界
* (回绕、空列表)才钉得住。JS 的 `%` 对负数返回负数 ——
* 直接写 `(i-1) % n` 会得到负下标,而负下标**不报错**(返回 `undefined`),
* 只表现为"按上键后没有任何一项高亮"。判据抓的就是这种静默失败。
*/
name: 'AddressSuggest',
electron: join(E_LIB, 'addressSuggest.ts'),
harmony: join(H_MODEL, 'AddressSuggest.ts'),
project: {},
gaps: [],
cases: [
/* ── parseParts:三段切分(`@` 与最后一个 `.` 为界) ── */
['三段全有', 'parseParts', ['pi@root.new']],
['只有 name', 'parseParts', ['dsh']],
['有 @ 无 .', 'parseParts', ['pi@root']],
['@ 在开头', 'parseParts', ['@root.new']],
['多个 @(取第一个)', 'parseParts', ['a@b@c.d']],
['路径带点(取最后一个)', 'parseParts', ['pi@a.b/c.sess']],
['空串', 'parseParts', ['']],
['末尾就是 .(session 为空)', 'parseParts', ['pi@root.']],
/* ── mergeCandidate:选完候选拼回完整地址 ── */
['选 name', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'name', 'dsh']],
['选 path', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'path', 'home/x']],
['选 session', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'session', 'new']],
/* ── nextActiveIndex:上下键循环 ── */
['下移', 'nextActiveIndex', [0, 3, 1]],
['下移到底回绕', 'nextActiveIndex', [2, 3, 1]],
['上移', 'nextActiveIndex', [1, 3, -1]],
['上移到头回绕(负下标陷阱)', 'nextActiveIndex', [0, 3, -1]],
['空列表', 'nextActiveIndex', [0, 0, 1]],
['单元素上移', 'nextActiveIndex', [0, 1, -1]],
/* ── queryFor:问哪一层 ── */
['没写 @ → 问 name', 'queryFor', [{ name: 'pi', path: '', session: '', hasAt: false, hasDot: false }]],
['写了 @ → 问 path', 'queryFor', [{ name: 'pi', path: 'ro', session: '', hasAt: true, hasDot: false }]],
['写了 . → 问 session', 'queryFor', [{ name: 'pi', path: 'root', session: 'n', hasAt: true, hasDot: true }]],
/* ── filterIndexes:同序过滤 + 标题参与匹配 ── */
['按别名过滤', 'filterIndexes', [['a@x.n', 'b@y.m'], ['', ''], 'a']],
['标题也参与匹配', 'filterIndexes', [['a@x.n', 'b@y.m'], ['缓存选型', '其他'], '缓存']],
['无匹配', 'filterIndexes', [['a@x.n'], [''], 'zzz']],
['空片段全保留', 'filterIndexes', [['a@x', 'b@y'], ['', ''], '']],
['candidates 比 suggestions 短', 'filterIndexes', [['a@x', 'b@y'], ['t1'], 'b']],
/*
* ── splitEditing:多地址字段只补全**最后一段** ──
*
* ★★ 2026-09-21 新增(用户:「webui 存在好几个自动填充位置,
* 比如抄送,转发等」)。
*
* 这段逻辑原先只活在 `AddressInput.tsx:38-43` 的 `useMemo` 里,
* 所以判据 import 不到、鸿蒙也没基准可抄 —— 我给鸿蒙写信页写补全时
* 就只做了单地址,**抄送框至今没有补全**。
* 移到 `lib/` + `model/` 后才比得了。
*
* 钉的四个边界全是真会踩的:
* · 单地址字段(allowMultiple=false)不得切;
* · 分号也算分隔符(中文输入法下很容易打出);
* · 逗号后的空格要 trimStart;
* · 多个逗号取**最后**一个(前面的都已成地址)。
*/
['单地址不切', 'splitEditing', ['a@x, b@y', false]],
['无分隔符', 'splitEditing', ['pi@root.new', true]],
['逗号切', 'splitEditing', ['a@x, b@y', true]],
['逗号后带空格', 'splitEditing', ['a@x, b@y', true]],
['分号也切', 'splitEditing', ['a@x;b@y', true]],
['分号+逗号取更右的', 'splitEditing', ['a@x, b@y;c@z', true]],
['连续分隔符', 'splitEditing', ['a@x,,b@y', true]],
['末尾就是逗号', 'splitEditing', ['a@x,', true]],
['空串', 'splitEditing', ['', true]],
/*
* ── normalizeCandidates:`omitempty` 缺键守卫 ──
*
* 这是本仓撞过的真坑:`SessionCandidate.title/source/unread`
* 都带 `omitempty` ⇒ 缺键时裸 cast 得到 `undefined`,
* 而类里那个 `= ''` **不会生效**。曾因此抛 `TypeError`。
*
* 进用例表是为了让"鸿蒙写一份、electron 写一份"时
* 两份守卫的边界**逐例一致**。
*/
['全正常', 'normalizeCandidates', [['a@x', 'b@y'], ['标题1', '标题2'], ['mail', 'platform'], [1, 0]]],
['title 缺键', 'normalizeCandidates', [['a@x'], [undefined], [''], [0]]],
['unread 缺键', 'normalizeCandidates', [['a@x'], [''], [''], [undefined]]],
['candidates 比 suggestions 短', 'normalizeCandidates', [['a@x', 'b@y'], [''], [''], [0]]],
['全空', 'normalizeCandidates', [[], [], [], []]],
/*
* ── filterSets:同序过滤四个数组 ──
*
* 收掉 `filterIndexes` + 四次 `pickBy` 的样板。
* 手写三遍就会有一处忘同步(四个数组长短不一就会错位)。
*/
['按片段过滤', 'filterSets',
[{ items: ['a@x.n', 'b@y.m'], titles: ['', ''], sources: ['', ''], unreads: [0, 0] }, 'a']],
['无匹配', 'filterSets',
[{ items: ['a@x.n'], titles: [''], sources: [''], unreads: [0] }, 'zzz']],
['空片段全保留', 'filterSets',
[{ items: ['a@x', 'b@y'], titles: ['t1', 't2'], sources: ['mail', 'platform'], unreads: [1, 2] }, '']]
]
}
];
/**
* 网格投影:两边都归到 `[[[y,m,d], …7], …6行]` —— **用两种格式都自带的
* "这一格是哪一天"信息**,不由投影推算日期。
*
* ★ 曾经写错过一次,值得留档:
* 第一版把 harmony 的格子按 `c.inMonth ? [y,m,c.day] : null` 处理 ——
* 于是"**邻月格子填不填真实日期**"这个差别被投影抹成了 `null`,
* 两端看起来一样。**但那个差别正是真分叉**(WebUI 填真日期并置灰、可点,
* 鸿蒙原来留空白格)。
*
* 投影里读 `iso`(两端都有这一格的完整日期)才是对的:
* 投影只做"换一种表示",不做"要不要看这一维"的判断。
*/
function gridProject() {
return (v) => {
/* electron:`Date[]`(42 个)→ 按 7 列折行 */
if (Array.isArray(v) && v.length > 0 && v[0] instanceof Date) {
const flat = v.map(d => [d.getFullYear(), d.getMonth() + 1, d.getDate()]);
const rows = [];
for (let i = 0; i < flat.length; i += 7) rows.push(flat.slice(i, i + 7));
return rows;
}
/*
* harmony:`DayCell[][]` —— 读格子的 `iso`(`YYYY-MM-DD`)。
* 用 `iso` 而不是 `[year, month, day]` 拼:`iso` 是格子**自己**的日期,
* 邻月日期的年月与格子的 `year`/`month` 参数**不同**,拿参数拼会算错。
*/
return (v ?? []).map(row => row.map(c => {
const parts = String(c.iso ?? '').split('-');
if (parts.length !== 3 || parts[0] === '') return null;
return [Number(parts[0]), Number(parts[1]), Number(parts[2])];
}));
};
}
for (const pair of PAIRS) {
test(`跨端纯逻辑一致|${pair.name}:同一张用例表两边结果必须相同`, async () => {
const E = await import(pathToFileURL(pair.electron).href);
const H = await import(pathToFileURL(pair.harmony).href);
const rows = [];
const diffs = [];
for (const c of pair.cases) {
/*
* 用例的四个位置:
* [0] 标签 [1] 函数名 [2] electron 的实参数组 [3] harmony 的实参数组(可选,缺省同 [2])
* [4] 网格投影键 'grid:Y,M'(可选,仅 monthGrid 用)
*
* ★ 这里必须**展开** `argE`(`fn(...argE)`)而不是把它当单个参数 ——
* 第一版我传的是 `args[0]`,于是 `formatAddress('dsh',…)` 变成了
* `formatAddress('d','s','h')`(字符串被当作可迭代对象展开)——
* 判据自己造出了假分叉。**判据的 bug 与代码的 bug 一样危险**:
* 它会把正确的实现报成错的,然后我就去"修"一个不存在的问题。
*/
const [tag, fn, argE] = c;
const argH = c.length > 3 && Array.isArray(c[3]) ? c[3] : argE;
const grid = c[4];
let pe = pair.project[fn];
let ph = pair.project[fn];
if (typeof grid === 'string' && grid.startsWith('grid:')) {
const [gy, gm] = grid.slice(5).split(',').map(Number);
/*
* 周起始日 = **周一**(`1`)
* · electron:`startOfWeek` 硬编码周一起(`dow===0 ? 6 : dow-1`),
* 表头也写死 `['一','二',…,'日']`
* · harmony :`CalendarPage` 的 `WEEK_START = 1`
* ★ 我第一版在这里传 `0`(周日),于是判据报"两端网格不一致" ——
* **那是判据自己的 bug,不是代码的**。差一点就去"修"一个不存在的问题。
* (教训:判据报差异时,先确认**输入真的相同**,再怀疑实现。)
*/
pe = gridProject();
ph = gridProject();
}
const e = callString(E[fn], argE, pe);
const h = callString(H[fn], argH, ph);
rows.push({ tag, fn, e, h });
if (e !== h) {
diffs.push(` · ${fn}/${tag}\n electron: ${String(e).slice(0, 300)}\n harmony : ${String(h).slice(0, 300)}`);
}
}
assert.ok(rows.length > 0, `${pair.name} 的用例表不能是空的(空表会假装通过)`);
assert.deepEqual(diffs, [],
`${pair.name} 两端对同一输入给出了不同结果(这就是"分叉"):\n${diffs.join('\n')}\n` +
'以 electron 为准(用户定的方向:electron 是唯一真实源泉)');
/*
* 自检:这张表**真的在比**吗?
* 造一个"故意不一样"的假 harmony,确认差异会被抓出来 ——
* 否则"没有差异"与"比较逻辑坏了"给出同样的绿,那是判据最容易骗自己的地方。
*/
const firstFn = pair.cases[0][1];
const FAKE = { ...H, [firstFn]: () => '总之不一样' };
const fakeDiff = [];
for (const c of pair.cases) {
const [, fn, argE] = c;
const argH = c.length > 3 && Array.isArray(c[3]) ? c[3] : argE;
if (callString(E[fn], argE, pair.project[fn]) !== callString(FAKE[fn], argH, pair.project[fn])) {
fakeDiff.push(c[0]);
}
}
assert.ok(fakeDiff.length > 0,
`自检失败:把 ${firstFn} 换成恒定返回值的假实现,竟然一条差异都没报 —— 说明这张表没在真的比`);
});
}
test('跨端纯逻辑一致|覆盖面自检:几个关键边界**必须在表里**', () => {
/*
* 判据最容易烂的方式是"表被删到只剩好走的几条"。
* 这里钉住几条**必须有**的边界 —— 尤其是那个真实造成白屏的形状
* (`omitempty` 缺失 ⇒ `undefined`),它是本表存在的理由。
*/
const rt = PAIRS.find(p => p.name === 'ReplyTarget').cases.map(c => `${c[1]}|${c[0]}`);
assert.ok(rt.some(x => x.startsWith('formatAddress|path/session 是 undefined')),
'「path/session 是 undefined」这条必须在 —— 它就是当初白屏崩溃的形状');
assert.ok(rt.some(x => x.includes('participantAddress|participant:workspace 缺失')),
'「participantAddress 遇到缺失 workspace」必须在 —— 那是那个 || 兜底分叉的位置');
const cal = PAIRS.find(p => p.name === 'Calendar').cases.map(c => c[0]);
assert.ok(cal.some(x => x.includes('闰年')), '闰年 2 月必须在 —— 日期逻辑最容易差一天的地方');
assert.ok(cal.some(x => x.includes('跨年')), '跨年必须在');
assert.ok(cal.filter(x => x.includes('月历网格')).length >= 2,
'月历网格至少要两个不同行数的月份 —— 那一处分叉正是"行数不固定"');
});
test('跨端纯逻辑一致|缺口只减不增(harmony 缺的导出要登记)', async () => {
/*
* 一致性表只能比**两侧都有**的导出。剩下的是**功能缺口**:
* `ReplyTarget` 里 harmony 少了 `sessionCounterpart` / `sessionReplyTarget` /
* `replyAllCC`(electron 有)。
*
* 这里把缺口集合钉住:**允许减少、不允许增加**。
* 不钉的话,下次再删一个 harmony 导出,一致性表会**安静地少比一项**,
* 而所有判据依然全绿 —— 那是最危险的一种"绿"。
*/
for (const pair of PAIRS) {
const E = await import(pathToFileURL(pair.electron).href);
const H = await import(pathToFileURL(pair.harmony).href);
const missing = Object.keys(E).filter(k => !(k in H)).sort();
assert.deepEqual(missing, [...pair.gaps].sort(),
`${pair.name} 两侧导出缺口的集合变了。\n` +
'减少(harmony 补上了功能)→ 请把 gaps 里对应的名字删掉;\n' +
'增加(harmony 又少了一个导出)→ 那是一处新的功能缺口,要登记并说明为什么。');
}
});
test('跨端纯逻辑一致|两个对照文件都不是空的(防止"比了个寂寞")', () => {
for (const pair of PAIRS) {
assert.ok(code(pair.harmony).length > 800, `${pair.harmony} 太短,可能路径写错了`);
assert.ok(code(pair.electron).length > 800, `${pair.electron} 太短,可能路径写错了`);
}
});