Files
MailUI4Agents/client/electron/test/harmony-calendar.test.mjs
pi d3f6f3baf6 跨端: 鸿蒙日历写侧(新建/编辑/删除/暂停恢复)+ 表单不再是一面滚轮墙
用户:「是的去吧」(批准上一封列出的下一步)。

- CalendarApi:createEvent / updateEvent / deleteEvent(POST /calendar/events、
  PUT/DELETE /calendar/events/{id});新建与编辑发**同一份** CalendarEventInput
  —— 服务端两个端点同形(CreateCalendarEvent 里那个 Status 字段的注释就写着这句),
  拆两份会在严格解码下 400
- CalendarPage:写侧表单(新建与编辑共用一份)、二次确认删除、暂停/恢复走 update
  (调度器只触发 active;没有暂停就只能删掉重加,而那会丢 event_id 与历史)
- 服务端 400 文案原样显示(收件地址无法解析:…)—— 建事件时就校验地址,
  吞成保存失败等于让人猜,而猜的代价是以为设好了、实际永远发不出去
- 空标题/空收件人在本地就挡住,不发注定 400 的请求
- 提交的是**时间戳**(isoTimestampOfLocal),不是日期键;编辑回填走 localDateOf
- ★ 表单形状:日期/时间选择器合起来 ~1400px,展开着放会把标题与收件人挤出屏幕 ——
  改成一行摘要 + 点开展开(默认收起)。这不是美观问题:展开态下保存在屏幕外,
  要跨过一整面滚轮才够得着
- 判据 +7(端点/载荷/前置校验/文案/二次确认/时间戳往返/变异自检),23 条全绿
- 真机实测(hvigorw + hdc + uitest 逐字段输入 + 逐键点击,每一步都回库核对):
  建 → 库里 event_time 是 2026-09-28T01:00Z(= 本地 09:00 整,默认值正确)、
  暂停 → status=paused、删除第一下只变确认删除(库里还在)、第二下才真删(库里 0 条)
2026-09-14 18:57:53 +08:00

379 lines
24 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 } 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`,
* **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"那条命中');
});