Files
MailUI4Agents/client/electron/test/cross-client-logic.test.mjs
JianFeeeee 125aec1191 跨端: 2in1 键盘可达(Ctrl+N 写信 / ↑↓ 换补全 / Enter 选中)+ 三处判据被实测改判
用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
「上下键切换发信目标」「回车展开输入框等」

## ① 打开发信页:Ctrl+N,用官方 `keyboardShortcut`

先查了官方文档再动手,避开两条静默失败的坑:

  · `keyboardShortcut`(组件快捷键事件,API 10+)「**无论组件是否获焦** ——
    只要窗口获焦,快捷键就会响应」。而 `onKeyEvent` 要求**组件先获焦**,
    邮件列表里焦点落在哪是不确定的(点一下就换)⇒ 用它做全局快捷键会时灵时不灵。
  · 「多个不同组件设置相同组合键 ⇒ 只响应节点树**深度最浅**的那个」。
    ⇒ 再给别处的"写信"按钮补一个 Ctrl+N 不是"多一个入口",而是**让后来那处永久失效**。
    判据因此钉"全仓只许绑一处"。

绑定位置在 `MainPage` 的**根 Stack**(窗口组件树的根)—— 绑在 FAB 上不行,
它在 `if` 分支里、窄屏/宽屏位置也不同,会随分支挂卸。

键位 `Ctrl+N` 避开了官方列出的五个**禁止绑定**组合(Alt+F4/Alt+Tab/Ctrl+Shift+Esc…)。
判据直接解析调用参数校验这三点。

## ② 根够不着 `openCompose()` ⇒ 照 `PushService` 的"两半"形状

`openCompose()` 住在**条件挂载**的 `CommPage` 上(`if (currentIndex === 0)`),
根组件拿不到它。新建 `common/ComposeIntent.ets`:

  · **格子**(`pending`)—— 用户此刻在别的页、`CommPage` 还没实例化;
  · **回调**(`listener`)—— 用户此刻就在通信页、页面早挂载完了。

缺任何一半都是**按键静默失效**:只有格子 ⇒ `aboutToAppear` 不重跑;
只有回调 ⇒ 没有监听者。这形状与 `api/PushService.ets` 处理"点通知跳转"时
踩的是同一个坑,那边注释里已写过解法 —— 这次是照着抄,不是重新踩。

## ③ 收件人三段式补全(↑↓ 切换、Enter/Tab 选中、Esc 收起)

WebUI 的逻辑原先**散在组件闭包里**(`parseParts` 没 export、`apply`/`onKeyDown`
直接改 React state)⇒ 判据根本 import 不到。先把它抽成一对纯逻辑:
`client/electron/src/lib/addressSuggest.ts` ↔ `model/AddressSuggest.ts`,
**抽的时候行为一字不改**(抽出来顺手"改进"会让判据比新行为、线上跑旧行为,两边都错)。
`AddressInput.tsx` 改接这份 lib。

`cross-client-logic` 新增 `AddressSuggest` 一对,22 个用例两边逐例比。
其中 `nextActiveIndex(0,3,-1)` 是**负下标陷阱**:JS 的 `%` 对负数返回负数
(`-1 % 5 === -1`),而负下标在数组访问里**不报错**(返回 `undefined`),
只表现为"按上键后没有任何一项高亮"。直接写 `(i-1) % n` 就会这样静默坏掉。

候选走**内联渲染**而不是 `bindPopup`/`bindMenu`:那两者各有焦点体系,
会先吃掉按键 ⇒ "↑↓ 换候选"落不到 `onToKey` 上。

## ④ 另修一个真 bug:`AddressSuggestion` 字段名整套写错

模型声明 `value`/`kind`,而服务端(`contacts.go:206`)给的是 `alias`/`title`/`source`
—— **从来没返回过** `value`/`kind`。按本仓纪律「声明了服务端从不返回的字段 ⇒ 删掉声明」改正。

