Files
MailUI4Agents/client/electron/test/cross-client-logic.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

569 lines
30 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
})),
/*
* ★★ 2026-09-23 新增:`groupPermissions` 的双边投影。
*
* ── 为什么必须加这一条 ──
* 上面那段注释(原文)说 "`groupPermissions` 不是改名了……
* **已决策的授权记录在鸿蒙上完全看不到**",然后把
* `harmony-permission-history` 登记进 `gaps`。2026-09-21 鸿蒙补上了
* 这个函数,`gaps` 清空了 —— 但**从没有人把两边的 `groupPermissions`
* 真正比过一次**:`gaps: []` 只意味着"名字都在",
* 不意味着"行为一致"。
*
* 实际后果:我刚要对比时才发现,两边都在算 `path`,
* 而鸿蒙那边是**硬编码空串**(`g.path = ''`,理由写着"MailLike 没这个字段"),
* electron 是真取 `latest.session_workspace`。
* ⇒ 鸿蒙的授权栏**永远显示不出 `agent@path`**。
* 这不是"少一个导出",是**同一个函数两边算得不一样** ——
* 而现有判据的形状只能抓前者。
*
* ⇒ 投影里**保留 `path`**(不归一掉),并补上用例(见下面 cases)。
* 它现在是这条判据的守具:谁再把它写成空串,这里会红。
*/
groupPermissions: (v) => (v ?? []).map(g => ({
sessionId: g.sessionId ?? g.session_id,
alias: g.alias,
agentName: g.agentName,
path: g.path,
pending: (g.pending ?? []).map(m => m.mail_id),
settled: (g.settled ?? []).map(m => m.mail_id),
latestMailId: g.latest?.mail_id ?? null
}))
},
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' })]]],
/*
* ── groupPermissions:两边逐字段算得一样吗 ──
*
* 用例特意覆盖 `path`(会话工作目录):
* 这是它与 WebUI 真分叉过的那个字段(鸿蒙曾硬编码空串)。
* 若只拿"有权限请求"的普通用例测,两组都不报 path 差异也看不出来。
*/
['权限分组:单会话待决', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request' })]]],
['权限分组:待决 + 已决策同会话', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request' }),
mkMail({ mail_id: 'p2', mail_type: 'permission_request', permission_result: 'allow' })]]],
['★ 权限分组:path 取会话的 workspace', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request',
session_workspace: '/home/program/agentmail' })]]],
['★ 权限分组:workspace 缺失时 path 为空(不是 undefined)', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request',
session_workspace: undefined })]]],
['权限分组:非权限邮件被过滤', 'groupPermissions',
[[mkMail({ mail_id: 'm1' }), mkMail({ mail_id: 'p1', mail_type: 'permission_request' })]]],
['权限分组:空列表', 'groupPermissions', [[]]]
]
},
{
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} 太短,可能路径写错了`);
}
});