Files
MailUI4Agents/client/electron/test/cross-client-logic.test.mjs
JianFeeeee d857352e29 跨端: 闭合 harmony-permission-history —— 授权栏补上「已决策的历史」
这是 `docs/DEBTS.json` 里登记的一条,它的到期条件原文是
「做『授权栏与 WebUI 对齐』时」—— 就是现在这一轮。

## 原缺口

鸿蒙的 `PermissionTab` 只调 `GET /permission/pending`(服务端
`ListPendingPermissionsFor`,SQL 带 `WHERE pr.result IS NULL`)
⇒ **只拿得到待决的**,于是"这条会话批过哪些事"完全看不到;
而 WebUI 有(`PermissionList.tsx:182` 的「历史 {n}」)。

## 关键判断:**不照抄 WebUI 的 inbox 分组**

我先把 `PermissionTab.load` 整个改成读 inbox + `groupPermissions`,
**改到一半发现行不通**(编译报 `question`/`context`/`agent_name` 找不到):

· WebUI 从 inbox 分组,但它的 `PermissionRow` **只渲染**
  `subject`/`created_at`/`permission_result`/`permission_expires_at`
  (逐字段 grep 过,全文件没有 `question`/`options`/`context`);
· 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind`
  —— 那四个字段在 `permission_requests` **表**里,
  inbox 回包(`models.Mail`)**没有它们**(模型逐条核过,只有 `permission_result`
  与 `permission_options`)。

⇒ 两条来源各有各的信息量,不是二选一:
  · **待决**继续走专用端点(信息更全、能直接决策);
  · **历史**走 inbox 补上。
  代价是每账号多一次请求 —— 这是**有意的取舍**,写在代码注释里。

(半成品已 `git checkout` 撤掉,没有把它留在提交里。
 撤掉的原因如实记在注释里,免得下一个人以为"照着 WebUI 改"就行。)

## 落地

· `model/MailGrouping.ts`:加 `groupPermissions` + `PermissionGroup` +
  `isPendingPermission`,逐条对齐 WebUI 的 `groupPermissions`,
  含它那**三步排序**(有待决的先来 → 待决多的更靠前 → 最新一封倒序)。
· `PermissionTab`:从 inbox 取 `mail_type=permission_request &&
  permission_result != ''` 的,分组后渲染「历史 n 条」(只读、不可操作)。
· 空态判据从 `requests.length === 0` 改成**两者都空**才显示 ——
  否则"有待决的历史"会被误报成"没有待决策的请求"。

## 判据自己抓到了我

`cross-client-logic.test.mjs` 的「缺口只减不增」在我补上 `groupPermissions`
之后立刻变红,并给出准确指引:

    减少(harmony 补上了功能)→ 请把 gaps 里对应的名字删掉

⇒ 已清空 `gaps`。**这条判据在这轮里三次发挥作用**:
  第一次报出这个缺口(09-20),第二次在我半成品时红了,
  第三次确认闭合。`pass=7 fail=0`。

`docs/DEBTS.json` 的 `count` 已改 0、`due` 记完成、`note` 写明修法与取舍。

## 设备验证(如实)

✓ 授权页正常渲染,进程存活(23343),无新 jscrash
✓ 空态文案正确(本机确实没有权限邮件)
✗ **"历史 n 条"真的显示出来**这条路径没能实测:
  本机没有已决策的权限请求(服务端实测 `permission_request` 0 封)。
  逻辑逐条对齐 WebUI、编译通过,但我不声称已看到它渲染。
2026-09-21 16:45:23 +08:00

462 lines
24 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']]
]
}
];
/**
* 网格投影:两边都归到 `[[[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} 太短,可能路径写错了`);
}
});