Files
MailUI4Agents/client/electron/test/harmony-calendar.test.mjs
JianFeeeee 13e8671d03 跨端: feat(P6): 表头与网格共用同一个 startOfWeek;"今天"的调用侧入可跑判据;禁用 toISOString 取日期键
pi 2026-09-14 两条,都赶在页面骨架之前定下来。

1. **唯一分叉点必须同时喂两处**:整月网格有两个地方依赖"周从哪天开始" ——
   空格数(`leadingBlanks`)**与表头第一格**。表头若在页面里硬编码,就是**第二个分叉**:
   格子全对、**表头整体错一列**,而原有 6 条判据一条都不会红(它们只看网格)。
   新增 `weekdayLabels(startOfWeek)`(顺序只从这一个参数出)+ **交叉核对判据**:
   把"1 号落在第几列"与"那一列的表头字"对上(6 个月份 × 2 种起始)。

2. **"今天"的调用侧**:`today` 入参化让纯逻辑侧干净了,代价是**唯一还能错的地方搬到了调用侧**
   —— 而它正好是纯逻辑判据够不着的。`toISOString()` 是 UTC 口径:UTC+8 的清晨会给**昨天**,
   "今天"就标到上一格,且在本机跑 UTC 的环境里**永远测不出来**。
   新增 `isoOfLocal(now)`(本地年月日手工补零)+ `isoAtOffset(now, 分钟)`(与前者同源但不依赖进程时区,
   好让三种偏移**可以真跑**)。判据钉住:UTC+8 / UTC-7 / UTC 三种偏移的日期、
   **两种取法在 UTC+8 清晨必须不同**(把陷阱本身钉死)、以及本机两条取法自洽。

3. 附带一条**未来时**的判据:鸿蒙树里不许出现 `toISOString().slice(0,10)` 取日期键
   (登记值 0 ⇒ 页面骨架写错时立刻红,报错写"正确修法 = isoOfLocal"与"最常见的错误修法 = 改期望值")。
   它第一次跑就抓到了 `model/Calendar.ts` 里**解释这个陷阱的注释** —— 所以改扫 `code()`
   (去注释后的代码):规则管代码,注释是文档。
2026-09-14 17:43:09 +08:00

168 lines
9.8 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 } from './lib/read.mjs';
/*
★ 日历的纯逻辑(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 不在本月:不许标在别的月,也不许标错格
assert.equal(C.monthGrid(2026, 9, 1, '2026-10-01').flat().filter(c => c.isToday).length, 0);
});
/*
* ★ 表头与网格必须**来自同一个 `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;
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 ')}`);
});