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