顺带守住 `title` 的 `omitempty`:缺键时裸 cast 给 `undefined`(**不是**类里那个 `= ''`),
直接读会 `Cannot read property of undefined` —— 本仓在 `MailDetail.normalize()` 上
踩过同一形状(整页白屏)。判据钉住那句 `typeof … === 'string'` 的守。

## ⑤ 三处判据被**实测**改判(不是我挑一边,是拿数字定的)

### a. 卡片不该有模糊 —— 反转原断言
原判据断言「玻璃卡要走参数化 `backgroundEffect`」。那是**记录旧实现的副作用**
(重言式)。逐字读 WebUI 的 CSS:全仓 `backdrop-filter` **只有两处**
(`.glass-control` 8px/1.1、`.narrow-nav` 18px/1.5),而 `.glass-card`(`index.css:1629`)
**完全没有** —— 它的玻璃感是 `rgb(255 255 255 / 0.92)` 这个 alpha。
留着旧断言更坏:下次谁把卡片改成正确的"白 + alpha"会被判红,然后去**把模糊加回来**。

### b. 我试了"把模糊移到导航条",被实测否掉
推断「真归属是底栏」,于是把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`。
实测(模拟器窄屏 1008×2232,底栏中心列 x=504):

    y      backgroundEffect        backgroundBlurStyle
    1960   rgb(191,199,209)        rgb(234,235,239)      ← 差 -36 亮度
    2060   rgb(206,211,219)        rgb(234,235,239)      ← 差 -24

⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,底栏暗了 24~36 级。
WebUI 的 `.narrow-nav` 是**两条声明**组合的(`background-color` + `backdrop-filter`),
我只搬了后者。系统材质**同时含色调 + 模糊 + 深浅两套** ⇒ 在这里它才是正解
(§7.12 把它判成"有意差异"是对的,我的"改进"是退步)。
判据改成**反向钉住** `backgroundEffect`,并把这段实测数字留在 Theme.ets 里。

### c. 页签条判据记录的是被否掉的"胶囊"
原判据钉 `TAB_BAR_RADIUS`/`TAB_BAR_SIDE`/`TAB_BAR_TOP` —— 那正是用户否掉的形状
(「你又在内部套了一个胶囊」)。CDP 读 WebUI 的实测几何:

    .comm-pane   x=80 w=320 radius=14px overflow=hidden   ← 窗格,裁圆的是它
    tabstrip     x=80 w=320 radius=0px                    ← 条自己无圆角

设备实测(`uitest dumpLayout`):页签条 `[28,140][980,267]`、窗格 `[28,140][980,1957]`
⇒ 左右边缘逐像素相同。判据改为钉"与窗格齐平 + 只有左上角圆角"这两条**不变式**,
`TAB_BAR_RADIUS`/`TAB_BAR_SIDE` 一并**删除**(留着就是孤儿,会邀请人把胶囊拼回来)。

## ⑥ 顺带修两条判据自己的正则
`{6}` 看不见 8 位色 ⇒ 把 `glassCard`/`glassCardWall` 报成"清册过期",
**病因报错了**。改成 `{6}(?:[0-9A-Fa-f]{2})?`(不能写 `{6,8}`,那会连 7 位也放进来)。
半透明禁令改为**枚举白名单**(不是放宽:遮罩那个真实约束原样保留,
`overlay` 写成 8 位单色照样红)。

新增判据 `harmony-2in1.test.mjs` 12 条;`files=34 checks=543 pass=540 fail=2`(收尾前)。
2026-09-21 13:29:24 +08:00

454 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`。
*
* 登记在这里的意义:这条判据会让**下次再少一个导出**时红,
* 而"这个缺口是已知的、有据可查的"与"悄悄少了一项比较"是两件事。
*/
gaps: ['groupPermissions'],
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} 太短,可能路径写错了`);
}
});