用户:「你为什么不加载鸿蒙开发相关skill?」—— 说得对。那份
`arkts-grammar-standards` 写着 "REQUIRED before writing the first .ets file of a
session",而我这轮一直在写 `.ets`。补加载后照它的规则表**逐条回扫**,
当场抓出两处此前没人管的违规。
══ ① 用户要做的 B:`cross-client-logic.test.mjs`(新,6 条判据)
背景:两套纯逻辑各写一份且已分叉(replyTarget 214/170 行、mailGroups 178/459、
appearance 187/324、calendar 208/446)。当天已**踩到**两处分叉
(`participantAddress` 的 `||`、`ThreadPage` 字段全错)。
做法:**同一张用例表喂给两边,逐条比结果**(`--experimental-strip-types`
直接在 node 里跑两侧源码 —— 两边的 model 层都是纯逻辑、无 SDK 依赖)。
不选"生成一份共享源码":harmony 不能 import 工程外文件,
且两边类型系统不同(ArkTS 禁解构/any/对象字面量要具名类型),
生成器要维护"两边都能过"的子集,是另一个大工程。
★ **首轮运行就报出两处真分叉,都不是我踩到才发现**:
① `formatAddress('dsh', undefined, undefined)`:electron 返回 `"dsh"`,
harmony **抛** `Cannot read properties of undefined`。
—— 又是 `omitempty` 那个坑(**第三次**),这次是判据先报的。
② `monthGrid`:electron **固定 6 行**(`grid-rows-6`),harmony **4~6 行**
⇒ 翻月时网格高度跳动。WebUI 的注释明写要避免这个("行数变化会让整个
网格高度跳动,翻月时页面内容上下弹")。
③ 顺着 ② 又发现:WebUI 邻月格子**填真实日期并置灰、可点**
(`CalendarView.tsx:545-556`),harmony 留**空白格**。
★ 判据自身的两次错,都留了档(判据的 bug 与代码的 bug 一样危险):
· 第一版把 `args[0]` 当单个参数传,字符串被当可迭代对象展开 ⇒
`formatAddress('d','s','h')` —— **判据自己造出假分叉**。
· 第一版 `weekStart` 传 0(周日),而两端实际都是 1(周一)⇒ 又一处假分叉。
差一点就去"修"一个不存在的问题。
· `monthGrid` 的投影第一版按 `inMonth ? [y,m,day] : null`,
把"邻月填不填真日期"这个**真分叉**抹平了 —— 投影只该换表示,不该替我看不看。
★ 三类"不同"要分清(写进文件头):**命名不同**(投影归一,不是分叉)、
**签名不同**(ArkTS 没 Date 重载习惯;语义必须一样)、**行为不同**(是分叉,以 electron 为准)。
══ ② skill 回扫抓出的两处隐形债(编译器只告警、判据也不管)
· **正则字面量**(`arkts-no-regexp-literals`):`MailDetailPage.ets:615` 的
`/^\d+$/`(从 2026-09-19 活到今天)。
· **废弃的全局 `router`**:`api/Logout.ets:76` 的 `router.replaceUrl(...)`。
它是个独立函数(没有 `this`)⇒ 拿不到 `UIContext`,改成由调用方传
(两个调用点都持有 `getUIContext()`,零成本)。
★ 这两条为什么能活这么久:**编译器对它们只告警、不挡构建**,
全仓也**没有判据**管 ⇒ 规则事实上不存在。已补两条判据,都做了变异验证。
══ ③ 顺带修正一条**恒真的同义反复**断言
`harmony-calendar` 里 "today 不在本月:不许标在别的月" 那条:
它是在"邻月格子是空 `DayCell`(`iso` 为空串)"时写的 ⇒ `c.iso === today`
**永远不可能**匹配 ⇒ `count === 0` 恒真,**看起来守着一条规则,其实什么都没守**。
改成真不变量:**"被标为今天的那一格,iso 必须就是 today;至多一格"**,
并反向核对"2026-10-01 确实出现在 9 月网格里"(否则那段是空转)。
实测 WebUI `CalendarView.tsx:547` 是逐格 `isSameDay` ⇒ **它会标**,
所以原来那条"不许标"本身就窄了一半。
══ ④ 登记两处盘点发现(**没有**顺手改,因为需要人决定)
· `harmony-dead-pages`:`InboxPage.ets`(238 行) 不可达(不在页面表、无人导航),
`SessionsPage.ets`(170 行) 唯一引用来自 InboxPage ⇒ 一起不可达。
没删是因为 `HARMONY-ALIGN-PLAN.md:214` 把它当变异测试靶子用过 ——
删掉会永久丢代码,是否只是"早期留存"我判断不了。
· `harmony-permission-history`:WebUI 授权栏显示**待决 + 已决策历史**两段
(拿 inbox 自己分组,`PermissionList.tsx:27/174/182`);鸿蒙调专用端点
`/permission/pending`(SQL `WHERE pr.result IS NULL`)⇒ **只拿得到待决的**。
已在 `cross-client-logic` 的 gaps 里如实登记,判据会盯着"不要再少"。
══ 判据状态
`files=33 ran=33 checks=530 pass=530 fail=0 skip=0 red=0 broken=0 unreported=0`;
`baseline=7/7✓`(底本第 9 次重算,已按规矩先 `git diff --quiet HEAD` 取证 + 记录理由)。
`verdict=red` 残余仍是 5 条静态判据的**设备到期提示**(既有机制)。
══ 环境
模拟器昨天起卡死(hdc 能连、shell 超时、CPU 150%、跑了 34 小时),
导致设备判据各跑 836 秒后失败 —— 看起来像"套件卡死"。用户批准后杀掉重启
(`Emulator -start HATriple -noWindow`,`devecocli` 那套因 x11 起不来),
现在**75~150 秒**跑完整套。
514 lines
32 KiB
JavaScript
514 lines
32 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 落在邻月时怎么办(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<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"那条命中');
|
||
});
|
||
|
||
/* ───────────────────────── 视图档:月 / 周 / 日 ───────────────────────── */
|
||
|
||
/*
|
||
* 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 周视图下"这一天没有日程"贴顶、下半屏全空');
|
||
});
|