pi 2026-09-14 骨架前最后两条。
1. **`today` 必须"显示时求值"**:顶层是「通信 / 日历 / 联系人」三个平级 pane,日历**常驻**,
而 `today` 是唯一随时间变的输入。建页取一次 ⇒ 跨午夜后标记停在昨天,**判据全绿**
(模型没错,是喂进去的值过期了)—— 与 `Calendar.ts` 防的"上次渲染残留"同一件事,
只是残留在**调用侧**。
· 纯逻辑侧判据(新增第 10 条):连续两次不同输入各自独立(不许跨调用残留)、
推进一天后值必须变、**不传就不许有今天**(不许内部兜底取系统时间 —— 那正是"取一次"的写法);
· 调用侧义务(pane 变可见时重算,不许只在 aboutToAppear)进余额:
`calendar-today-recompute`(kind=scope),到期前提写明"骨架写完后立刻"。
2. **`code()` 去注释、不去字符串**:任何只是**提到**被禁写法的字符串会被自己判红。
两条路("报错文案不引被禁模式" vs "只在表达式位置匹配")现在就定,选后者:
先**整段去掉字符串字面量**再匹配 —— "提到"不再误红,"真的用它取日期键"照样红。
副作用写在旁边(正则近似、非解析器;转义/跨行模板处理不干净 ⇒ 是采样不是完备性证明),
并写明:**出现同族误红时不许"再改一次扫描口径"把判据往宽里挪**。
213 lines
12 KiB
JavaScript
213 lines
12 KiB
JavaScript
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';
|
||
|
||
/*
|
||
* `code()` 去的是**注释**,不是**字符串**(pi 2026-09-14 §2):任何只是"提到"被禁写法的
|
||
* 字符串(UI 提示、dev 警告、甚至报错文案自己)都会把它自己判红 —— 那不是它要管的东西。
|
||
*
|
||
* **口径现在就定下来**(两条路里选"只在表达式位置匹配"):先整段去掉字符串字面量,再匹配。
|
||
* 于是"提到"不再误红,而**真的用它取日期键**照样红。
|
||
* 副作用写在旁边,免得下一个人当成漏网:这是**正则近似、不是解析器** ——
|
||
* 转义引号、跨行模板、嵌套模板都处理不干净,它是采样而非完备性证明。
|
||
* **出现同族误红时不许"再改一次扫描口径"把判据往宽里挪。**
|
||
*/
|
||
function stripStrings(src) {
|
||
return src
|
||
.replace(/`(?:[^`\\]|\\.)*`/g, '``')
|
||
.replace(/'(?:[^'\\\n]|\\.)*'/g, "''")
|
||
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""');
|
||
}
|
||
|
||
/*
|
||
★ 日历的纯逻辑(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;
|
||
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`。
|
||
*/
|
||
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,
|
||
'不传就不许有今天(不许内部兜底取系统时间 —— 那正是"取一次"的写法)');
|
||
});
|