跨端: 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:
@ -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 ')}`);
|
||||
});
|
||||
|
||||
@ -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 的具名入口
|
||||
|
||||
@ -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())}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user