From e94e4dcd391005cbb27cb3832a16cb4e2973f57a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 17:41:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20feat(P6):=20=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E7=BA=AF=E9=80=BB=E8=BE=91=20model/Calendar.ts=20+=20?= =?UTF-8?q?6=20=E6=9D=A1=E5=8F=AF=E8=B7=91=E5=88=A4=E6=8D=AE=EF=BC=88?= =?UTF-8?q?=E7=AC=AC=201=20=E6=AD=A5=E7=9A=84=E9=80=BB=E8=BE=91=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E5=8D=8A=EF=BC=8C=E4=B8=8D=E9=9C=80=E8=A6=81=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../electron/test/harmony-calendar.test.mjs | 85 +++++++++++++++++ client/electron/test/run-all.mjs | 4 +- .../entry/src/main/ets/model/Calendar.ts | 91 +++++++++++++++++++ docs/DEBTS.json | 29 ++++-- 4 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 client/electron/test/harmony-calendar.test.mjs create mode 100644 client/harmony/entry/src/main/ets/model/Calendar.ts diff --git a/client/electron/test/harmony-calendar.test.mjs b/client/electron/test/harmony-calendar.test.mjs new file mode 100644 index 0000000..0d234e8 --- /dev/null +++ b/client/electron/test/harmony-calendar.test.mjs @@ -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); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index b55c69e..5c38d36 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -77,6 +77,7 @@ const SUITE = [ ['test/appearance-defaults.test.mjs', [], 4], ['test/build-stamp.test.mjs', [], 7], ['test/packaging.test.mjs', [], 5], + ['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], ['test/debt-visibility.test.mjs', [], 1], ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2], // 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口 @@ -547,7 +548,8 @@ try { : debtRegStatic === measuredStatic ? `static-only=${measuredStatic}==登记 ✓` : `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) { debtsLine = ` debts=登记读不到(${e.code || e.message})`; } diff --git a/client/harmony/entry/src/main/ets/model/Calendar.ts b/client/harmony/entry/src/main/ets/model/Calendar.ts new file mode 100644 index 0000000..02eaa6a --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/Calendar.ts @@ -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; +} diff --git a/docs/DEBTS.json b/docs/DEBTS.json index 0d7820f..6599090 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -11,55 +11,64 @@ "id": "static-criteria", "count": 5, "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", "count": 1, "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", "count": 1, "due": "P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建(此前建 = 只有一端存在的假判据)", - "where": "docs/HARMONY-ALIGN-PLAN.md P6 段" + "where": "docs/HARMONY-ALIGN-PLAN.md P6 段", + "kind": "scope" }, { "id": "observability-output", "count": 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", "count": 1, "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", "count": 1, "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", "count": 1, "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", "count": 1, "due": "**第一次给导航元素加工具类模糊(Tailwind `backdrop-blur-*`)时**堵", - "where": "同上文件 —— **已知未覆盖的回滚路径**:元素级 `backdrop-blur-lg` 不在那条选择器下,变异确认过逃得掉" + "where": "同上文件 —— **已知未覆盖的回滚路径**:元素级 `backdrop-blur-lg` 不在那条选择器下,变异确认过逃得掉", + "kind": "scope" }, { "id": "boundary-vocabulary-incomplete", "count": 1, - "due": "有人用**词表外**的说法声明边界时(`TODO`、「这里没判」、「跳过」…)——发现即扩词表,并把那一处登记;在此之前它抓不到", - "where": "client/electron/test/debt-visibility.test.mjs(词表键控的盲区:**已知未覆盖**——词表是采样、不是完备)" + "due": "**由外部读者报告时**(自查机制对这一类结构性失明 —— 发现词表外说法的机制,正是看不见它的那个机制)。收到报告后:扩词表 + 登记该处 + 保留\"上一次是谁发现的\"。**没有内部触发器,这是这条递归的不动点**:无论词表多长、判据多严,总有一类盲区只能靠\"外面有人读了一遍\"", + "where": "client/electron/test/debt-visibility.test.mjs(词表键控的盲区:**已知未覆盖**——词表是采样、不是完备)", + "kind": "env" } ] }