跨端: 鸿蒙日历补「月/周/日」三档 —— 原来只有月视图

用户列的缺失之一(WebUI `CalendarView.tsx` 的 `type Scale = 'month'|'week'|'day'`)。
鸿蒙这边 `CalendarPage.ets` 自己的注释里就写着"没做"。

## 三处必须一起改,所以档位进 model 而不是页面里一串 if

档位切换同时改变三件事,任一漏改都**不报错、只是静静地不对**:

| | 月 | 周 | 日 |
|---|---|---|---|
| 标题 | `2026年9月` | 跨月时两头写月份 `9.28 – 10.4` | `2026年9月18日 周五` |
| 翻页步长 | ±1 **月** | ±7 天 | ±1 天 |
| 显示格子 | 整月网格 | 一行 7 天 | 一行、只亮一格 |

所以 `model/Calendar.ts`(纯逻辑,node 直接跑)新增:
`CalendarScale` / `addDaysIso` / `weekDaysOf` / `rangeTitleOf` / `stepDaysOf` / `scaleLabel`。
页面只调用,不在渲染里重写 —— 重写就是"三处里漏改一处"。

★ 步长按档走是**必须**的:周档按 1 天翻看起来像日档、日档按 7 天翻会跳过一周,
  两者都不报错。判据钉"三档步长两两不同"。
★ 周/日档的 7 天必须与月网格用**同一个** `weekStart`:不一致的话同一日期在两档下列位置
  不同,用户看到的是"切个视图日期就跳位了"。
★ 日档的格子仍摆在一行的**星期列**里(不是居中大字):上下翻日时格子不会在屏幕上跳。

## 实现要点

- 档位状态叫 `calScale` 而**不是** `scale` —— ArkUI 的 `CustomComponent` 已有 `scale`
  修饰符,同名直接编译失败(实测报 `Property 'scale' ... is not assignable to ...`)。
  判据钉住这个命名。
- 周/日档下 `year/month` 由锚点 `selectedIso` 推出来(`syncYearMonthFrom`),
  不允许两处各自保存"当前是几月" —— 否则会出现"标题写 9 月、周视图显示 10 月那周"。
- `monthKey()` 加上**档位**前缀:切档时那 7 个 iso 就是月网格里的 7 个,
  不带档位的话键完全重叠 ⇒ 一个节点都不被替换 ⇒ 过渡静默不播、旧 `inMonth` 残留。
- 空态补 `layoutWeight(1)`:实测周视图下"这一天没有日程"贴顶、**下半屏是一大片空壁纸**。

## 判据(harmony-calendar 23 → 30 条)

7 条新增,每条对着一个具体错法。**变异自检跑过两个方向**:
- 周档步长改成 1(看起来像日档)⇒ 判红 ✓
- 跨月周标题漏掉结束月份(`9.28 – 4`)⇒ 判红 ✓

## 实测(模拟器,逐档截图)

月/周/日三档切换后:周档标题 `2026年9月14–20日` + 一行 14~20,日档标题
`2026年9月18日 周五` + 只亮 18,月档回到整月网格。三张截图逐一核过。
This commit is contained in:
2026-09-18 11:08:05 +08:00
parent af2d2b5cad
commit fbe7879981
4 changed files with 401 additions and 30 deletions

View File

@ -385,3 +385,95 @@ test('★ 判据自检:写侧的错误写法必须判红', () => {
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 周视图下"这一天没有日程"贴顶、下半屏全空');
});

View File

@ -102,7 +102,7 @@ const SUITE = [
// 明文只对公网告警 / 404 说清“少了 /api/v1” / 网络错误码分类 / 归一化只有一份实现。
// 值判据跑真逻辑(model/ApiBase.ts);`.ets` 那几条是**静态**接线判据(无设备)。
['test/harmony-apibase.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],
['test/debt-visibility.test.mjs', [], 1],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 4],
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口