Files
MailUI4Agents/client/electron/test/harmony-calendar.test.mjs
JianFeeeee 5e4a1b616c 跨端: B 的交付物(跨端纯逻辑一致性判据)+ 照 skill 回扫修掉两处隐形债
用户:「你为什么不加载鸿蒙开发相关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 秒**跑完整套。
2026-09-20 22:35:35 +08:00

514 lines
32 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.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readdirSync } from 'node:fs';
import { code, stripStrings } from './lib/read.mjs';
/*
* `code()` 去的是**注释**,不是**字符串**(pi 2026-09-14 §2):任何只是"提到"被禁写法的
* 字符串(UI 提示、dev 警告、甚至报错文案自己)都会把它自己判红 —— 那不是它要管的东西。
*
* **口径现在就定下来**(两条路里选"只在表达式位置匹配"):先整段去掉字符串字面量,再匹配。
* 于是"提到"不再误红,而**真的用它取日期键**照样红。
* **出现同族误红时不许"再改一次扫描口径"把判据往宽里挪。**
*
* ★★ 这里原来有**自己的一份** `stripStrings`(正则近似),现在改用 `lib/read.mjs` 那份
* (pi 2026-09-15 抓到"同一件东西两份实现")。三条理由,按重要性:
*
* ① **这份判据打印 `文件:行号`**(`hits.push(\`…:${i+1} …\`)`),行号是它**唯一**的定位手段。
* 旧那份不保证行数守恒 —— 实测 89 个 `.ets`/`.ts` 里 **3 个**抹除后行数变少
* (`plugins/dsh-mail-bridge/src/index.ts` 原 2248 → 2245),于是跨行模板之后的
* **报错行号整体前移**。**这正是我两轮前为 `stripComments` 修过的同一条**
* ("行号必须保持不变")—— 那次修在主实现里,**兄弟副本里没修**。
* ② `read.mjs` 那份是**逐字符**扫的(不是正则近似),**保留引号与换行** ⇒ 行数守恒
* (实测 89/89 不变,旧那份 3 个不对)。
* ③ 两份里**只有一份**经过那五轮边界修(注释/字符串/等价写法/锚/正则引号)——
* 留两份,第六轮只会被修在其中一份里。**不给自己留兄弟副本。**
*
* ★ 换之前我实测过兼容性:两份实现在**这份判据真正用的那个性质**上结论一致
* ——89 个文件里"抹除后还有没有 `toISOString`"**全部相同**(0 处分歧)。
* ⇒ 换实现**不改判据结论**,只是让行号可信。
*/
/*
★ 日历的纯逻辑(P6 第 1 步的逻辑那一半)—— **不需要设备**,所以它不该进静态欠账。
日历最容易悄悄错的是算术:闰年、月首落在星期几、网格行数、跨年翻月、"今天"标错格。
这些在界面上表现为"看起来正常但日子错位",属于最难靠肉眼发现的一类,
所以先把它放在可跑判据下面(与 `Wallpaper.ts` 同一个模式:纯逻辑 + node 直接跑)。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const ETS = join(HERE, '..', '..', '..', 'client', 'harmony', 'entry', 'src', 'main', 'ets');
const C = await import(pathToFileURL(join(ETS, 'model', 'Calendar.ts')).href);
test('闰年与月长:整百年只有能被 400 整除才是闰年', () => {
assert.equal(C.daysInMonth(2024, 2), 29, '2024 是闰年');
assert.equal(C.daysInMonth(2026, 2), 28, '2026 不是');
assert.equal(C.daysInMonth(2000, 2), 29, '2000 能被 400 整除 ⇒ 闰年');
assert.equal(C.daysInMonth(2100, 2), 28, '2100 能被 100 但不能被 400 整除 ⇒ 平年(这条最容易写错)');
assert.deepEqual([1, 3, 5, 7, 8, 10, 12].map(m => C.daysInMonth(2026, m)), [31, 31, 31, 31, 31, 31, 31]);
assert.deepEqual([4, 6, 9, 11].map(m => C.daysInMonth(2026, m)), [30, 30, 30, 30]);
});
test('月首星期与"周一起始/周日起始"的唯一分叉点', () => {
assert.equal(C.firstWeekday(2026, 9), 2, '2026-09-01 是周二(0=周日)');
assert.equal(C.leadingBlanks(2026, 9, 1), 1, '周一起始:周二前留 1 格');
assert.equal(C.leadingBlanks(2026, 9, 0), 2, '周日起始:留 2 格');
// 同一份代码在不同本地时区必须给同一天(一律 UTC 构造/取星期)
assert.equal(C.firstWeekday(2026, 1, ), new Date(Date.UTC(2026, 0, 1)).getUTCDay());
});
test('网格形状:每行 7 格、行数 4~6、本月日子各出现恰好一次', () => {
for (const [y, m, len] of [[2026, 2, 28], [2024, 2, 29], [2026, 9, 30], [2026, 8, 31]]) {
const rows = C.monthGrid(y, m, 1);
assert.ok(rows.length >= 4 && rows.length <= 6, `${y}-${m} 行数应在 4~6(实际 ${rows.length})`);
for (const r of rows) assert.equal(r.length, 7, '每行必须 7 格(否则界面会错位)');
const days = rows.flat().filter(c => c.inMonth).map(c => c.day);
assert.equal(days.length, len, `${y}-${m} 本月天数`);
assert.deepEqual(days, [...Array(len).keys()].map(i => i + 1), '本月日子必须连续且各一次');
// 首格的位置必须等于 leadingBlanks(否则整月错位一格)
const first = rows.flat().findIndex(c => c.inMonth);
assert.equal(first, C.leadingBlanks(y, m, 1), `${y}-${m} 首格位置`);
}
});
test('格子的 iso 键唯一、且与年月日一致(键错了会让"今天"标错格)', () => {
const cells = C.monthGrid(2026, 9, 1).flat().filter(c => c.inMonth);
const keys = cells.map(c => c.iso);
assert.equal(new Set(keys).size, keys.length, 'iso 必须唯一');
assert.equal(keys[0], '2026-09-01');
assert.equal(keys[keys.length - 1], '2026-09-30');
assert.ok(keys.every(k => /^\d{4}-\d{2}-\d{2}$/.test(k)), '补零:2026-09-01 而不是 2026-9-1');
});
test('翻月:跨年两个方向都对(这是"翻到 13 月"的经典错法)', () => {
assert.deepEqual(C.addMonths(2026, 12, 1), [2027, 1]);
assert.deepEqual(C.addMonths(2026, 1, -1), [2025, 12]);
assert.deepEqual(C.addMonths(2026, 6, 0), [2026, 6]);
assert.deepEqual(C.addMonths(2026, 1, -13), [2024, 12], '一次翻一年零一个月也要对');
for (const [y, m] of [[2026, 12], [2026, 1], [2100, 12]]) {
for (const d of [-25, -1, 1, 25]) {
const [ny, nm] = C.addMonths(y, m, d);
assert.ok(nm >= 1 && nm <= 12, `翻月后月份必须落在 1~12(得到 ${nm})`);
// 我第一版这里写的是"年份落在 2024~2126"—— **那是我拍的范围,不是性质**:
// 2026-01 往回 25 个月 = 2023-12 完全正确,却把我的断言撞红了(判据错、代码对)。
// 换成**往返性质**:翻过去再翻回来必须回到原点(对任意 delta 都成立,无需拍范围)。
assert.deepEqual(C.addMonths(ny, nm, -d), [y, m], `翻 ${d} 个月再翻回来必须回到 ${y}-${m}`);
}
}
});
test('"今天":只有传入的那一天被标出来,且标在本月格子内', () => {
const rows = C.monthGrid(2026, 9, 1, '2026-09-14');
const today = rows.flat().filter(c => c.isToday);
assert.equal(today.length, 1, '必须恰好一格是今天');
assert.equal(today[0].iso, '2026-09-14');
assert.ok(today[0].inMonth, '今天必须落在本月格里');
// 不传 today:一格都不许标(否则会标到"服务器时间"或"上次渲染残留"上)
assert.equal(C.monthGrid(2026, 9, 1).flat().filter(c => c.isToday).length, 0, '不传 today 就不该有今天');
/*
* ── 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 —— 它不在的话,上面那段是空转');
});
/*
* ★ 表头与网格必须**来自同一个 `startOfWeek`**(pi 2026-09-14 §1)。
*
* 危险形态:页面里把表头硬编码成 `['日','一',…]`(或另算一次)⇒ 格子位置全对、
* **表头整体错一列**。上面 6 条判据一条都不会红,因为**它们只看网格**。
* 所以这条判据做的是**交叉核对**:把"1 号落在第几列"与"那一列的表头字"对上。
*/
test('★ 表头顺序与 leadingBlanks 必须来自同一个 startOfWeek(否则表头整体错一列)', () => {
for (const ws of [0, 1]) {
const labels = C.weekdayLabels(ws);
assert.equal(labels.length, 7);
assert.equal(new Set(labels).size, 7, '表头七天不许重名');
assert.equal(labels[0], ws === 0 ? '日' : '一', `startOfWeek=${ws} 的第一格必须是那一天`);
assert.equal(labels[6], ws === 0 ? '六' : '日', '最后一格也要跟着换(只改第一格是半吊子)');
}
// 交叉核对:若干月份的 1 号落在那列,那一列的表头字必须就是 1 号的星期名
for (const ws of [0, 1]) {
const labels = C.weekdayLabels(ws);
for (const [y, m] of [[2026, 9], [2026, 2], [2024, 2], [2026, 3], [2027, 1], [2026, 11]]) {
const col = C.leadingBlanks(y, m, ws);
assert.equal(labels[col], C.weekdayNameOf(y, m, 1),
`${y}-${m}(startOfWeek=${ws}):1 号在第 ${col} 列,那一列表头写着「${labels[col]}」,` +
`而 1 号其实是「${C.weekdayNameOf(y, m, 1)}」—— 表头与网格分叉了`);
}
}
});
/*
* ★ "今天"的调用侧:`toISOString()` 是 UTC,UTC+8 早上会把今天标到昨天那一格
* (pi 2026-09-14 §2)。这一格纯逻辑判据够不着,所以把取今天的函数也放进可跑范围。
*/
test('★ 调用侧取今天:按偏移取(UTC+8 / UTC-8 / UTC),并钉住 toISOString 的 UTC 陷阱', () => {
// 2026-09-14T16:30Z:UTC+8 已经是 15 日 00:30;UTC-7 还是 14 日 09:30
const t = new Date('2026-09-14T16:30:00Z');
assert.equal(C.isoAtOffset(t, 480), '2026-09-15', 'UTC+8 该给 15 日');
assert.equal(C.isoAtOffset(t, -420), '2026-09-14', 'UTC-7 该给 14 日');
assert.equal(C.isoAtOffset(t, 0), '2026-09-14', 'UTC 该给 14 日');
// 陷阱本身:两种取法在 UTC+8 的清晨**必须不同** —— 这就是"今天标到昨天"那一格
const naive = t.toISOString().slice(0, 10);
assert.equal(naive, '2026-09-14', '自检:naive 取法给 UTC 日期');
assert.notEqual(C.isoAtOffset(t, 480), naive,
'UTC+8 的清晨,toISOString 取法会给昨天 —— 页面若用它,"今天"就标到上一格(且本机跑 UTC 时永远测不出)');
// 同一时刻、两条取法必须自洽:本机偏移那条 == isoAtOffset(本机偏移)
const now = new Date();
assert.equal(C.isoOfLocal(now), C.isoAtOffset(now, -now.getTimezoneOffset()),
'isoOfLocal 与 isoAtOffset(本机偏移) 必须一致(不一致说明其中一条用错了基准)');
assert.match(C.isoOfLocal(now), /^\d{4}-\d{2}-\d{2}$/, '补零格式');
});
/*
* ★ 调用侧的**唯一入口**:鸿蒙树里不许出现 `toISOString().slice(0,10)` 这种取日期键的写法
* (与"结算/日期键必须本地口径"同族)。文件里出现一次即红 —— 那是"今天标错格"的直接来源。
* 登记值 0:现在没有(页面骨架还没写),所以这条的红是**未来时**。
* 扫的是 (**去掉注释后的代码**)—— 第一次跑这条判据时它抓到了
* 里**解释这个陷阱的注释**。规则管的是代码,注释是文档:
* 这正是本仓库 code/prose 两套读取入口存在的理由(JSON 那类是数据,走 prose)。
*/
test('★ 鸿蒙树里不许用 toISOString 取日期键(今天标错格的直接来源)', () => {
const hits = [];
const scan = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const f = join(dir, e.name);
if (e.isDirectory()) { scan(f); continue; }
if (!/\.(ets|ts)$/.test(e.name)) continue;
stripStrings(code(f)).split('\n').forEach((line, i) => {
if (/toISOString\s*\(\s*\)\s*\.\s*slice\(\s*0\s*,\s*10\s*\)/.test(line)) {
hits.push(`${f.slice(ETS.length + 1)}:${i + 1} ${line.trim()}`);
}
});
}
};
scan(ETS);
assert.deepEqual(hits, [],
`用 toISOString 取日期键了 —— **停下**:它是 UTC 口径,UTC+8 的清晨会给出昨天,` +
`"今天"就标到上一格。**正确修法**:页面侧用 model/Calendar.ts 的 isoOfLocal(new Date())。` +
`**最常见的错误修法**:把断言里的期望值改成 UTC 日期(那是把判据废掉)。\n ${hits.join('\n ')}`);
});
/*
* ★ "今天"必须在显示时求值 —— 日历是**常驻 pane**(pi 2026-09-14 §1)。
*
* 顶层导航是「通信 / 日历 / 联系人」三个平级 pane,日历长时间挂着;`today` 是**唯一随时间变**
* 的输入。建页时取一次存进状态 ⇒ 跨过午夜后标记停在昨天,而**所有判据全绿**
* (模型没错,是喂进去的值过期了)。这与 `Calendar.ts` 里防的"上次渲染残留"是同一件事,
* 只是残留发生在**调用侧**。
*
* 纯逻辑侧能钉的是:同一次输入给同一结果、不同输入各自独立、**不许跨调用残留**。
* 调用侧义务(pane 变可见时重算)原登记在 docs/DEBTS.json 的 `calendar-today-recompute`,
* **2026-09-14 已结算**:日历页落地时用 `@Prop @Watch('onVisibleChanged') visible` 实现,
* 判据见本文件末尾的「★ today 在 pane **变可见时**重算」。
*/
test('★ today 不许跨调用残留:连续两次不同输入各自独立,且推进一天后值必须变', () => {
const mark = g => g.flat().filter(c => c.isToday).map(c => c.iso);
assert.deepEqual(mark(C.monthGrid(2026, 9, 1, '2026-09-14')), ['2026-09-14']);
assert.deepEqual(mark(C.monthGrid(2026, 9, 1, '2026-09-15')), ['2026-09-15'],
'第二次调用必须标 15 日 —— 不许留着上一次的值');
const t0 = new Date('2026-09-14T23:59:00Z');
const t1 = new Date(t0.getTime() + 86400000);
const d0 = C.isoAtOffset(t0, 480);
const d1 = C.isoAtOffset(t1, 480);
assert.notEqual(d0, d1, '跨过零点的两个时刻必须给出不同的今天(否则页面重算也没用)');
assert.deepEqual(mark(C.monthGrid(2026, 9, 1, d0)), [d0]);
assert.deepEqual(mark(C.monthGrid(2026, 9, 1, d1)), [d1]);
assert.equal(mark(C.monthGrid(2026, 9, 1)).length, 0,
'不传就不许有今天(不许内部兜底取系统时间 —— 那正是"取一次"的写法)');
});
/*
* ═══════════════════════════════════════════════════════════════════
* P6 第 1 步的**页面那一半**(2026-09-14:日历入口上架)
*
* 上面那些钉的是"算术对不对";从这里开始钉"页面有没有如实消费它"。
* 两者分工明确:算术错 → 日子错位;消费错 → 数据对但画错、或永远画不出来。
*/
test('页面:网格与表头都走 Calendar.ts 的同一套入参(WEEK_START=1,与 WebUI 同值)', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
// 网格:行来自 monthGrid,且 weekStart 传的是常量(不是字面量 0/1 —— 那会与表头分叉)
assert.match(page, /monthGrid\(this\.year, this\.month, WEEK_START, this\.todayIso\)/,
'网格必须由 monthGrid 生成,并把 WEEK_START 与 todayIso 一起传进去');
assert.match(page, /weekdayLabels\(WEEK_START\)/,
'表头顺序必须来自 weekdayLabels(WEEK_START) —— 硬编码成 日/一/二… 就是第二个分叉点(表头整体错一列)');
const decl = /const WEEK_START: number = (\d)/.exec(page);
assert.ok(decl, 'WEEK_START 要是常量,判据要能读到它');
assert.equal(decl[1], '1', '周起始必须是周一(1)—— 与 WebUI lib/calendar.ts 的 startOfWeek 同值,两端不同值会让同一天落在不同列');
});
test('页面:事件按 localIsoOf 归日,且不自己切时间戳', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /localIsoOf\(/, '事件归日必须走 localIsoOf');
assert.ok(!/\.slice\(0, 10\)/.test(page),
'页面里不许切时间戳前 10 位取日期(那是 UTC 日期 ⇒ UTC+8 的凌晨会把事件画到前一天)');
// 会话/列表侧的日期格式化也不许自己切(同一族:只有 localIsoOf/hhmmAtOffset 两个出口)
assert.match(page, /hhmmAtOffset\(/, '钟点显示走 hhmmAtOffset(偏移是入参,可判据)');
});
test('★ localIsoOf / hhmmAtOffset:偏移是入参,所以三个时区都能真跑', () => {
// UTC+8 的 09-15 00:30 在 UTC 里是 09-14T16:30Z —— 切字符串会给 09-14(错一天)
assert.equal(C.localIsoOf('2026-09-14T16:30:00Z'), C.isoAtOffset(new Date('2026-09-14T16:30:00Z'), C.deviceOffsetMinutes(new Date('2026-09-14T16:30:00Z'))),
'localIsoOf 必须与 isoAtOffset(设备偏移) 同口径');
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', 480), '00:30', 'UTC+8 的钟点');
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', -480), '08:30', 'UTC-8 的钟点');
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', 0), '16:30', 'UTC 的钟点');
// 坏值不许把整个月画崩
assert.equal(C.localIsoOf('not-a-time'), '');
assert.equal(C.hhmmAtOffset('not-a-time', 480), '');
});
test('★ today 在 pane **变可见时**重算(DEBTS 的 calendar-today-recompute)', () => {
/*
* 这条欠账的到期前提就是"日历页面骨架写完后立刻",现在到期了 —— 于是要么实现、要么改前提。
* 实现方式:`@Prop @Watch('onVisibleChanged') visible` + aboutToAppear 各自重算一次。
*
* 为什么两条都要:
* · 重新挂载(切页签)→ aboutToAppear 重算 ✓;
* · 常驻 pane 一直开着跨过午夜 → **只有 @Watch(visible)** 能救(卸载重挂不会发生)。
* 判据盯的是"重算路径存在且绑定在可见性上",不是"今天恰好是几号"(那个由纯逻辑判据管)。
*/
const raw = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(raw, /@Prop @Watch\('onVisibleChanged'\) visible: boolean/,
'visible 必须挂 @Watch —— 否则常驻 pane 跨午夜后 today 停在昨天');
assert.match(raw, /onVisibleChanged\([^)]*\): void \{[\s\S]{0,200}?refreshToday\(\)/,
'onVisibleChanged 里必须重算 today');
assert.match(raw, /refreshToday\(\)[\s\S]{0,60}isoOfLocal\(new Date\(\)\)/,
'today 只能由 isoOfLocal(new Date()) 求(toISOString 是 UTC 口径,清晨会给出昨天)');
// 首次可见才拉数据:常驻挂载 ≠ 常拉(隐藏着也发请求是给用户白花的流量/电)
assert.match(raw, /if \(!this\.loadedOnce\)/,
'首次可见才拉数据 —— 常驻挂载不等于启动就拉');
});
test('页面:翻月走 addMonths(跨年两个方向已被纯逻辑钉过),不自己算 month+1', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /addMonths\(this\.year, this\.month, delta\)/, '翻月必须复用 addMonths');
assert.ok(!/this\.month \+ 1|this\.month - 1/.test(page),
'页面里不许自己算 month±1 —— "翻到 13 月"就是那样来的(纯逻辑判据已覆盖 addMonths,别在页面重写一遍)');
});
test('★ 判据自检:把错误写法喂进来必须判红', () => {
// ① 自己切时间戳取日期键(UTC 口径 ⇒ 错一天)
assert.ok(/\.slice\(0, 10\)/.test("localIsoOf(e.event_time.slice(0, 10))"), '切时间戳能被"不许切"这条命中');
// ② 表头硬编码(与网格分叉)
assert.ok(!/weekdayLabels/.test("['日','一','二','三','四','五','六']"), '硬编码表头能被"必须来自 weekdayLabels"命中');
// ③ month±1 自算
assert.ok(/this\.month \+ 1/.test('const m = this.month + 1;'), '自算 month+1 能被命中');
// ④ 没有 @Watch(today 跨午夜停在昨天)
assert.ok(!/@Prop @Watch\('onVisibleChanged'\)/.test('@Prop visible: boolean = true;'), '缺 @Watch 能被命中');
});
/*
* ═══════════════════════════════════════════════════════════════════
* P6 第 2 步:写侧(新建 / 编辑 / 删除 / 暂停恢复)
*
* 这一层最容易"看起来能用但实际没写进去":表单填了、按钮点了、界面回到日历 ——
* 而请求发错端点、字段名写错(严格解码直接 400)、或者时间戳拿日期键顶替
* (事件被存到另一天的 00:00)。所以判据钉的是**端点与载荷**,不是"有个保存按钮"。
*/
test('写侧:三个端点各自的路径与动词(GET 之外)', () => {
const api = code(join(ETS, 'api', 'CalendarApi.ets'));
assert.match(api, /post<CalendarEvent>\('\/calendar\/events', input\)/, '新建:POST /calendar/events');
assert.match(api, /put<CalendarEvent>\('\/calendar\/events\/' \+ encodeURIComponent\(id\), input\)/,
'编辑:PUT /calendar/events/{id}(id 要编码)');
assert.match(api, /del<CalendarDeleteResponse>\('\/calendar\/events\/' \+ encodeURIComponent\(id\)\)/,
'删除:DELETE /calendar/events/{id}');
// 编辑与新建必须发**同一份**输入类型(服务端两个端点同形;拆两份会在严格解码下 400)
assert.match(api, /createEvent\(input: CalendarEventInput\)/, '新建收 CalendarEventInput');
assert.match(api, /updateEvent\(id: string, input: CalendarEventInput\)/, '编辑收同一份 CalendarEventInput');
});
test('写侧:提交的是**时间戳**(不是日期键),回填走 localDateOf', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /isoTimestampOfLocal\(this\.fDate\.getFullYear\(\)/, '提交时由本地年月日+时分合成时间戳');
assert.ok(!/event_time = .*localIsoOf|input\.event_time = this\.selectedIso/.test(page),
'不许把日期键(YYYY-MM-DD)当 event_time 发出去 —— 那会被解析成当天 00:00,事件落到错误的小时上');
assert.match(page, /localDateOf\(e\.event_time\)/, '编辑回填要用 localDateOf 把时间戳还原成本地 Date');
});
test('★ 写侧:空标题 / 空收件人**在本地就挡住**,不发注定 400 的请求', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
const save = page.slice(page.indexOf('async saveEvent()'), page.indexOf('async deleteEvent()'));
assert.ok(save.length > 200, '要能取到 saveEvent 的正文');
// 两个前置校验都要在**发起请求之前**(`const input` 之前)
const beforeInput = save.slice(0, save.indexOf('const input'));
assert.match(beforeInput, /this\.fTitle\.trim\(\)\.length === 0/, '标题为空要拦住');
assert.match(beforeInput, /this\.recipientList\(\)\.length === 0/, '收件人为空要拦住');
assert.match(beforeInput, /return;/, '拦住就是 return,不许继续发请求');
});
test('★ 写侧:服务端 400 的文案原样显示(不是"保存失败"四个字)', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /private errorTextOf\(e: Object, fallback: string\): string/,
'要有一个把 ApiError 转成用户可读文案的函数');
assert.match(page, /return err\.message\.length > 0 \? err\.message/, '服务端文案优先');
// 三处调用都走它(保存 / 删除 / 改状态)
const calls = [...page.matchAll(/this\.errorTextOf\(e, '/g)].length;
assert.ok(calls >= 3, `保存、删除、改状态都要走 errorTextOf(实际 ${calls} 处)—— 吞掉服务端文案等于让人猜`);
});
test('写侧:删除有二次确认、暂停恢复走同一个 update', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
assert.match(page, /this\.confirmDelete \? '确认删除' : '删除'/, '删除按钮要两态');
assert.match(page, /if \(this\.confirmDelete\) \{\s*this\.deleteEvent\(\);/, '第一下只是置位,第二下才真删');
// 暂停/恢复:改 status 后走 update(不是删除重加 —— 那会丢 event_id 与历史)
const toggle = page.slice(page.indexOf('async toggleStatus()'), page.indexOf('private errorTextOf'));
assert.match(toggle, /this\.fStatus === 'active' \? 'paused' : 'active'/, '暂停/恢复就是 active ↔ paused');
assert.match(toggle, /a\.updateEvent\(this\.editingId, input\)/, '走 updateEvent');
});
test('★ isoTimestampOfLocal:本地年月日时 → 时间戳 → 回读还是同一格(含跨日)', () => {
const iso = C.isoTimestampOfLocal(2026, 9, 17, 10, 20);
assert.equal(C.localIsoOf(iso), '2026-09-17', '归日要回到同一天');
assert.equal(C.hhmmAtOffset(iso, C.deviceOffsetMinutes(C.localDateOf(iso))), '10:20', '钟点要回到同一时刻');
// 跨日边界:23:59 与 00:01 必须归到不同的两天(否则"跨午夜"那类事件会串天)
assert.notEqual(C.localIsoOf(C.isoTimestampOfLocal(2026, 9, 17, 23, 59)),
C.localIsoOf(C.isoTimestampOfLocal(2026, 9, 18, 0, 1)));
// 坏值回填不许抛
assert.ok(C.localDateOf('not-a-time') instanceof Date, '坏时间戳要给一个可用 Date,不能让表单崩');
});
test('★ 判据自检:写侧的错误写法必须判红', () => {
// ① 拿日期键当 event_time
assert.ok(/input\.event_time = this\.selectedIso/.test('input.event_time = this.selectedIso;'),
'拿日期键当时间戳能被"不许"那条命中');
// ② 没有前置校验
assert.ok(!/fTitle\.trim\(\)\.length === 0/.test('async saveEvent() { const a = this.api; }'),
'没有标题校验的写法能被命中');
// ③ 端点写错(PUT 写成 POST)
assert.ok(!/put<CalendarEvent>/.test("post<CalendarEvent>('/calendar/events/' + id, input)"),
'编辑用 POST 能被"必须 PUT"那条命中');
});
/* ───────────────────────── 视图档:月 / 周 / 日 ───────────────────────── */
/*
* 2026-09-18:鸿蒙的日历原来**只有月视图**(`CalendarPage.ets` 的注释列过"没做"清单)。
* 对齐 WebUI `CalendarView.tsx` 的 `type Scale = 'month' | 'week' | 'day'`。
*
* 档位切错**不会报错**,只会静静地不对 —— 所以下面每一条都对着一个具体的错法。
*/
test('★ 翻页步长按档走:月=按天 0(按月翻)、周=7、日=1 —— 认错档位就是静默跳过/原地不动', () => {
assert.equal(C.stepDaysOf('month'), 0, '月档按**月**翻,不按天 —— 返回 0 表示走 addMonths');
assert.equal(C.stepDaysOf('week'), 7, '周档必须按 7 天翻(按 1 天翻看起来像日档)');
assert.equal(C.stepDaysOf('day'), 1, '日档按 1 天翻(按 7 天翻会跳过一周且毫无提示)');
// 自检:三个档的步长必须**互不相同**,否则"分档"本身没意义
const steps = C.SCALE_ORDER.map(s => C.stepDaysOf(s));
assert.equal(new Set(steps).size, 3, `三档步长必须两两不同,实际 ${steps.join('/')}`);
});
test('★ 周档跨月时标题两头都写月份(只写"9月28日-4日"看不出 4 日是十月)', () => {
// 2026-09-30 是周三 → 周一是 09-28,周日是 10-04 ⇒ 跨月
const t = C.rangeTitleOf('week', '2026-09-30', 1);
assert.match(t, /9\.28/, `跨月周标题要写左端月日,实际「${t}」`);
assert.match(t, /10\.4/, `跨月周标题要写右端月日(含月份),实际「${t}」`);
// 不跨月时反过来:不该出现"两个月份"
const same = C.rangeTitleOf('week', '2026-09-18', 1);
assert.ok(!same.includes('–') || (same.match(/月/g) ?? []).length === 1,
`同一月内的周不该写两个月份,实际「${same}」`);
});
test('★ 日档标题带星期;月档标题不带(三档各一种写法,别互相串)', () => {
const d = C.rangeTitleOf('day', '2026-09-18', 1);
assert.match(d, /^2026年9月18日 周./, `日档要「年月日 + 周X」,实际「${d}」`);
assert.equal(C.rangeTitleOf('month', '2026-09-18', 1), '2026年9月', '月档只有年月');
});
test('★ 周/日档的 7 天必须与月网格用**同一个** weekStart(否则同一日期两档列位置不同)', () => {
/*
* 这是"看起来只是排版不同"的那类错:月视图第一列是周一、周视图第一列是周日,
* 同一个日期在两档下会落在不同的列 —— 用户会觉得"切个视图日期就跳位了"。
* 两处都必须从**同一个** `weekStart` 参数出发。
*/
for (const ws of [0, 1]) {
const days = C.weekDaysOf('2026-09-18', ws);
assert.equal(days.length, 7, '一周必须恰好 7 天');
// 这 7 天的星期序列必须等于 weekdayLabels(ws) 的顺序
const names = C.weekdayLabels(ws);
const got = days.map(iso => {
const p = iso.split('-').map(Number);
return C.weekdayNameOf(p[0], p[1], p[2]);
});
assert.deepEqual(got, names, `weekStart=${ws} 时两处星期序必须一致`);
}
});
test('★ 周/日档日期推进:跨月、跨年、闰年都对(与月档同一个 Date.UTC 口径)', () => {
assert.equal(C.addDaysIso('2026-09-30', 1), '2026-10-01', '跨月');
assert.equal(C.addDaysIso('2026-01-01', -1), '2025-12-31', '跨年(往回)');
assert.equal(C.addDaysIso('2024-02-28', 1), '2024-02-29', '闰年二月');
assert.equal(C.addDaysIso('2025-02-28', 1), '2025-03-01', '平年二月');
assert.equal(C.addDaysIso('2026-09-18', 7), '2026-09-25', '周档一整步(前)');
assert.equal(C.addDaysIso('2026-09-18', -7), '2026-09-11', '周档一整步(后)');
// 非法输入不许猜一个日子
assert.deepEqual(C.weekDaysOf('not-a-date', 1), [], '坏日期键要给空数组,不能猜');
});
test('★ 页面:档位必须驱动「标题/步长/格子」三处,且三处都来自 Calendar.ts', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
/*
* 三处里任一处漏改 ⇒ "看起来正常但翻错量/标题不对"。
* 所以判据不只要求"有档位这个状态",而是要求**三个消费者**都在。
*/
assert.match(page, /calScale:\s*CalendarScale/, '要有档位状态(类型来自 Calendar.ts)');
assert.match(page, /stepDaysOf\(\s*this\.calScale\s*\)/, '翻页步长要按档走(不许在页面里写 if/else)');
assert.match(page, /rangeTitleOf\(\s*this\.calScale/, '标题要按档走');
assert.match(page, /this\.calScale === 'month'/, '格子形状要按档走(月=整月网格,其余=一行)');
/*
* ★ 档位状态名**不能**叫 `scale`:ArkUI 的 `CustomComponent` 已经有一个 `scale`
* 属性(通用修饰符 `scale(value: ScaleOptions)`),同名会编译失败
* (实测:`Property 'scale' ... is not assignable to ... '{ (value: ScaleOptions)... }'`)。
* 这条钉住它,免得后人图省事改回去。
*/
assert.ok(!/@State\s+scale\s*:/.test(page),
'档位状态不许叫 `scale` —— 与 ArkUI 内置的 scale 修饰符同名,编译不过');
});
test('★ 页面:周/日档下空态要占满剩余高度(否则下半屏是一大片空壁纸)', () => {
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
const empty = /这一天没有日程[\s\S]{0,200}?layoutWeight\(1\)/.exec(page);
assert.ok(empty,
'空态必须 layoutWeight(1):实测 09-18 周视图下"这一天没有日程"贴顶、下半屏全空');
});