From 5e4a1b616cda6e1c050454b1c3b249cbf939b24e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 20 Sep 2026 22:35:35 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20B=20=E7=9A=84=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E7=89=A9=EF=BC=88=E8=B7=A8=E7=AB=AF=E7=BA=AF=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E4=B8=80=E8=87=B4=E6=80=A7=E5=88=A4=E6=8D=AE=EF=BC=89?= =?UTF-8?q?+=20=E7=85=A7=20skill=20=E5=9B=9E=E6=89=AB=E4=BF=AE=E6=8E=89?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E9=9A=90=E5=BD=A2=E5=80=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「你为什么不加载鸿蒙开发相关skill?」—— 说得对。那份 `arkts-grammar-standards` 写着 "REQUIRED before writing the first .ets file of a session",而我这轮一直在写 `.ets`。补加载后照它的规则表**逐条回扫**, 当场抓出两处此前没人管的违规。 ══ ① 用户要做的 B:`cross-client-logic.test.mjs`(新,6 条判据) 背景:两套纯逻辑各写一份且已分叉(replyTarget 214/170 行、mailGroups 178/459、 appearance 187/324、calendar 208/446)。当天已**踩到**两处分叉 (`participantAddress` 的 `||`、`ThreadPage` 字段全错)。 做法:**同一张用例表喂给两边,逐条比结果**(`--experimental-strip-types` 直接在 node 里跑两侧源码 —— 两边的 model 层都是纯逻辑、无 SDK 依赖)。 不选"生成一份共享源码":harmony 不能 import 工程外文件, 且两边类型系统不同(ArkTS 禁解构/any/对象字面量要具名类型), 生成器要维护"两边都能过"的子集,是另一个大工程。 ★ **首轮运行就报出两处真分叉,都不是我踩到才发现**: ① `formatAddress('dsh', undefined, undefined)`:electron 返回 `"dsh"`, harmony **抛** `Cannot read properties of undefined`。 —— 又是 `omitempty` 那个坑(**第三次**),这次是判据先报的。 ② `monthGrid`:electron **固定 6 行**(`grid-rows-6`),harmony **4~6 行** ⇒ 翻月时网格高度跳动。WebUI 的注释明写要避免这个("行数变化会让整个 网格高度跳动,翻月时页面内容上下弹")。 ③ 顺着 ② 又发现:WebUI 邻月格子**填真实日期并置灰、可点** (`CalendarView.tsx:545-556`),harmony 留**空白格**。 ★ 判据自身的两次错,都留了档(判据的 bug 与代码的 bug 一样危险): · 第一版把 `args[0]` 当单个参数传,字符串被当可迭代对象展开 ⇒ `formatAddress('d','s','h')` —— **判据自己造出假分叉**。 · 第一版 `weekStart` 传 0(周日),而两端实际都是 1(周一)⇒ 又一处假分叉。 差一点就去"修"一个不存在的问题。 · `monthGrid` 的投影第一版按 `inMonth ? [y,m,day] : null`, 把"邻月填不填真日期"这个**真分叉**抹平了 —— 投影只该换表示,不该替我看不看。 ★ 三类"不同"要分清(写进文件头):**命名不同**(投影归一,不是分叉)、 **签名不同**(ArkTS 没 Date 重载习惯;语义必须一样)、**行为不同**(是分叉,以 electron 为准)。 ══ ② skill 回扫抓出的两处隐形债(编译器只告警、判据也不管) · **正则字面量**(`arkts-no-regexp-literals`):`MailDetailPage.ets:615` 的 `/^\d+$/`(从 2026-09-19 活到今天)。 · **废弃的全局 `router`**:`api/Logout.ets:76` 的 `router.replaceUrl(...)`。 它是个独立函数(没有 `this`)⇒ 拿不到 `UIContext`,改成由调用方传 (两个调用点都持有 `getUIContext()`,零成本)。 ★ 这两条为什么能活这么久:**编译器对它们只告警、不挡构建**, 全仓也**没有判据**管 ⇒ 规则事实上不存在。已补两条判据,都做了变异验证。 ══ ③ 顺带修正一条**恒真的同义反复**断言 `harmony-calendar` 里 "today 不在本月:不许标在别的月" 那条: 它是在"邻月格子是空 `DayCell`(`iso` 为空串)"时写的 ⇒ `c.iso === today` **永远不可能**匹配 ⇒ `count === 0` 恒真,**看起来守着一条规则,其实什么都没守**。 改成真不变量:**"被标为今天的那一格,iso 必须就是 today;至多一格"**, 并反向核对"2026-10-01 确实出现在 9 月网格里"(否则那段是空转)。 实测 WebUI `CalendarView.tsx:547` 是逐格 `isSameDay` ⇒ **它会标**, 所以原来那条"不许标"本身就窄了一半。 ══ ④ 登记两处盘点发现(**没有**顺手改,因为需要人决定) · `harmony-dead-pages`:`InboxPage.ets`(238 行) 不可达(不在页面表、无人导航), `SessionsPage.ets`(170 行) 唯一引用来自 InboxPage ⇒ 一起不可达。 没删是因为 `HARMONY-ALIGN-PLAN.md:214` 把它当变异测试靶子用过 —— 删掉会永久丢代码,是否只是"早期留存"我判断不了。 · `harmony-permission-history`:WebUI 授权栏显示**待决 + 已决策历史**两段 (拿 inbox 自己分组,`PermissionList.tsx:27/174/182`);鸿蒙调专用端点 `/permission/pending`(SQL `WHERE pr.result IS NULL`)⇒ **只拿得到待决的**。 已在 `cross-client-logic` 的 gaps 里如实登记,判据会盯着"不要再少"。 ══ 判据状态 `files=33 ran=33 checks=530 pass=530 fail=0 skip=0 red=0 broken=0 unreported=0`; `baseline=7/7✓`(底本第 9 次重算,已按规矩先 `git diff --quiet HEAD` 取证 + 记录理由)。 `verdict=red` 残余仍是 5 条静态判据的**设备到期提示**(既有机制)。 ══ 环境 模拟器昨天起卡死(hdc 能连、shell 超时、CPU 150%、跑了 34 小时), 导致设备判据各跑 836 秒后失败 —— 看起来像"套件卡死"。用户批准后杀掉重启 (`Emulator -start HATriple -noWindow`,`devecocli` 那套因 x11 起不来), 现在**75~150 秒**跑完整套。 --- .../electron/test/cross-client-logic.test.mjs | 393 ++++++++++++++++++ client/electron/test/harmony-arkts.test.mjs | 85 ++++ .../electron/test/harmony-calendar.test.mjs | 38 +- client/electron/test/mutants/baseline.sha | 12 +- client/electron/test/run-all.mjs | 10 +- .../harmony/entry/src/main/ets/api/Logout.ets | 33 +- .../entry/src/main/ets/model/Calendar.ts | 69 ++- .../entry/src/main/ets/model/ReplyTarget.ts | 25 +- .../src/main/ets/pages/MailDetailPage.ets | 16 +- .../entry/src/main/ets/pages/MainPage.ets | 2 +- .../entry/src/main/ets/pages/SettingsPage.ets | 2 +- docs/DEBTS.json | 16 + 12 files changed, 681 insertions(+), 20 deletions(-) create mode 100644 client/electron/test/cross-client-logic.test.mjs diff --git a/client/electron/test/cross-client-logic.test.mjs b/client/electron/test/cross-client-logic.test.mjs new file mode 100644 index 0000000..7f1f95b --- /dev/null +++ b/client/electron/test/cross-client-logic.test.mjs @@ -0,0 +1,393 @@ +/* + * 跨端**纯逻辑一致性**判据:同一张用例表喂给两边,逐条比结果。 + * + * # 为什么需要它(用户要做的 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'] + ] + } +]; + +/** + * 网格投影:两边都归到 `[[[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} 太短,可能路径写错了`); + } +}); diff --git a/client/electron/test/harmony-arkts.test.mjs b/client/electron/test/harmony-arkts.test.mjs index 1ccdbd5..2e8ace3 100644 --- a/client/electron/test/harmony-arkts.test.mjs +++ b/client/electron/test/harmony-arkts.test.mjs @@ -153,9 +153,41 @@ test('ArkTS 词汇层硬坑:全仓 .ets 不许出现解构 / any / unknown / if (/\bany\b/.test(src)) hits.push('any'); if (/\bunknown\b/.test(src)) hits.push('unknown'); if (/\bfunction\s*\(/.test(src)) hits.push('函数表达式'); + /* + * ★★ 2026-09-20 补:**正则字面量**(`arkts-no-regexp-literals`)。 + * + * 这条是照 `arkts-grammar-standards` skill 的规则表逐条回扫时发现的缺口: + * · 编译器对它是**告警级**(不挡构建),所以 build 一直是绿的; + * · 全仓也没有判据管它 ⇒ `MailDetailPage.budgetDraftValid()` 里那个 + * `/^\d+$/` 从 2026-09-19 一直活到今天。 + * 「编译器只告警 + 判据不管」= 这条规则在本仓**事实上不存在**。 + * + * 判法要**避开**三类合法的 `/`: + * ① 注释(先剥掉) + * ② 除法 `a / b`(前面是标识符/数字/`)`,且后面紧跟空格或标识符) + * ③ 字符串里的路径(`'../model/X'`) + * 所以只认这个形状:`/` 前面是**行首/`=`/`(`/`,`/`return`/`:`**, + * 且到下一个 `/` 之间不含空白与引号 —— 那是正则字面量的典型写法。 + */ + const noComment = src; + const reLike = /(?:^|[=(,:]|return)\s*\/(?![/*=\s])(?:[^/\n'"\\]|\\.)+\/[gimsuy]*/gm; + if (reLike.test(noComment)) hits.push('正则字面量(用 new RegExp)'); if (hits.length) bad.push(`${rel(f)}:${hits.join('、')}`); } assert.deepEqual(bad, [], `★ ArkTS 硬坑(编译不过):\n ${bad.join('\n ')}`); + + /* + * 自检:正则字面量那一段探测器要**认得错、不误伤对**。 + * 不写自检的话,"没报"与"探测器坏了"结果一样 —— 那正是这条规则 + * 躲过一轮的原因(它此前根本没人探)。 + */ + const probeRe = (t) => /(?:^|[=(,:]|return)\s*\/(?![/*=\s])(?:[^/\n'"\\]|\\.)+\/[gimsuy]*/gm.test(t); + assert.equal(probeRe('return /^\\d+$/.test(t);'), true, '要抓得住 return 后的正则字面量'); + assert.equal(probeRe('const ok = /ab/.test(s);'), true, '要抓得住赋值后的正则字面量'); + assert.equal(probeRe('return new RegExp(\'^[0-9]+$\').test(t);'), false, '不许误伤 new RegExp'); + assert.equal(probeRe('const half = total / 2;'), false, '不许误伤除法'); + assert.equal(probeRe("import { X } from '../model/Y';"), false, '不许误伤字符串里的路径'); + assert.equal(probeRe('const p = apiBase + path; // a / b'), false, '剥注释后的行内斜杠不误伤'); }); /* ─────────────────── 图标:不许用 Unicode 符号,且尺寸不许直接加在 AmIcon 上 ─────────────────── */ @@ -496,3 +528,56 @@ test('★ 服务端「只以 omitempty 形式出现」的字段:客户端读 assert.equal(probe('const n = mail.attachments.length;', 'attachments'), 1, '要抓得住裸读'); assert.equal(probe('const n = (mail.attachments ?? []).length;', 'attachments'), 0, '不许误伤兜底写法'); }); + +test('★ 不许用已废弃的全局 API(`router` / `promptAction` / `animateTo` / `vp2px`)', () => { + /* + * ★★ 2026-09-20 加。这条是照 `arkts-grammar-standards` skill 的规则表 + * **逐条回扫**时发现的: + * + * · 编译器对它们只**告警**、不挡构建 ⇒ `hvigorw assembleHap` 一直全绿; + * · 本仓也**没有判据**管它们; + * ⇒ 这些规则在本仓**事实上不存在**,靠"记得别写"维持。 + * + * 实际抓到的:`api/Logout.ets:76` 的 `router.replaceUrl(...)` 全局调用 + * (从 2026-09-19 一直活到今天)。已改成把 `UIContext` 传进 `performLogout` + * 再走 `ui.getRouter()`。 + * + * 为什么值得管:这些是**废弃** API,SDK 升级时是最先被移除的一批; + * 而且它们绕开了 `UIContext`,在多实例/多窗口场景下语义是错的。 + * + * 判法:找**裸用**(前面不是 `.` 也不是标识符字符),且排除注释与 + * `this.getUIContext()` / `ui.getRouter()` 这类**正确**写法 —— + * 正确写法里这些词出现在 `getXxx()` 的方法名位置,前面有 `.`。 + */ + const DEPRECATED = [ + // [正则, 说明] + [/(^|[^.\w$])router\.(pushUrl|replaceUrl|back|getParams|clear)\s*\(/m, '全局 router.*(用 this.getUIContext().getRouter() 或 ui.getRouter())'], + [/(^|[^.\w$])promptAction\.\w+\s*\(/m, '全局 promptAction(用 this.getUIContext().getPromptAction())'], + [/(^|[^.\w$])animateTo\s*\(/m, '全局 animateTo(用 this.getUIContext().animateTo())'], + [/(^|[^.\w$])(vp2px|px2vp)\s*\(/m, '全局 vp2px/px2vp(用 this.getUIContext().vp2px(),或 display 密度换算)'] + ]; + const bad = []; + const walkDir = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) { walkDir(full); continue; } + if (!/\.ets$/.test(e.name)) continue; + const src = code(full); // 先剥注释,免得注释里的说明被当成违规 + for (const [re, msg] of DEPRECATED) { + if (re.test(src)) bad.push(`${e.name}:${msg}`); + } + } + }; + walkDir(ETS_ROOT); + assert.deepEqual(bad, [], + `这些地方用了已废弃的全局 API(编译器只告警、不挡构建):\n ${bad.join('\n ')}`); + + // 自检:探测器要认得裸用,也要放过正确写法 + const hit = (t, i) => DEPRECATED[i][0].test(t); + assert.equal(hit('router.replaceUrl({ url: x });', 0), true, '要抓得住全局 router 裸用'); + assert.equal(hit('ui.getRouter().replaceUrl({ url: x });', 0), false, '不许误伤 ui.getRouter()'); + assert.equal(hit('this.getUIContext().getRouter().back();', 0), false, '不许误伤 this.getUIContext()'); + assert.equal(hit('await this.getUIContext().animateTo({}, () => {});', 2), false, '不许误伤 ui.animateTo'); + assert.equal(hit('animateTo({}, () => {});', 2), true, '要抓得住全局 animateTo'); + assert.equal(hit('const x = this.getUIContext().vp2px(24);', 3), false, '不许误伤 ui.vp2px'); +}); diff --git a/client/electron/test/harmony-calendar.test.mjs b/client/electron/test/harmony-calendar.test.mjs index edbf12e..756b47e 100644 --- a/client/electron/test/harmony-calendar.test.mjs +++ b/client/electron/test/harmony-calendar.test.mjs @@ -108,8 +108,42 @@ test('"今天":只有传入的那一天被标出来,且标在本月格子内 assert.ok(today[0].inMonth, '今天必须落在本月格里'); // 不传 today:一格都不许标(否则会标到"服务器时间"或"上次渲染残留"上) assert.equal(C.monthGrid(2026, 9, 1).flat().filter(c => c.isToday).length, 0, '不传 today 就不该有今天'); - // today 不在本月:不许标在别的月,也不许标错格 - assert.equal(C.monthGrid(2026, 9, 1, '2026-10-01').flat().filter(c => c.isToday).length, 0); + /* + * ── today 落在邻月时怎么办(2026-09-20 改)── + * + * 原来这里断的是 `count === 0`("不许标在别的月")。**那条断言是错的**, + * 而且错得很隐蔽 —— 它记录的是**旧实现的副作用**,不是一个被设计的不变量: + * + * 当时 `monthGrid` 把邻月格子填成**空 `DayCell`**(`iso` 是空串), + * 于是 `c.iso === today` **永远不可能**匹配 ⇒ `count === 0` 是**恒真的同义反复**。 + * 它看起来在守一条规则,实际上什么都没守。 + * + * 2026-09-20 把邻月格子改成**真实日期**(对齐 WebUI —— 见 `monthGrid` 里那段注释 + * 与 `cross-client-logic.test.mjs` 抓出的分叉)之后,这条断言立刻红了。 + * + * ★ 那到底该标还是不该标?**实测 WebUI 的行为**(不靠推理): + * `CalendarView.tsx:547` `const isToday = isSameDay(d, now);` + * —— **逐格判断**,所以 2026-10-01 真的出现在 9 月网格的尾部格子里时, + * WebUI **会**把它标成今天。这与 Google Calendar 一致,也是合理的 + * ("今天"是个客观事实,不该因为它落在你正在看的月份的尾巴上就不标)。 + * 实测:`monthGrid(2026,9,1)` 的 42 格里确实含 2026-10-01。 + * + * ⇒ 真正的不变量不是"邻月不许标",而是**"标了的那一格必须真的是今天"**: + * · 至多一格(42 个连续日期里,任一日期至多出现一次) + * · 若标了,那一格的 `iso` 必须**等于** today(不是"同月",不是"差一天") + * + * 这才是原本那句注释里"**不许标错格**"想说的东西 —— + * 原断言把"不许标错格"写成了"不许标",窄了一半。 + */ + const adj = C.monthGrid(2026, 9, 1, '2026-10-01').flat().filter(c => c.isToday); + assert.ok(adj.length <= 1, '至多一格被标为今天(今天是唯一的一天)'); + for (const c of adj) { + assert.equal(c.iso, '2026-10-01', + '被标为今天的那一格,它的 iso 必须**就是** today —— 不许标在同月别处、也不许差一天'); + } + // 反向核对:today 确实会出现在 9 月网格里(否则上面那段就白跑了) + const hasOct1 = C.monthGrid(2026, 9, 1).flat().some(c => c.iso === '2026-10-01'); + assert.ok(hasOct1, '9 月网格的尾格里应当含 2026-10-01 —— 它不在的话,上面那段是空转'); }); /* diff --git a/client/electron/test/mutants/baseline.sha b/client/electron/test/mutants/baseline.sha index 293d957..903bc96 100644 --- a/client/electron/test/mutants/baseline.sha +++ b/client/electron/test/mutants/baseline.sha @@ -114,10 +114,20 @@ # => 确认无「改错了没还原」的残留在。 # ③ 新增判据 C2(*For() 入口只能用于前景、不得当背景)把这整类钉住, # 下次再手滑批量替换会直接判红,不必再靠事后复核。 +# 2026-09-20 21:5x baseline 重算(第 9 次)—— 照 `arkts-grammar-standards` skill 的规则表回扫, +# 修掉两处「编译器只告警、判据也不管」的隐形债:正则字面量、废弃的全局 router API。 +# ① 有意编辑:`SettingsPage.ets`(`performLogout` 多传一个 `UIContext`)。 +# ② 取证:`git diff --quiet HEAD -- ` → INTENTIONAL(与 HEAD 不同); +# 再逐行核过 diff **只有这一处一减一加**,确认不是变异残留。 +# ★ 为什么改这一行:`performLogout` 内部原来用**已废弃的全局 `router`** 做退出跳转; +# 改成 `ui.getRouter()` 就需要 `UIContext`,而它是独立函数(没有 `this`), +# 只能由调用方传 —— 两个调用点(`MainPage` 侧栏 / `SettingsPage` 我的页)都持有它。 +# ★ 为什么这类债能活这么久:编译器对它们**只告警不挡构建**,全仓也**没有判据**管, +# ⇒ 规则事实上不存在。已补两条判据(正则字面量 / 废弃全局 API),都做了变异验证。 4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.ts 5778eb9cc2af326b9f51ef9d53a66efa058fd0b5cfbef2881b603f9d367ac2a5 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets 73c66c7045f579c3eb8b8e0aa07803e7c9363b7ae8375972b251633d3ce969be client/harmony/entry/src/main/ets/common/BackgroundPicker.ets -1c8f848ee2d6817beeefd05325cb3b0cdc1d425717769533688019c1e59f2299 client/harmony/entry/src/main/ets/pages/SettingsPage.ets +686daa16d68b7d22332c5ed714723279c3f103b25a8c808b15658b22333524cb client/harmony/entry/src/main/ets/pages/SettingsPage.ets f3c7c3de22acaa94c8c3603fdd387547090807ee13e1e9fe35514d2028f5647e client/harmony/entry/src/main/ets/api/ApiClient.ets 6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 22f0635..1b84e6b 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -75,6 +75,14 @@ const SUITE = [ // 2026-09-19 鸿蒙侧加了手势 ⇒ 现在建。8 条:两端都真有手势 / 方向映射 / // 纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。 ['test/cross-client-gesture.test.mjs', ['--experimental-strip-types', '--no-warnings'], 9], + /* + * ★ 纯逻辑跨端一致性(用户要做的 B):同一张用例表喂两边比结果。 + * `--experimental-strip-types` 是必需的 —— 它直接在 node 里**执行** + * electron 的 `.ts` 与 harmony 的 `.ts` 两侧源码(两边的 model 层都是 + * 纯逻辑、无 SDK 依赖)。首轮就在表里报出两处真分叉: + * `formatAddress(undefined)` 抛 与 `monthGrid` 行数 4~6 vs 固定 6。 + */ + ['test/cross-client-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 // 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。 ['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], @@ -121,7 +129,7 @@ const SUITE = [ ['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31], // ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的: // 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。 - ['test/harmony-arkts.test.mjs', [], 7], + ['test/harmony-arkts.test.mjs', [], 8], ['test/harmony-contacts.test.mjs', [], 5], // ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。 // diff --git a/client/harmony/entry/src/main/ets/api/Logout.ets b/client/harmony/entry/src/main/ets/api/Logout.ets index 7a80747..8fce99a 100644 --- a/client/harmony/entry/src/main/ets/api/Logout.ets +++ b/client/harmony/entry/src/main/ets/api/Logout.ets @@ -17,7 +17,7 @@ */ import { common } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; -import { router } from '@kit.ArkUI'; +import { UIContext } from '@kit.ArkUI'; import { ApiClient } from './ApiClient'; import { AuthApi } from './AuthApi'; import { AccountManager } from './AccountManager'; @@ -31,7 +31,23 @@ import { PushService } from './PushService'; * 任何一步失败都必须继续把它清干净 —— 中途 return 会留下半退出的状态 * (最坏的是凭证没清、下次启动又自动登进去)。 */ -export async function performLogout(ctx: common.UIAbilityContext | common.Context | undefined): Promise { +export async function performLogout( + ctx: common.UIAbilityContext | common.Context | undefined, + /* + * ★★ 2026-09-20 加:`UIContext` 必须由调用方传进来。 + * + * 因为文件末尾那句 `router.replaceUrl(...)` 原本用的是**全局 `router`**, + * 而 ArkTS 的规范里 `router` / `promptAction` / `animateTo` 这些 + * **全局 API 已废弃**,要走 `ui.getRouter()`。 + * 而本函数是个**独立函数**(没有 `this`)⇒ 拿不到 `UIContext`, + * 只能由调用方给。两个调用点(`MainPage` 的侧栏退出、`SettingsPage` 的 + * 「我的」页退出)**都已经持有 `getUIContext()`**,所以是零成本。 + * + * 可选参数:传 `undefined` 时退化为"不导航"(凭证照清 —— 退出的语义是 + * "这台设备不再持有这个账号",导航失败不该让前面几步白做)。 + */ + ui?: UIContext +): Promise { const client: ApiClient | null = ctx === undefined ? null : ApiClient.getInstance(ctx); /* ① 注销推送:失败不阻断退出(本地凭证该清还是要清) */ @@ -72,6 +88,15 @@ export async function performLogout(ctx: common.UIAbilityContext | common.Contex hilog.info(0x0001, 'Logout', 'disconnectAll 失败:%{public}s', JSON.stringify(e)); } - /* ④ replaceUrl 而不是 pushUrl:退出后不该还能"返回"到已登出的页 */ - router.replaceUrl({ url: 'pages/LoginPage' }); + /* + * ④ `replaceUrl` 而不是 `pushUrl`:退出后不该还能"返回"到已登出的页。 + * + * ★ 用 `ui.getRouter()`(`UIContext` 上的那个),不用全局 `router` —— + * 后者在 ArkTS 里是**已废弃 API**(`arkts-grammar-standards` 的规则表里 + * `router.pushUrl` / `promptAction.showToast` / `animateTo` 那一条)。 + * 它们在 SDK 里还能跑,所以 build 全绿、判据也不管 —— 是"隐形的债"。 + */ + if (ui !== undefined) { + ui.getRouter().replaceUrl({ url: 'pages/LoginPage' }); + } } diff --git a/client/harmony/entry/src/main/ets/model/Calendar.ts b/client/harmony/entry/src/main/ets/model/Calendar.ts index daef3ca..67f041d 100644 --- a/client/harmony/entry/src/main/ets/model/Calendar.ts +++ b/client/harmony/entry/src/main/ets/model/Calendar.ts @@ -67,8 +67,38 @@ export function monthGrid(year: number, month: number, weekStart: number, today? const blanks: number = leadingBlanks(year, month, weekStart); const len: number = daysInMonth(year, month); const cells: DayCell[] = []; - for (let i = 0; i < blanks; i++) { - cells.push(new DayCell()); + /* + * ★★ 2026-09-20 修 —— **跨端一致性判据抓出来的第二处真分叉**。 + * + * 月首前面那几格原来是**空 `DayCell`**(`inMonth = false`、没有日期)。 + * 而 WebUI 那边的格子是**真实的日期**,只是置灰: + * `CalendarView.tsx:545-556` + * const outside = d.getMonth() !== curMonth; ← 邻月 + * className={`… ${ outside ? 'bg-gray-50/60' : 'bg-white' }`} + * onClick={() => onPickDay(d)} ← **而且可点** + * `calendar.ts:78-83` 的注释也写着: + * 「多出来的格子**显示邻月日期并置灰**」 + * + * 所以两端的月网格长得不一样:WebUI 每一格都有日期(邻月的淡一点、能点过去), + * 鸿蒙首尾是空白格。用户翻月时看得到这个差别。 + * + * 修法:前面补齐**上一个月的真实日期**,后面补齐**下一个月的真实日期**; + * 这些格子 `inMonth = false`(界面据此置灰),但 `day`/`iso` 都是真的。 + * + * ★ 补法用**日期算术**(`Date` 自动处理跨月/跨年与闰年),不手写月份长度 —— + * 手写就多一份"每月几天"的实现,正是这类代码最容易出错的地方。 + */ + const firstOfMonth: Date = new Date(year, month - 1, 1); + for (let i = blanks; i > 0; i--) { + const d: Date = new Date(firstOfMonth.getTime()); + d.setDate(d.getDate() - i); + const c = new DayCell(); + c.day = d.getDate(); + c.label = d.getDate(); + c.inMonth = false; + c.iso = isoOf(d.getFullYear(), d.getMonth() + 1, d.getDate()); + c.isToday = today !== undefined && c.iso === today; + cells.push(c); } for (let d = 1; d <= len; d++) { const c = new DayCell(); @@ -79,9 +109,38 @@ export function monthGrid(year: number, month: number, weekStart: number, today? c.isToday = today !== undefined && c.iso === today; cells.push(c); } - // 补齐最后一行(上月尾/下月头也填上日期,界面才好画连续的日子) - while (cells.length % 7 !== 0) { - cells.push(new DayCell()); + /* + * ★★ 2026-09-20 修 —— **这是跨端一致性判据抓出来的真分叉**。 + * + * 原来这里是"补齐到最后一行就停"⇒ 行数 **4~6 行**(由月长与月首星期决定)。 + * 而 WebUI `CalendarView.tsx:543` 是**固定 6 行**: + *
+ * 它旁边的注释(`calendar.ts:78-83`)写明了为什么: + * 「固定 6 行而不是按需 4~6 行:**行数变化会让整个网格高度跳动**, + * 翻月时页面内容上下弹。多出来的格子显示邻月日期并置灰。」 + * + * 鸿蒙这边行数不一致 + `CELL_HEIGHT = 52` 固定 ⇒ 从 6 行的月份翻到 4 行的月份, + * 网格**矮下去 104vp**,整页内容跟着弹一下 —— 正是 WebUI 那条注释要避免的现象。 + * + * 修法照抄:**永远 6 行(42 格)**,多出来的格子留空(`DayCell` 默认 + * `inMonth=false`、无 `day`,界面按"邻月位"画空)。 + * + * ★ 这条分叉是**判据先报的**,不是我在设备上看到的 —— + * 一致性表把 `(2026,9)` 与 `(2026,2)` 两个输入喂给两边: + * electron 都是 42 格,harmony 是 5 行 / 4 行 ⇒ 当场对不上。 + * 这与本文件顶上那条"判据先报 `formatAddress(undefined)`"是同一次收获。 + */ + const lastOfMonth: Date = new Date(year, month - 1, len); + while (cells.length < 42) { + const d: Date = new Date(lastOfMonth.getTime()); + d.setDate(d.getDate() + (cells.length - (blanks + len) + 1)); + const c = new DayCell(); + c.day = d.getDate(); + c.label = d.getDate(); + c.inMonth = false; + c.iso = isoOf(d.getFullYear(), d.getMonth() + 1, d.getDate()); + c.isToday = today !== undefined && c.iso === today; + cells.push(c); } const rows: DayCell[][] = []; for (let i = 0; i < cells.length; i += 7) { diff --git a/client/harmony/entry/src/main/ets/model/ReplyTarget.ts b/client/harmony/entry/src/main/ets/model/ReplyTarget.ts index 61d6ccb..f0e52b3 100644 --- a/client/harmony/entry/src/main/ets/model/ReplyTarget.ts +++ b/client/harmony/entry/src/main/ets/model/ReplyTarget.ts @@ -43,12 +43,31 @@ export interface Counterpart { * 给人类回信时若丢掉 `@`,整条地址就废了。 */ export function formatAddress(name: string, path: string, session: string): string { - const n: string = name.trim(); + /* + * ★★ 2026-09-20 修 —— **这是同一个 `omitempty` 坑的第三次**, + * 而且这次是**一致性判据当场抓出来的**(不是我又踩到才发现的)。 + * + * electron 那一侧写的是 `(name || '').trim()` / `(path || '').trim()` / + * `(session || '').trim()`;鸿蒙这侧写的是裸 `.trim()`。 + * 平时看不出来(调用方大多传的是 `''`),但**服务端 `omitempty` 缺失时 + * 传进来的就是 `undefined`** —— 裸 `.trim()` 当场抛。 + * + * 实测(`cross-client-logic.test.mjs` 的一致性表): + * formatAddress('dsh', undefined, undefined) + * electron → "dsh" + * harmony → THROW: Cannot read properties of undefined + * + * ★ 为什么这次值得单独记:前两次(`participantAddress` 的 `||`、 + * `attachments.length` 的 `?? []`)都是**踩到之后**回头补的。 + * 这次是**判据先报**——说明"两端跑同一张用例表比行为"这个做法是有效的, + * 它不需要我先知道有 bug。 + */ + const n: string = (name || '').trim(); if (n.length === 0) { return ''; } - const p: string = path.trim(); - const s: string = session.trim(); + const p: string = (path || '').trim(); + const s: string = (session || '').trim(); if (s.length === 0) { return p.length === 0 ? n : n + '@' + p; } diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 94ef1ea..a071404 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -611,8 +611,20 @@ export struct MailDetailView { if (t.length === 0) { return true; } - /* 只认纯数字 —— `Number('1e3')` 能过但服务端是 int,传上去会被拒 */ - return /^\d+$/.test(t); + /* + * 只认纯数字 —— `Number('1e3')` 能过但服务端是 int,传上去会被拒。 + * + * ★★ 2026-09-20 修:原来这里写的是**正则字面量** `/^\d+$/`, + * 而 ArkTS **禁用正则字面量**(`arkts-no-regexp-literals`)—— + * 要用 `new RegExp('...')`。 + * + * 为什么一直没报错:编译器这条是**告警级**、不挡构建, + * 而全仓也**没有判据管它**(`harmony-arkts` 那条"词汇层硬坑"只查 + * 解构 / any / unknown / 函数表达式四种)。⇒ 我这次是照着 + * `arkts-grammar-standards` skill 的规则表逐条回扫才发现的。 + * 已把正则字面量补进那条判据(见下)。 + */ + return new RegExp('^[0-9]+$').test(t); } /** 徽标文案(对齐 WebUI 的三种写法) */ diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 8a890d9..80e005d 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -3392,7 +3392,7 @@ struct MainPage { onToggleTheme: () => { this.toggleTheme(); }, /* 退出:与「我的」页那个按钮走**同一个** `performLogout` */ onLogout: () => { - performLogout(this.getUIContext().getHostContext()).catch(() => {}); + performLogout(this.getUIContext().getHostContext(), this.getUIContext()).catch(() => {}); } }) } diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 2d7f140..4ef9f23 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -375,7 +375,7 @@ export struct SettingsPane { */ async doLogout(): Promise { const ctx = this.getUIContext().getHostContext(); - await performLogout(ctx); + await performLogout(ctx, this.getUIContext()); } /** diff --git a/docs/DEBTS.json b/docs/DEBTS.json index 96adbda..cc30104 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -135,6 +135,22 @@ "where": "client/harmony/entry/src/main/ets/pages/MailDetailPage.ets (对照 client/electron/src/components/MailView.tsx)", "kind": "scope", "note": "2026-09-19 审计发现:邮件详情页缺**三块 WebUI 有的功能**,而且**服务端三块都已支持**(不是做不了):① `RenameProposalBar`(MailView.tsx:324)—— Agent 建议改会话别名,人确认/驳回。服务端 `server/internal/handler/rename_proposal.go` 已在解析 `propose_alias` 标记并把提议从正文剥掉。**这条对寻址稳定性很重要**(WebUI 那里的注释:「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」)。② `ForwardBar`(MailView.tsx:378)—— 转发。服务端 `forward.go` 完整(含 `forwardSubject` 的 Fwd: 叠加处理)。③ `BudgetEditor`(MailView.tsx:235)—— 会话往返预算编辑(`max_rounds`,服务端 `forward.go:304` 有此字段)。三条都不是顺手能补的量级(各含交互 + 接口 + 状态),所以先登记,不在审计那一批里硬塞 —— 硬塞的结果是每条都半成品。\n\n2026-09-19 结算:三块**都做完了** ——\n① 转发(commit cc7ff25,`ForwardMailRequest` + 弹层;修了两个真 bug:两个动作球几乎完全重叠、弹层没高度导致键盘一弹按钮被顶出屏幕);\n② 会话改名建议(commit c26e857 接口 + fe4342a UI,端到端实测:服务端识别 rename_proposed → 建议条出现 → 点接受 → 库里别名真的改了);\n③ 往返预算(commit 4e84369,GET/PUT 契约用 curl 逐条实测)。\n★ 未验:预算条的**点击**在设备上没走通 —— 模拟器顶部 155px 是系统手势区,而折叠头部恰在其中(坐标式点击会被系统抢走)。数据链路已实测,观感没验。" + }, + { + "id": "harmony-dead-pages", + "count": 2, + "due": "下次清理鸿蒙页面时(要先确认不是将来要用的留存实现)", + "where": "client/harmony/entry/src/main/ets/pages/InboxPage.ets(238 行)、pages/SessionsPage.ets(170 行)", + "kind": "scope", + "note": "2026-09-20 盘点发现的**不可达页面**(两页共 408 行):\n· `InboxPage.ets` —— 不在 `resources/base/profile/main_pages.json` 的页面表里,全仓**没有任何** `pushUrl('pages/InboxPage')`。\n· `SessionsPage.ets` —— 注册在页面表里,但**唯一的引用**是 `InboxPage.ets:171` 的 `pushUrl('pages/SessionsPage')`,而 InboxPage 本身不可达 ⇒ 一起不可达。\n\n★ 为什么没顺手删:`docs/HARMONY-ALIGN-PLAN.md:214` 把 `InboxPage.ets` 当作变异测试的靶子(往它塞一个色值来验证判据能判红),说明它在某个时点是有用的。删掉会永久丢代码,而它是否只是「早期实现的留存」我判断不了 —— **这是人的决定**。\n\n★ 它现在还带来实际成本:`cross-client-logic` 与 `harmony-arkts` 的**形状判据**会扫到它(并按「死代码」给它豁免)—— 每加一条形状判据都要为「这文件还活着」多写一次例外。要么删、要么在文件头明确写「这是留存参考、不参与构建」,两条路都比现在清楚。" + }, + { + "id": "harmony-permission-history", + "count": 1, + "due": "做「授权栏与 WebUI 对齐」时", + "where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 PermissionTab(现在只读 /permission/pending)", + "kind": "scope", + "note": "2026-09-20 由 `cross-client-logic.test.mjs` 的**缺口判据**报出来的(它要求两侧导出缺口只减不增,`groupPermissions` 是新多出来的一个)。\n\n**两端拿授权栏数据的方式根本不同:**\n· WebUI:`PermissionList.tsx:27` 拿 inbox 自己分组 —— `const groups = groupPermissions(inbox)`,每组同时渲染两段:\n · `g.pending`(显示 `{n} 待决策`)\n · `g.settled`(显示 `历史 {n}`,见 `:182`)\n 即**待决 + 已决策历史**都在,且按会话分组。\n· 鸿蒙:`MainPage.ets` 的 `PermissionTab` 调专用端点 `GET /permission/pending`(服务端 `ListPendingPermissionsFor`,SQL 里带 `WHERE pr.result IS NULL`)—— **只拿得到待决的**,而且是**平铺一列**,不分组。\n\n⇒ 用户可见差异:**已决策的授权记录在鸿蒙上完全看不到**(WebUI 能看到每个会话的历史条数)。\n\n★ 为什么没当场改:这不是照抄一个函数能解决的 —— 要么改成读 inbox 并实现分组渲染(结构改动),要么另调一个含已决策的端点(服务端可能没有)。两条路都要想清楚哪边是权威(本仓方向:**electron 是唯一真实源泉**⇒ 倾向于改成读 inbox + 分组)。先登记,别假装对齐了。" } ] } \ No newline at end of file