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 落在邻月时怎么办(2026-09-20 改)── * * 原来这里断的是 `count === 0`("不许标在别的月")。**那条断言是错的**, * 而且错得很隐蔽 —— 它记录的是**旧实现的副作用**,不是一个被设计的不变量: * * 当时 `monthGrid` 把邻月格子填成**空 `DayCell`**(`iso` 是空串), * 于是 `c.iso === today` **永远不可能**匹配 ⇒ `count === 0` 是**恒真的同义反复**。 * 它看起来在守一条规则,实际上什么都没守。 * * 2026-09-20 把邻月格子改成**真实日期**(对齐 WebUI —— 见 `monthGrid` 里那段注释 * 与 `cross-client-logic.test.mjs` 抓出的分叉)之后,这条断言立刻红了。 * * ★ 那到底该标还是不该标?**实测 WebUI 的行为**(不靠推理): * `CalendarView.tsx:547` `const isToday = isSameDay(d, now);` * —— **逐格判断**,所以 2026-10-01 真的出现在 9 月网格的尾部格子里时, * WebUI **会**把它标成今天。这与 Google Calendar 一致,也是合理的 * ("今天"是个客观事实,不该因为它落在你正在看的月份的尾巴上就不标)。 * 实测:`monthGrid(2026,9,1)` 的 42 格里确实含 2026-10-01。 * * ⇒ 真正的不变量不是"邻月不许标",而是**"标了的那一格必须真的是今天"**: * · 至多一格(42 个连续日期里,任一日期至多出现一次) * · 若标了,那一格的 `iso` 必须**等于** today(不是"同月",不是"差一天") * * 这才是原本那句注释里"**不许标错格**"想说的东西 —— * 原断言把"不许标错格"写成了"不许标",窄了一半。 */ const adj = C.monthGrid(2026, 9, 1, '2026-10-01').flat().filter(c => c.isToday); assert.ok(adj.length <= 1, '至多一格被标为今天(今天是唯一的一天)'); for (const c of adj) { assert.equal(c.iso, '2026-10-01', '被标为今天的那一格,它的 iso 必须**就是** today —— 不许标在同月别处、也不许差一天'); } // 反向核对:today 确实会出现在 9 月网格里(否则上面那段就白跑了) const hasOct1 = C.monthGrid(2026, 9, 1).flat().some(c => c.iso === '2026-10-01'); assert.ok(hasOct1, '9 月网格的尾格里应当含 2026-10-01 —— 它不在的话,上面那段是空转'); }); /* * ★ 表头与网格必须**来自同一个 `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\('\/calendar\/events', input\)/, '新建:POST /calendar/events'); assert.match(api, /put\('\/calendar\/events\/' \+ encodeURIComponent\(id\), input\)/, '编辑:PUT /calendar/events/{id}(id 要编码)'); assert.match(api, /del\('\/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/.test("post('/calendar/events/' + id, input)"), '编辑用 POST 能被"必须 PUT"那条命中'); }); /* ───────────────────────── 视图档:月 / 周 / 日 ───────────────────────── */ /* * 2026-09-18:鸿蒙的日历原来**只有月视图**(`CalendarPage.ets` 的注释列过"没做"清单)。 * 对齐 WebUI `CalendarView.tsx` 的 `type Scale = 'month' | 'week' | 'day'`。 * * 档位切错**不会报错**,只会静静地不对 —— 所以下面每一条都对着一个具体的错法。 */ test('★ 翻页步长按档走:月=按天 0(按月翻)、周=7、日=1 —— 认错档位就是静默跳过/原地不动', () => { assert.equal(C.stepDaysOf('month'), 0, '月档按**月**翻,不按天 —— 返回 0 表示走 addMonths'); assert.equal(C.stepDaysOf('week'), 7, '周档必须按 7 天翻(按 1 天翻看起来像日档)'); assert.equal(C.stepDaysOf('day'), 1, '日档按 1 天翻(按 7 天翻会跳过一周且毫无提示)'); // 自检:三个档的步长必须**互不相同**,否则"分档"本身没意义 const steps = C.SCALE_ORDER.map(s => C.stepDaysOf(s)); assert.equal(new Set(steps).size, 3, `三档步长必须两两不同,实际 ${steps.join('/')}`); }); test('★ 周档跨月时标题两头都写月份(只写"9月28日-4日"看不出 4 日是十月)', () => { // 2026-09-30 是周三 → 周一是 09-28,周日是 10-04 ⇒ 跨月 const t = C.rangeTitleOf('week', '2026-09-30', 1); assert.match(t, /9\.28/, `跨月周标题要写左端月日,实际「${t}」`); assert.match(t, /10\.4/, `跨月周标题要写右端月日(含月份),实际「${t}」`); // 不跨月时反过来:不该出现"两个月份" const same = C.rangeTitleOf('week', '2026-09-18', 1); assert.ok(!same.includes('–') || (same.match(/月/g) ?? []).length === 1, `同一月内的周不该写两个月份,实际「${same}」`); }); test('★ 日档标题带星期;月档标题不带(三档各一种写法,别互相串)', () => { const d = C.rangeTitleOf('day', '2026-09-18', 1); assert.match(d, /^2026年9月18日 周./, `日档要「年月日 + 周X」,实际「${d}」`); assert.equal(C.rangeTitleOf('month', '2026-09-18', 1), '2026年9月', '月档只有年月'); }); test('★ 周/日档的 7 天必须与月网格用**同一个** weekStart(否则同一日期两档列位置不同)', () => { /* * 这是"看起来只是排版不同"的那类错:月视图第一列是周一、周视图第一列是周日, * 同一个日期在两档下会落在不同的列 —— 用户会觉得"切个视图日期就跳位了"。 * 两处都必须从**同一个** `weekStart` 参数出发。 */ for (const ws of [0, 1]) { const days = C.weekDaysOf('2026-09-18', ws); assert.equal(days.length, 7, '一周必须恰好 7 天'); // 这 7 天的星期序列必须等于 weekdayLabels(ws) 的顺序 const names = C.weekdayLabels(ws); const got = days.map(iso => { const p = iso.split('-').map(Number); return C.weekdayNameOf(p[0], p[1], p[2]); }); assert.deepEqual(got, names, `weekStart=${ws} 时两处星期序必须一致`); } }); test('★ 周/日档日期推进:跨月、跨年、闰年都对(与月档同一个 Date.UTC 口径)', () => { assert.equal(C.addDaysIso('2026-09-30', 1), '2026-10-01', '跨月'); assert.equal(C.addDaysIso('2026-01-01', -1), '2025-12-31', '跨年(往回)'); assert.equal(C.addDaysIso('2024-02-28', 1), '2024-02-29', '闰年二月'); assert.equal(C.addDaysIso('2025-02-28', 1), '2025-03-01', '平年二月'); assert.equal(C.addDaysIso('2026-09-18', 7), '2026-09-25', '周档一整步(前)'); assert.equal(C.addDaysIso('2026-09-18', -7), '2026-09-11', '周档一整步(后)'); // 非法输入不许猜一个日子 assert.deepEqual(C.weekDaysOf('not-a-date', 1), [], '坏日期键要给空数组,不能猜'); }); test('★ 页面:档位必须驱动「标题/步长/格子」三处,且三处都来自 Calendar.ts', () => { const page = code(join(ETS, 'pages', 'CalendarPage.ets')); /* * 三处里任一处漏改 ⇒ "看起来正常但翻错量/标题不对"。 * 所以判据不只要求"有档位这个状态",而是要求**三个消费者**都在。 */ assert.match(page, /calScale:\s*CalendarScale/, '要有档位状态(类型来自 Calendar.ts)'); assert.match(page, /stepDaysOf\(\s*this\.calScale\s*\)/, '翻页步长要按档走(不许在页面里写 if/else)'); assert.match(page, /rangeTitleOf\(\s*this\.calScale/, '标题要按档走'); assert.match(page, /this\.calScale === 'month'/, '格子形状要按档走(月=整月网格,其余=一行)'); /* * ★ 档位状态名**不能**叫 `scale`:ArkUI 的 `CustomComponent` 已经有一个 `scale` * 属性(通用修饰符 `scale(value: ScaleOptions)`),同名会编译失败 * (实测:`Property 'scale' ... is not assignable to ... '{ (value: ScaleOptions)... }'`)。 * 这条钉住它,免得后人图省事改回去。 */ assert.ok(!/@State\s+scale\s*:/.test(page), '档位状态不许叫 `scale` —— 与 ArkUI 内置的 scale 修饰符同名,编译不过'); }); test('★ 页面:周/日档下空态要占满剩余高度(否则下半屏是一大片空壁纸)', () => { const page = code(join(ETS, 'pages', 'CalendarPage.ets')); const empty = /这一天没有日程[\s\S]{0,200}?layoutWeight\(1\)/.exec(page); assert.ok(empty, '空态必须 layoutWeight(1):实测 09-18 周视图下"这一天没有日程"贴顶、下半屏全空'); });