跨端: feat(P6): 日历纯逻辑 model/Calendar.ts + 6 条可跑判据(第 1 步的逻辑那一半,不需要设备)

This commit is contained in:
2026-09-14 17:41:26 +08:00
parent 9fd89308ab
commit e94e4dcd39
4 changed files with 198 additions and 11 deletions

View File

@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
/*
★ 日历的纯逻辑(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);
});

View File

@ -77,6 +77,7 @@ const SUITE = [
['test/appearance-defaults.test.mjs', [], 4], ['test/appearance-defaults.test.mjs', [], 4],
['test/build-stamp.test.mjs', [], 7], ['test/build-stamp.test.mjs', [], 7],
['test/packaging.test.mjs', [], 5], ['test/packaging.test.mjs', [], 5],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
['test/debt-visibility.test.mjs', [], 1], ['test/debt-visibility.test.mjs', [], 1],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2], ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2],
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口 // 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口
@ -547,7 +548,8 @@ try {
: debtRegStatic === measuredStatic : debtRegStatic === measuredStatic
? `static-only=${measuredStatic}==登记 ✓` ? `static-only=${measuredStatic}==登记 ✓`
: `static-only=${measuredStatic}≠登记${debtRegStatic} ✗`; : `static-only=${measuredStatic}≠登记${debtRegStatic} ✗`;
debtsLine = ` debts=${total} (${staticNote} / other=${other} by go-test)(${items})`; const env = led.debts.filter(d => d.kind === 'env').reduce((n, d) => n + d.count, 0);
debtsLine = ` debts=${total} (${staticNote} / other=${other} by go-test / env=${env} 等环境或人)(${items})`;
} catch (e) { } catch (e) {
debtsLine = ` debts=登记读不到(${e.code || e.message})`; debtsLine = ` debts=登记读不到(${e.code || e.message})`;
} }

View File

@ -0,0 +1,91 @@
/**
* 日历的**纯逻辑**(无 `@ohos` 依赖 ⇒ 判据能用 node `--experimental-strip-types` 直接跑它)。
*
* 为什么先做这一层:P6 的第 1、2 步(结构、圆角与裁剪)**不需要设备**,
* 而日历真正会悄悄错的是"月份网格"这类算术 —— 闰年、月首落在星期几、6 行还是 5 行、
* 跨年翻月、"今天"标在哪一格。这些错了,界面上是"看起来正常但日子错位",
* 属于最难靠肉眼发现的一类,所以把它放在可跑判据下面。
*
* 时区:一律用 **UTC** 构造与取星期(`Date.UTC` + `getUTCDay`),
* 免得同一份代码在 UTC+8 与 UTC-5 上给出不同的列。
*/
export class DayCell {
/** 该格属于本月的第几天;不在本月则为 0 */
day: number = 0;
/** 格里显示的数字(上月尾/下月头也要显示,便于对照) */
label: number = 0;
inMonth: boolean = false;
isToday: boolean = false;
/** `YYYY-MM-DD`,格子的唯一键 */
iso: string = '';
}
export function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
export function daysInMonth(year: number, month: number): number {
const table: number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (month === 2 && isLeapYear(year)) {
return 29;
}
return table[month - 1];
}
function isoOf(year: number, month: number, day: number): string {
const mm: string = month < 10 ? `0${month}` : `${month}`;
const dd: string = day < 10 ? `0${day}` : `${day}`;
return `${year}-${mm}-${dd}`;
}
/** 该月 1 号落在星期几:0=周日 … 6=周六(UTC,与本地时区无关) */
export function firstWeekday(year: number, month: number): number {
return new Date(Date.UTC(year, month - 1, 1)).getUTCDay();
}
/**
* 月首之前要先留几个空格 —— 由 `weekStart`(0=周日,1=周一)决定。
* 这是"周一起始"与"周日起始"唯一的分叉点,所以单独抽出来判。
*/
export function leadingBlanks(year: number, month: number, weekStart: number): number {
const w: number = firstWeekday(year, month);
return (w - weekStart + 7) % 7;
}
/** 翻月(可跨年):`addMonths(2026, 12, 1)` → `2027-01` */
export function addMonths(year: number, month: number, delta: number): number[] {
const total: number = (year * 12 + (month - 1)) + delta;
return [Math.floor(total / 12), (total % 12) + 1];
}
export function monthLabel(year: number, month: number): string {
return `${year}年${month}月`;
}
/** 整月网格:每行 7 格,行数 4~6(由月长与月首星期共同决定) */
export function monthGrid(year: number, month: number, weekStart: number, today?: string): DayCell[][] {
const blanks: number = leadingBlanks(year, month, weekStart);
const len: number = daysInMonth(year, month);
const cells: DayCell[] = [];
for (let i = 0; i < blanks; i++) {
cells.push(new DayCell());
}
for (let d = 1; d <= len; d++) {
const c = new DayCell();
c.day = d;
c.label = d;
c.inMonth = true;
c.iso = isoOf(year, month, d);
c.isToday = today !== undefined && c.iso === today;
cells.push(c);
}
// 补齐最后一行(上月尾/下月头也填上日期,界面才好画连续的日子)
while (cells.length % 7 !== 0) {
cells.push(new DayCell());
}
const rows: DayCell[][] = [];
for (let i = 0; i < cells.length; i += 7) {
rows.push(cells.slice(i, i + 7));
}
return rows;
}

