/* * 跨端**纯逻辑一致性**判据:同一张用例表喂给两边,逐条比结果。 * * # 为什么需要它(用户要做的 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} 太短,可能路径写错了`); } });