跨端: 鸿蒙日历写侧(新建/编辑/删除/暂停恢复)+ 表单不再是一面滚轮墙

用户:「是的去吧」(批准上一封列出的下一步)。

- CalendarApi:createEvent / updateEvent / deleteEvent(POST /calendar/events、
  PUT/DELETE /calendar/events/{id});新建与编辑发**同一份** CalendarEventInput
  —— 服务端两个端点同形(CreateCalendarEvent 里那个 Status 字段的注释就写着这句),
  拆两份会在严格解码下 400
- CalendarPage:写侧表单(新建与编辑共用一份)、二次确认删除、暂停/恢复走 update
  (调度器只触发 active;没有暂停就只能删掉重加,而那会丢 event_id 与历史)
- 服务端 400 文案原样显示(收件地址无法解析:…)—— 建事件时就校验地址,
  吞成保存失败等于让人猜,而猜的代价是以为设好了、实际永远发不出去
- 空标题/空收件人在本地就挡住,不发注定 400 的请求
- 提交的是**时间戳**(isoTimestampOfLocal),不是日期键;编辑回填走 localDateOf
- ★ 表单形状:日期/时间选择器合起来 ~1400px,展开着放会把标题与收件人挤出屏幕 ——
  改成一行摘要 + 点开展开(默认收起)。这不是美观问题:展开态下保存在屏幕外,
  要跨过一整面滚轮才够得着
- 判据 +7(端点/载荷/前置校验/文案/二次确认/时间戳往返/变异自检),23 条全绿
- 真机实测(hvigorw + hdc + uitest 逐字段输入 + 逐键点击,每一步都回库核对):
  建 → 库里 event_time 是 2026-09-28T01:00Z(= 本地 09:00 整,默认值正确)、
  暂停 → status=paused、删除第一下只变确认删除(库里还在)、第二下才真删(库里 0 条)
This commit is contained in:
pi
2026-09-14 18:57:53 +08:00
parent 94ba4b9c58
commit d3f6f3baf6
5 changed files with 659 additions and 78 deletions

View File

@ -293,3 +293,86 @@ test('★ 判据自检:把错误写法喂进来必须判红', () => {
// ④ 没有 @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"那条命中');
});