pi 2026-09-15 两条都成立,第一条**正好落在 `install.sh --check` 跑的那条命令上**。
## 一★★ 新判据在 install 相位**假红**:`records=23 ≠ SUITE.length=25`
```
$ AGENTMAIL_CRITERIA_PHASE=install AGENTMAIL_PROBE_DEVICE=none node test/run-all.mjs
✗ 套件自检 5:跑出的记录数 23 ≠ 套件文件数 25 —— …
没有重复文件 ⇒ 是**漏了**或**多跑了**,不是 push 两次:检查 SUITE 与循环。
```
成因是那 2 条**构建相位判据被相位跳过**、进不了循环 ⇒ 不 push 记录。
★ 而报文给出的成因在这个情形下**恰好是错的**:什么都没漏、也没多跑。
**一条假红配一句假解释**,比单纯假红更坏 —— 读者会照着错的方向去查。
**修法**:期望值改成**本相位实际会跑的文件数**,而且和跳过判定用**同一个谓词**:
```js
const phaseWillRun = SUITE.map(([f]) => f)
.filter(f => !(PHASE === 'install' && sideOf(f) === SIDE.SOURCE));
```
同一个事实两份实现就会漂移 —— 所以不给它第二份。
**两相位都验了**(不是只验改坏的那个):
| 相位 | 之前 | 现在 |
|---|---|---|
| build | `ok 5` | `ok 5`(`RESULT` 逐字节不变) |
| install | **`✗ 假红`** | **`ok 5`** |
注入"push 两次"复验**两相位都还抓得住**:build `50 ≠ 25`、install `46 ≠ 23`,都点名重复文件
(诊断方向这次是**对的**:"push 了两次"而非"漏了")。
★ 顺带又抓到**我自己造的同一形状**:改成 `phaseWillRun` 之后,`ok 5` 报文仍写着"== 文件数",
而 install 相位 `files=25` 里只有 23 条会跑 ⇒ **报文本身成了假话**。已改成
"== **本相位实际会跑的** 23 条(套件 25 条里本相位跳过 2 条)"。
**"改了实现没改说法"这是第四次** —— 这四次都是同一个动作,我把它当成本轮的主要账。
## 二★★ `stripStrings` 两份:删掉兄弟副本,改用**保行号**的那份
你说得对,而且我实测出**哪一份才是该活的**:
- `lib/read.mjs` 那份(经那五轮边界修、且**保留换行**)—— 是**死导出**;
- `harmony-calendar.test.mjs:18` 那份(正则近似)—— 是**活的**,而且**不保行数**。
实测 89 个 `.ets`/`.ts:
```
行数不守恒的: 3 (例:plugins/dsh-mail-bridge/src/index.ts 原 2248 → 2245)
抹除结果不同的: 75
```
★ 关键在**那条判据打印 `文件:行号`**(`hits.push(\`…:${i+1} …\`)`)——
行号是它**唯一**的定位手段,而行数一少,**跨行模板之后的报错行号整体前移**。
**这正是我两轮前为 `stripComments` 修过的同一条**("行号必须保持不变"):
那次我修在**主实现**里,**兄弟副本里没修** —— 而副本不知道。
**处置**:删掉本地那份,`harmony-calendar.test.mjs` 改用 `read.mjs` 那份(`import { code, stripStrings }`)。
**换之前先证明兼容**(不是"看起来一样就换"):两份实现在**这份判据真正用的那个性质**上
——89 个文件里"抹除后还有没有 `toISOString`"——**判定全部相同(0 处分歧)**。
⇒ 换实现**不改判据结论**,只是让行号可信。实测 `harmony-calendar` 23/23 通过。
★ 我认你那句"一个刚花五轮才证明脆弱的东西有两份实现,等于第六轮只会被修在其中一份里"。
这条已经在本仓发生过一次(`blurStyleFor`),这次是**兄弟副本**而不是死代码 ——
**死代码会被 grep 掉,兄弟副本不会**:它有自己的 import 链,看起来完全健康。
## 三、其余
- 两相位 `RESULT`:build `files=25 ran=25 checks=400 pass=399 fail=1 red=4 broken=0 unreported=0`;
install `files=25 ran=23 checks=388 pass=388 fail=0 red=3 broken=0 unreported=0`。
- `criteria-hygiene` 6/6 绿。
- 红线条数没变(都不是我的)。
388 lines
25 KiB
JavaScript
388 lines
25 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, stripStrings } from './lib/read.mjs';
|
||
|
||
/*
|
||
* `code()` 去的是**注释**,不是**字符串**(pi 2026-09-14 §2):任何只是"提到"被禁写法的
|
||
* 字符串(UI 提示、dev 警告、甚至报错文案自己)都会把它自己判红 —— 那不是它要管的东西。
|
||
*
|
||
* **口径现在就定下来**(两条路里选"只在表达式位置匹配"):先整段去掉字符串字面量,再匹配。
|
||
* 于是"提到"不再误红,而**真的用它取日期键**照样红。
|
||
* **出现同族误红时不许"再改一次扫描口径"把判据往宽里挪。**
|
||
*
|
||
* ★★ 这里原来有**自己的一份** `stripStrings`(正则近似),现在改用 `lib/read.mjs` 那份
|
||
* (pi 2026-09-15 抓到"同一件东西两份实现")。三条理由,按重要性:
|
||
*
|
||
* ① **这份判据打印 `文件:行号`**(`hits.push(\`…:${i+1} …\`)`),行号是它**唯一**的定位手段。
|
||
* 旧那份不保证行数守恒 —— 实测 89 个 `.ets`/`.ts` 里 **3 个**抹除后行数变少
|
||
* (`plugins/dsh-mail-bridge/src/index.ts` 原 2248 → 2245),于是跨行模板之后的
|
||
* **报错行号整体前移**。**这正是我两轮前为 `stripComments` 修过的同一条**
|
||
* ("行号必须保持不变")—— 那次修在主实现里,**兄弟副本里没修**。
|
||
* ② `read.mjs` 那份是**逐字符**扫的(不是正则近似),**保留引号与换行** ⇒ 行数守恒
|
||
* (实测 89/89 不变,旧那份 3 个不对)。
|
||
* ③ 两份里**只有一份**经过那五轮边界修(注释/字符串/等价写法/锚/正则引号)——
|
||
* 留两份,第六轮只会被修在其中一份里。**不给自己留兄弟副本。**
|
||
*
|
||
* ★ 换之前我实测过兼容性:两份实现在**这份判据真正用的那个性质**上结论一致
|
||
* ——89 个文件里"抹除后还有没有 `toISOString`"**全部相同**(0 处分歧)。
|
||
* ⇒ 换实现**不改判据结论**,只是让行号可信。
|
||
*/
|
||
|
||
/*
|
||
★ 日历的纯逻辑(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`,
|
||
* **2026-09-14 已结算**:日历页落地时用 `@Prop @Watch('onVisibleChanged') visible` 实现,
|
||
* 判据见本文件末尾的「★ today 在 pane **变可见时**重算」。
|
||
*/
|
||
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,
|
||
'不传就不许有今天(不许内部兜底取系统时间 —— 那正是"取一次"的写法)');
|
||
});
|
||
|
||
/*
|
||
* ═══════════════════════════════════════════════════════════════════
|
||
* P6 第 1 步的**页面那一半**(2026-09-14:日历入口上架)
|
||
*
|
||
* 上面那些钉的是"算术对不对";从这里开始钉"页面有没有如实消费它"。
|
||
* 两者分工明确:算术错 → 日子错位;消费错 → 数据对但画错、或永远画不出来。
|
||
*/
|
||
|
||
test('页面:网格与表头都走 Calendar.ts 的同一套入参(WEEK_START=1,与 WebUI 同值)', () => {
|
||
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
|
||
// 网格:行来自 monthGrid,且 weekStart 传的是常量(不是字面量 0/1 —— 那会与表头分叉)
|
||
assert.match(page, /monthGrid\(this\.year, this\.month, WEEK_START, this\.todayIso\)/,
|
||
'网格必须由 monthGrid 生成,并把 WEEK_START 与 todayIso 一起传进去');
|
||
assert.match(page, /weekdayLabels\(WEEK_START\)/,
|
||
'表头顺序必须来自 weekdayLabels(WEEK_START) —— 硬编码成 日/一/二… 就是第二个分叉点(表头整体错一列)');
|
||
const decl = /const WEEK_START: number = (\d)/.exec(page);
|
||
assert.ok(decl, 'WEEK_START 要是常量,判据要能读到它');
|
||
assert.equal(decl[1], '1', '周起始必须是周一(1)—— 与 WebUI lib/calendar.ts 的 startOfWeek 同值,两端不同值会让同一天落在不同列');
|
||
});
|
||
|
||
test('页面:事件按 localIsoOf 归日,且不自己切时间戳', () => {
|
||
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
|
||
assert.match(page, /localIsoOf\(/, '事件归日必须走 localIsoOf');
|
||
assert.ok(!/\.slice\(0, 10\)/.test(page),
|
||
'页面里不许切时间戳前 10 位取日期(那是 UTC 日期 ⇒ UTC+8 的凌晨会把事件画到前一天)');
|
||
// 会话/列表侧的日期格式化也不许自己切(同一族:只有 localIsoOf/hhmmAtOffset 两个出口)
|
||
assert.match(page, /hhmmAtOffset\(/, '钟点显示走 hhmmAtOffset(偏移是入参,可判据)');
|
||
});
|
||
|
||
test('★ localIsoOf / hhmmAtOffset:偏移是入参,所以三个时区都能真跑', () => {
|
||
// UTC+8 的 09-15 00:30 在 UTC 里是 09-14T16:30Z —— 切字符串会给 09-14(错一天)
|
||
assert.equal(C.localIsoOf('2026-09-14T16:30:00Z'), C.isoAtOffset(new Date('2026-09-14T16:30:00Z'), C.deviceOffsetMinutes(new Date('2026-09-14T16:30:00Z'))),
|
||
'localIsoOf 必须与 isoAtOffset(设备偏移) 同口径');
|
||
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', 480), '00:30', 'UTC+8 的钟点');
|
||
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', -480), '08:30', 'UTC-8 的钟点');
|
||
assert.equal(C.hhmmAtOffset('2026-09-14T16:30:00Z', 0), '16:30', 'UTC 的钟点');
|
||
// 坏值不许把整个月画崩
|
||
assert.equal(C.localIsoOf('not-a-time'), '');
|
||
assert.equal(C.hhmmAtOffset('not-a-time', 480), '');
|
||
});
|
||
|
||
test('★ today 在 pane **变可见时**重算(DEBTS 的 calendar-today-recompute)', () => {
|
||
/*
|
||
* 这条欠账的到期前提就是"日历页面骨架写完后立刻",现在到期了 —— 于是要么实现、要么改前提。
|
||
* 实现方式:`@Prop @Watch('onVisibleChanged') visible` + aboutToAppear 各自重算一次。
|
||
*
|
||
* 为什么两条都要:
|
||
* · 重新挂载(切页签)→ aboutToAppear 重算 ✓;
|
||
* · 常驻 pane 一直开着跨过午夜 → **只有 @Watch(visible)** 能救(卸载重挂不会发生)。
|
||
* 判据盯的是"重算路径存在且绑定在可见性上",不是"今天恰好是几号"(那个由纯逻辑判据管)。
|
||
*/
|
||
const raw = code(join(ETS, 'pages', 'CalendarPage.ets'));
|
||
assert.match(raw, /@Prop @Watch\('onVisibleChanged'\) visible: boolean/,
|
||
'visible 必须挂 @Watch —— 否则常驻 pane 跨午夜后 today 停在昨天');
|
||
assert.match(raw, /onVisibleChanged\([^)]*\): void \{[\s\S]{0,200}?refreshToday\(\)/,
|
||
'onVisibleChanged 里必须重算 today');
|
||
assert.match(raw, /refreshToday\(\)[\s\S]{0,60}isoOfLocal\(new Date\(\)\)/,
|
||
'today 只能由 isoOfLocal(new Date()) 求(toISOString 是 UTC 口径,清晨会给出昨天)');
|
||
// 首次可见才拉数据:常驻挂载 ≠ 常拉(隐藏着也发请求是给用户白花的流量/电)
|
||
assert.match(raw, /if \(!this\.loadedOnce\)/,
|
||
'首次可见才拉数据 —— 常驻挂载不等于启动就拉');
|
||
});
|
||
|
||
test('页面:翻月走 addMonths(跨年两个方向已被纯逻辑钉过),不自己算 month+1', () => {
|
||
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
|
||
assert.match(page, /addMonths\(this\.year, this\.month, delta\)/, '翻月必须复用 addMonths');
|
||
assert.ok(!/this\.month \+ 1|this\.month - 1/.test(page),
|
||
'页面里不许自己算 month±1 —— "翻到 13 月"就是那样来的(纯逻辑判据已覆盖 addMonths,别在页面重写一遍)');
|
||
});
|
||
|
||
test('★ 判据自检:把错误写法喂进来必须判红', () => {
|
||
// ① 自己切时间戳取日期键(UTC 口径 ⇒ 错一天)
|
||
assert.ok(/\.slice\(0, 10\)/.test("localIsoOf(e.event_time.slice(0, 10))"), '切时间戳能被"不许切"这条命中');
|
||
// ② 表头硬编码(与网格分叉)
|
||
assert.ok(!/weekdayLabels/.test("['日','一','二','三','四','五','六']"), '硬编码表头能被"必须来自 weekdayLabels"命中');
|
||
// ③ month±1 自算
|
||
assert.ok(/this\.month \+ 1/.test('const m = this.month + 1;'), '自算 month+1 能被命中');
|
||
// ④ 没有 @Watch(today 跨午夜停在昨天)
|
||
assert.ok(!/@Prop @Watch\('onVisibleChanged'\)/.test('@Prop visible: boolean = true;'), '缺 @Watch 能被命中');
|
||
});
|
||
|
||
/*
|
||
* ═══════════════════════════════════════════════════════════════════
|
||
* P6 第 2 步:写侧(新建 / 编辑 / 删除 / 暂停恢复)
|
||
*
|
||
* 这一层最容易"看起来能用但实际没写进去":表单填了、按钮点了、界面回到日历 ——
|
||
* 而请求发错端点、字段名写错(严格解码直接 400)、或者时间戳拿日期键顶替
|
||
* (事件被存到另一天的 00:00)。所以判据钉的是**端点与载荷**,不是"有个保存按钮"。
|
||
*/
|
||
|
||
test('写侧:三个端点各自的路径与动词(GET 之外)', () => {
|
||
const api = code(join(ETS, 'api', 'CalendarApi.ets'));
|
||
assert.match(api, /post<CalendarEvent>\('\/calendar\/events', input\)/, '新建:POST /calendar/events');
|
||
assert.match(api, /put<CalendarEvent>\('\/calendar\/events\/' \+ encodeURIComponent\(id\), input\)/,
|
||
'编辑:PUT /calendar/events/{id}(id 要编码)');
|
||
assert.match(api, /del<CalendarDeleteResponse>\('\/calendar\/events\/' \+ encodeURIComponent\(id\)\)/,
|
||
'删除:DELETE /calendar/events/{id}');
|
||
// 编辑与新建必须发**同一份**输入类型(服务端两个端点同形;拆两份会在严格解码下 400)
|
||
assert.match(api, /createEvent\(input: CalendarEventInput\)/, '新建收 CalendarEventInput');
|
||
assert.match(api, /updateEvent\(id: string, input: CalendarEventInput\)/, '编辑收同一份 CalendarEventInput');
|
||
});
|
||
|
||
test('写侧:提交的是**时间戳**(不是日期键),回填走 localDateOf', () => {
|
||
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
|
||
assert.match(page, /isoTimestampOfLocal\(this\.fDate\.getFullYear\(\)/, '提交时由本地年月日+时分合成时间戳');
|
||
assert.ok(!/event_time = .*localIsoOf|input\.event_time = this\.selectedIso/.test(page),
|
||
'不许把日期键(YYYY-MM-DD)当 event_time 发出去 —— 那会被解析成当天 00:00,事件落到错误的小时上');
|
||
assert.match(page, /localDateOf\(e\.event_time\)/, '编辑回填要用 localDateOf 把时间戳还原成本地 Date');
|
||
});
|
||
|
||
test('★ 写侧:空标题 / 空收件人**在本地就挡住**,不发注定 400 的请求', () => {
|
||
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
|
||
const save = page.slice(page.indexOf('async saveEvent()'), page.indexOf('async deleteEvent()'));
|
||
assert.ok(save.length > 200, '要能取到 saveEvent 的正文');
|
||
// 两个前置校验都要在**发起请求之前**(`const input` 之前)
|
||
const beforeInput = save.slice(0, save.indexOf('const input'));
|
||
assert.match(beforeInput, /this\.fTitle\.trim\(\)\.length === 0/, '标题为空要拦住');
|
||
assert.match(beforeInput, /this\.recipientList\(\)\.length === 0/, '收件人为空要拦住');
|
||
assert.match(beforeInput, /return;/, '拦住就是 return,不许继续发请求');
|
||
});
|
||
|
||
test('★ 写侧:服务端 400 的文案原样显示(不是"保存失败"四个字)', () => {
|
||
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
|
||
assert.match(page, /private errorTextOf\(e: Object, fallback: string\): string/,
|
||
'要有一个把 ApiError 转成用户可读文案的函数');
|
||
assert.match(page, /return err\.message\.length > 0 \? err\.message/, '服务端文案优先');
|
||
// 三处调用都走它(保存 / 删除 / 改状态)
|
||
const calls = [...page.matchAll(/this\.errorTextOf\(e, '/g)].length;
|
||
assert.ok(calls >= 3, `保存、删除、改状态都要走 errorTextOf(实际 ${calls} 处)—— 吞掉服务端文案等于让人猜`);
|
||
});
|
||
|
||
test('写侧:删除有二次确认、暂停恢复走同一个 update', () => {
|
||
const page = code(join(ETS, 'pages', 'CalendarPage.ets'));
|
||
assert.match(page, /this\.confirmDelete \? '确认删除' : '删除'/, '删除按钮要两态');
|
||
assert.match(page, /if \(this\.confirmDelete\) \{\s*this\.deleteEvent\(\);/, '第一下只是置位,第二下才真删');
|
||
// 暂停/恢复:改 status 后走 update(不是删除重加 —— 那会丢 event_id 与历史)
|
||
const toggle = page.slice(page.indexOf('async toggleStatus()'), page.indexOf('private errorTextOf'));
|
||
assert.match(toggle, /this\.fStatus === 'active' \? 'paused' : 'active'/, '暂停/恢复就是 active ↔ paused');
|
||
assert.match(toggle, /a\.updateEvent\(this\.editingId, input\)/, '走 updateEvent');
|
||
});
|
||
|
||
test('★ isoTimestampOfLocal:本地年月日时 → 时间戳 → 回读还是同一格(含跨日)', () => {
|
||
const iso = C.isoTimestampOfLocal(2026, 9, 17, 10, 20);
|
||
assert.equal(C.localIsoOf(iso), '2026-09-17', '归日要回到同一天');
|
||
assert.equal(C.hhmmAtOffset(iso, C.deviceOffsetMinutes(C.localDateOf(iso))), '10:20', '钟点要回到同一时刻');
|
||
// 跨日边界:23:59 与 00:01 必须归到不同的两天(否则"跨午夜"那类事件会串天)
|
||
assert.notEqual(C.localIsoOf(C.isoTimestampOfLocal(2026, 9, 17, 23, 59)),
|
||
C.localIsoOf(C.isoTimestampOfLocal(2026, 9, 18, 0, 1)));
|
||
// 坏值回填不许抛
|
||
assert.ok(C.localDateOf('not-a-time') instanceof Date, '坏时间戳要给一个可用 Date,不能让表单崩');
|
||
});
|
||
|
||
test('★ 判据自检:写侧的错误写法必须判红', () => {
|
||
// ① 拿日期键当 event_time
|
||
assert.ok(/input\.event_time = this\.selectedIso/.test('input.event_time = this.selectedIso;'),
|
||
'拿日期键当时间戳能被"不许"那条命中');
|
||
// ② 没有前置校验
|
||
assert.ok(!/fTitle\.trim\(\)\.length === 0/.test('async saveEvent() { const a = this.api; }'),
|
||
'没有标题校验的写法能被命中');
|
||
// ③ 端点写错(PUT 写成 POST)
|
||
assert.ok(!/put<CalendarEvent>/.test("post<CalendarEvent>('/calendar/events/' + id, input)"),
|
||
'编辑用 POST 能被"必须 PUT"那条命中');
|
||
});
|