View File

@ -11,55 +11,64 @@
"id": "static-criteria", "id": "static-criteria",
"count": 5, "count": 5,
"due": "本工作区能装、能点设备(探针三值转 true 时自动变红)", "due": "本工作区能装、能点设备(探针三值转 true 时自动变红)",
"where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(test/harmony-nav.test.mjs、test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/appearance-defaults.test.mjs)" "where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(test/harmony-nav.test.mjs、test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/appearance-defaults.test.mjs)",
"kind": "scope"
}, },
{ {
"id": "mails-status-derived", "id": "mails-status-derived",
"count": 1, "count": 1,
"due": "详情/线程改为按读者派生(readStateFor)之后 —— 那时 mail_status_derived_test.go 从 Skip 转实跑", "due": "详情/线程改为按读者派生(readStateFor)之后 —— 那时 mail_status_derived_test.go 从 Skip 转实跑",
"where": "server/internal/repo/mail_status_derived_test.go" "where": "server/internal/repo/mail_status_derived_test.go",
"kind": "scope"
}, },
{ {
"id": "gesture-semantics", "id": "gesture-semantics",
"count": 1, "count": 1,
"due": "P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建(此前建 = 只有一端存在的假判据)", "due": "P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建(此前建 = 只有一端存在的假判据)",
"where": "docs/HARMONY-ALIGN-PLAN.md P6 段" "where": "docs/HARMONY-ALIGN-PLAN.md P6 段",
"kind": "scope"
}, },
{ {
"id": "observability-output", "id": "observability-output",
"count": 1, "count": 1,
"due": "页面层(MainPage.ets)接上「读 presetSubstitutedFrom 并打一行日志」时;那一步同时补判据『读侧恰好出现 1 次且在日志调用里』", "due": "页面层(MainPage.ets)接上「读 presetSubstitutedFrom 并打一行日志」时;那一步同时补判据『读侧恰好出现 1 次且在日志调用里』",
"where": "尚无判据 —— 这正是欠账的一部分(P6 第 1、2 步动 MainPage.ets 时一起做);形态判据在位:test/harmony-appearance.test.mjs(bgBlur 消费侧计数)" "where": "尚无判据 —— 这正是欠账的一部分(P6 第 1、2 步动 MainPage.ets 时一起做);形态判据在位:test/harmony-appearance.test.mjs(bgBlur 消费侧计数)",
"kind": "scope"
}, },
{ {
"id": "unknown-preset-approval", "id": "unknown-preset-approval",
"count": 1, "count": 1,
"due": "有人对上表那格**追认或驳回**「未知 id 显示 aurora 而不是空白」这个方向时(我作为实现者不能自己追认自己)", "due": "有人对上表那格**追认或驳回**「未知 id 显示 aurora 而不是空白」这个方向时(我作为实现者不能自己追认自己)",
"where": "client/electron/test/CRITERIA.md §10 的『未知的预设 id』行(现为『无人类批准』)" "where": "client/electron/test/CRITERIA.md §10 的『未知的预设 id』行(现为『无人类批准』)",
"kind": "env"
}, },
{ {
"id": "overlay-follows-app-theme", "id": "overlay-follows-app-theme",
"count": 1, "count": 1,
"due": "上设备后**翻转一次 colorMode**(应用深色 / 系统浅色),断言**解析出的遮罩值跟着「应用」主题变、而不是跟「系统」**;真机若证伪,正确修法是「遮罩从应用主题派生」,不是回到双常量", "due": "上设备后**翻转一次 colorMode**(应用深色 / 系统浅色),断言**解析出的遮罩值跟着「应用」主题变、而不是跟「系统」**;真机若证伪,正确修法是「遮罩从应用主题派生」,不是回到双常量",
"where": "client/harmony/entry/src/main/ets/common/Theme.ets:58-79 的注释(机制依据:AppearanceStore.applyTheme → app.setColorMode)——**注释不是判据,所以进余额**" "where": "client/harmony/entry/src/main/ets/common/Theme.ets:58-79 的注释(机制依据:AppearanceStore.applyTheme → app.setColorMode)——**注释不是判据,所以进余额**",
"kind": "env"
}, },
{ {
"id": "nav-dark-route-b-unguarded", "id": "nav-dark-route-b-unguarded",
"count": 1, "count": 1,
"due": "**引入深色主题(或第一次给导航组件加 `dark:` 变体)时**必须一并堵;堵法按**文件窄豁免**写,不许写成「导航目录不许出现 dark:」(`bg-chrome-600` plain 档徽标那个先例我踩过一次)", "due": "**引入深色主题(或第一次给导航组件加 `dark:` 变体)时**必须一并堵;堵法按**文件窄豁免**写,不许写成「导航目录不许出现 dark:」(`bg-chrome-600` plain 档徽标那个先例我踩过一次)",
"where": "client/electron/test/background.test.mjs 两条反向断言 —— 这是**已知未覆盖的回滚路径**(不是未验的运行时性质):`.dark .nav-rail{}` 选择器作用域与组件 `dark:` 变体,变异确认过都会逃掉" "where": "client/electron/test/background.test.mjs 两条反向断言 —— 这是**已知未覆盖的回滚路径**(不是未验的运行时性质):`.dark .nav-rail{}` 选择器作用域与组件 `dark:` 变体,变异确认过都会逃掉",
"kind": "scope"
}, },
{ {
"id": "nav-blur-route-c-unguarded", "id": "nav-blur-route-c-unguarded",
"count": 1, "count": 1,
"due": "**第一次给导航元素加工具类模糊(Tailwind `backdrop-blur-*`)时**堵", "due": "**第一次给导航元素加工具类模糊(Tailwind `backdrop-blur-*`)时**堵",
"where": "同上文件 —— **已知未覆盖的回滚路径**:元素级 `backdrop-blur-lg` 不在那条选择器下,变异确认过逃得掉" "where": "同上文件 —— **已知未覆盖的回滚路径**:元素级 `backdrop-blur-lg` 不在那条选择器下,变异确认过逃得掉",
"kind": "scope"
}, },
{ {
"id": "boundary-vocabulary-incomplete", "id": "boundary-vocabulary-incomplete",
"count": 1, "count": 1,
"due": "有人用**词表外**的说法声明边界时(`TODO`、「这里没判」、「跳过」…)——发现即扩词表,并把那一处登记;在此之前它抓不到", "due": "**由外部读者报告时**(自查机制对这一类结构性失明 —— 发现词表外说法的机制,正是看不见它的那个机制)。收到报告后:扩词表 + 登记该处 + 保留\"上一次是谁发现的\"。**没有内部触发器,这是这条递归的不动点**:无论词表多长、判据多严,总有一类盲区只能靠\"外面有人读了一遍\"",
"where": "client/electron/test/debt-visibility.test.mjs(词表键控的盲区:**已知未覆盖**——词表是采样、不是完备)" "where": "client/electron/test/debt-visibility.test.mjs(词表键控的盲区:**已知未覆盖**——词表是采样、不是完备)",
"kind": "env"
} }
] ]
} }