跨端: 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()`
   (去注释后的代码):规则管代码,注释是文档。
This commit is contained in:
2026-09-14 17:43:09 +08:00
parent e94e4dcd39
commit 13e8671d03
3 changed files with 138 additions and 1 deletions

View File

@ -2,6 +2,8 @@ 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 步的逻辑那一半)—— **不需要设备**,所以它不该进静态欠账。
@ -83,3 +85,83 @@ test('"今天":只有传入的那一天被标出来,且标在本月格子内
// 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 ')}`);
});

View File

@ -77,7 +77,7 @@ const SUITE = [
['test/appearance-defaults.test.mjs', [], 4],
['test/build-stamp.test.mjs', [], 7],
['test/packaging.test.mjs', [], 5],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 9],
['test/debt-visibility.test.mjs', [], 1],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2],
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口