Files
MailUI4Agents/client/electron/test/harmony-calendar.test.mjs
JianFeeeee f4f9174865 跨端: 自检 5 在 install 相位假红(期望值改用本相位实际会跑的文件数)+ 删掉 harmony-calendar 那份 stripStrings 兄弟副本(改用保行号的那份)
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 绿。
- 红线条数没变(都不是我的)。
2026-09-15 13:46:56 +08:00

388 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"那条命中');
});