diff --git a/client/electron/test/harmony-calendar.test.mjs b/client/electron/test/harmony-calendar.test.mjs index 0d234e8..001b37c 100644 --- a/client/electron/test/harmony-calendar.test.mjs +++ b/client/electron/test/harmony-calendar.test.mjs @@ -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 ')}`); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 5c38d36..4b82cc9 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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 的具名入口 diff --git a/client/harmony/entry/src/main/ets/model/Calendar.ts b/client/harmony/entry/src/main/ets/model/Calendar.ts index 02eaa6a..4b01fb2 100644 --- a/client/harmony/entry/src/main/ets/model/Calendar.ts +++ b/client/harmony/entry/src/main/ets/model/Calendar.ts @@ -89,3 +89,58 @@ export function monthGrid(year: number, month: number, weekStart: number, today? } return rows; } + +/** + * 星期表头 —— **必须与 `leadingBlanks` 来自同一个 `startOfWeek`**(pi 2026-09-14)。 + * + * 整月网格有两个地方依赖"周从哪天开始":**空格数**与**表头第一格**。 + * 如果表头在页面里硬编码成 `['日','一',…]` 或另算一次,那就是**第二个分叉点**: + * 格子位置全对、**表头整体错一列** —— 而这是肉眼第一眼就看到的东西, + * 纯逻辑判据却一条都不会红(它们只看了网格)。 + * + * 所以:字的写法可以硬编码在这里,**顺序只能从这一个参数出**。 + */ +export const WEEKDAY_NAMES: string[] = ['日', '一', '二', '三', '四', '五', '六']; + +export function weekdayLabels(startOfWeek: number): string[] { + const s: number = ((startOfWeek % 7) + 7) % 7; + const out: string[] = []; + for (let i = 0; i < 7; i++) { + out.push(WEEKDAY_NAMES[(s + i) % 7]); + } + return out; +} + +/** 某一天是星期几的**名字**(用于把表头与网格对齐做交叉核对) */ +export function weekdayNameOf(year: number, month: number, day: number): string { + return WEEKDAY_NAMES[new Date(Date.UTC(year, month - 1, day)).getUTCDay()]; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : `${n}`; +} + +/** + * "今天"的**调用侧**(pi 2026-09-14 §2,趁骨架之前定下来)。 + * + * 为什么单独抽出来:`today` 入参化之后,`Calendar.ts` 是纯粹的, + * **唯一还能错的地方搬到了调用侧** —— 而它恰好是纯逻辑判据够不着的那一格。 + * + * 陷阱:UTC+8 的早上 7 点,`new Date().toISOString().slice(0,10)` 给的是**昨天**, + * 而网格的 `iso` 键是 `Date.UTC` 造的 ⇒ "今天"会被标到昨天那一格, + * 且在本机跑在 UTC 的环境里**永远测不出来**。 + * 所以页面侧取今天**只许**用这个函数(本地年月日手工补零),不许用 `toISOString()`。 + */ +export function isoOfLocal(now: Date): string { + return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`; +} + +/** + * 按**给定偏移**取日期(与 `isoOfLocal` 同源,但不依赖进程时区)—— + * 好让"UTC+8 / UTC-8 / UTC 三种下取到的本地日期都对"这条判据可以**真跑**, + * 而不必依赖跑判据的机器正好在哪个时区。 + */ +export function isoAtOffset(now: Date, offsetMinutes: number): string { + const shifted = new Date(now.getTime() + offsetMinutes * 60000); + return `${shifted.getUTCFullYear()}-${pad2(shifted.getUTCMonth() + 1)}-${pad2(shifted.getUTCDate())}`; +}