Files
MailUI4Agents/web/test/components/calendar.test.tsx
JianFeeeee 7f28552440 feat(web): 日历前端 —— 月/周/日三粒度 + 事件编辑器 + 农历显示
布局与全站一致:**内容区自己再分两栏**。

第一版是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做,
而点「新建」时编辑器**顶掉**整个日历,人失去正在看的那个月的上下文。
两个问题同源:日历没有「详情栏」这个位置。

现在左边网格、右边常驻面板:默认显示选中那天的日程(所以永远不空),
新建/编辑时同一位置变编辑器 —— 与「新建邮件是右侧整页」同一套语言。
窄屏退回覆盖式(NarrowStack,与收件箱详情同一组件同一段动画)。

lib/calendar.ts 里三个易错点(都有测试钉住):

**周首必须是周一。** getDay() 把周日算作 0,直接减它会让周日归到上一周
末尾,月视图第一行整体错位。

**分桶用本地日期串而不是 toISOString().slice(0,10)。** 后者给 UTC 日期,
东八区晚上 8 点后的事件会被归到第二天的格子里。

**月视图固定 42 格。** 按需 4~6 行会让网格高度随月份跳动,翻月时页面弹动。

另有一个 TS 陷阱:replaceAll 在 tsconfig 的 target: ES2020 下不存在
(TS2550)。改用 split/join —— **不能**退回 replace,那只换第一个,
同一变量写两次时第二个会原样漏进邮件。

编辑器:
- 农历规则预览**接下来三次的公历日期**。规则名(「每农历月廿二」)看不出
  公历日子,而那日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。
- 变量按钮 + 实时渲染预览。「模板里写了什么」和「Agent 收到什么」不是一个
  东西,不给预览人只能发一次试试看。
- 收件人列表可增删调序(together 模式下首个是主收件人,顺序有语义)。
  编辑旧事件时初值走与后端相同的兜底链 —— 不做归一化的话,编辑一条老事件
  再保存会把收件人清空(列表是空的,保存就覆盖了)。
- remindAt 单独显示:调度器比较的是「事件时间 − 提前分钟」那个点,
  不显示的话人设了「提前 30 分钟」却在事件时间才反应过来。

日历格子标农历日(初一显示月名,纸质日历惯例)。暂停/取消的事件加删除线
与图标 —— 否则人以为设好了,实际到点什么都不会发生。

exportCalendarICS 不走 request()(那个假定 JSON,这里是 text/calendar),
返回文本让调用方用 Blob 触发下载而不是让浏览器导航 —— 导航会丢掉 cookie
之外的认证头。

测试 35 例。
2026-09-04 06:29:50 +08:00

282 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest';
import {
startOfDay, endOfDay, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
isSameDay, addDays, addMonths, monthGrid, weekDays, remindAt,
bucketByDay, dayKey, renderReminder, toLocalInput, fromLocalInput,
describeRecurrence, describeRemindBefore
} from '../../src/lib/calendar';
import type { CalendarEvent } from '../../src/types';
let seq = 0;
function ev(over: Partial<CalendarEvent> = {}): CalendarEvent {
seq += 1;
return {
event_id: `e${String(seq).padStart(3, '0')}`,
title: `事件 ${seq}`,
description: '',
reminder_text: '',
agent_name: 'pi',
to_address: '',
recipients: [],
delivery_mode: 'separate',
event_time: '2026-09-03T14:30:00+08:00',
remind_before: 0,
recurrence: 'none',
status: 'active',
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
created_by: 'jianf',
...over
};
}
describe('日期边界', () => {
it('startOfDay / endOfDay 用本地时区', () => {
const d = new Date(2026, 8, 3, 14, 30, 45, 123);
expect(startOfDay(d).getHours()).toBe(0);
expect(startOfDay(d).getMilliseconds()).toBe(0);
expect(endOfDay(d).getHours()).toBe(23);
expect(endOfDay(d).getMilliseconds()).toBe(999);
// 不修改入参
expect(d.getHours()).toBe(14);
});
it('startOfMonth / endOfMonth', () => {
const d = new Date(2026, 8, 15);
expect(startOfMonth(d).getDate()).toBe(1);
expect(endOfMonth(d).getDate()).toBe(30); // 9 月 30 天
expect(endOfMonth(new Date(2026, 1, 5)).getDate()).toBe(28); // 2026 年 2 月
});
it('周从周一开始,周日归到上一周末尾', () => {
// 2026-09-06 是周日
const sunday = new Date(2026, 8, 6);
expect(sunday.getDay()).toBe(0);
// 它所在周的周一应是 08-31不是 09-07
expect(dayKey(startOfWeek(sunday))).toBe('2026-08-31');
expect(dayKey(endOfWeek(sunday))).toBe('2026-09-06');
});
it('周一自己就是周首', () => {
const monday = new Date(2026, 8, 7);
expect(monday.getDay()).toBe(1);
expect(dayKey(startOfWeek(monday))).toBe('2026-09-07');
});
});
describe('日期运算', () => {
it('addDays 不修改入参', () => {
const d = new Date(2026, 8, 3);
const r = addDays(d, 5);
expect(dayKey(r)).toBe('2026-09-08');
expect(dayKey(d)).toBe('2026-09-03');
});
it('addDays 跨月', () => {
expect(dayKey(addDays(new Date(2026, 8, 29), 5))).toBe('2026-10-04');
});
it('addMonths 从月末加一个月不会溢出', () => {
// setMonth 在 1 月 31 日上加一个月会给 3 月 2/3 日
const jan31 = new Date(2026, 0, 31);
expect(dayKey(addMonths(jan31, 1))).toBe('2026-02-01');
});
it('isSameDay 只比年月日', () => {
expect(isSameDay(new Date(2026, 8, 3, 0, 0), new Date(2026, 8, 3, 23, 59))).toBe(true);
expect(isSameDay(new Date(2026, 8, 3), new Date(2026, 8, 4))).toBe(false);
expect(isSameDay(new Date(2026, 8, 3), new Date(2025, 8, 3))).toBe(false);
});
});
describe('monthGrid', () => {
it('固定 42 格6 行 × 7 列)', () => {
// 行数变化会让网格高度跳动,翻月时页面内容上下弹
expect(monthGrid(new Date(2026, 8, 15))).toHaveLength(42);
expect(monthGrid(new Date(2026, 1, 15))).toHaveLength(42);
});
it('首格是月首所在周的周一', () => {
// 2026-09-01 是周二 → 首格应是 08-31周一
const cells = monthGrid(new Date(2026, 8, 15));
expect(dayKey(cells[0])).toBe('2026-08-31');
});
it('格子连续无空洞', () => {
const cells = monthGrid(new Date(2026, 8, 15));
for (let i = 1; i < cells.length; i++) {
const diff = cells[i].getTime() - cells[i - 1].getTime();
// 允许夏令时造成的 ±1 小时偏差
expect(diff).toBeGreaterThanOrEqual(23 * 3600_000);
expect(diff).toBeLessThanOrEqual(25 * 3600_000);
}
});
it('包含整个当月', () => {
const anchor = new Date(2026, 8, 15);
const keys = monthGrid(anchor).map(dayKey);
expect(keys).toContain('2026-09-01');
expect(keys).toContain('2026-09-30');
});
});
describe('weekDays', () => {
it('给 7 天,周一起头', () => {
const days = weekDays(new Date(2026, 8, 3)); // 周四
expect(days).toHaveLength(7);
expect(dayKey(days[0])).toBe('2026-08-31');
expect(dayKey(days[6])).toBe('2026-09-06');
});
});
describe('remindAt', () => {
it('提前 30 分钟', () => {
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 30 });
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:00:00.000Z');
});
it('remind_before 为 0 时就是事件时间', () => {
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 0 });
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:30:00.000Z');
});
it('提前一整天', () => {
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 1440 });
expect(remindAt(e).toISOString()).toBe('2026-09-02T06:30:00.000Z');
});
});
describe('bucketByDay', () => {
it('按本地日期分桶,不用 UTC 前缀', () => {
// 东八区 22:00 → UTC 是前一天 14:00。用 toISOString().slice(0,10) 会归错天
const e = ev({ event_time: '2026-09-03T22:00:00+08:00' });
const m = bucketByDay([e]);
expect([...m.keys()]).toEqual(['2026-09-03']);
});
it('同一天内按时间正序', () => {
const late = ev({ event_time: '2026-09-03T18:00:00+08:00', title: '晚' });
const early = ev({ event_time: '2026-09-03T09:00:00+08:00', title: '早' });
const m = bucketByDay([late, early]);
expect(m.get('2026-09-03')!.map(x => x.title)).toEqual(['早', '晚']);
});
it('同刻事件用 event_id 兜底定序', () => {
const ts = '2026-09-03T09:00:00+08:00';
const a = ev({ event_id: 'aaa', event_time: ts });
const z = ev({ event_id: 'zzz', event_time: ts });
const m1 = bucketByDay([a, z]).get('2026-09-03')!.map(x => x.event_id);
const m2 = bucketByDay([z, a]).get('2026-09-03')!.map(x => x.event_id);
expect(m1).toEqual(m2);
expect(m1).toEqual(['aaa', 'zzz']);
});
it('时间解析失败的脏数据被跳过而不是让整个视图空白', () => {
const bad = ev({ event_time: '不是时间' });
const good = ev({ event_time: '2026-09-03T09:00:00+08:00' });
const m = bucketByDay([bad, good]);
expect([...m.keys()]).toEqual(['2026-09-03']);
});
it('空输入给空 Map', () => {
expect(bucketByDay([]).size).toBe(0);
});
});
describe('renderReminder', () => {
it('替换三个变量', () => {
const out = renderReminder('【{title}】{time} — {description}', {
title: '发布评审',
description: '看 llmsproxy 的部署脚本',
event_time: '2026-09-03T14:30:00+08:00'
});
expect(out).toBe('【发布评审】2026-09-03 14:30 — 看 llmsproxy 的部署脚本');
});
it('同一变量出现多次全部替换', () => {
// replaceAll 而非 replace后者只换第一个
const out = renderReminder('{title} / {title}', {
title: 'X',
event_time: '2026-09-03T14:30:00+08:00'
});
expect(out).toBe('X / X');
});
it('description 缺失时替换成空串而不是 undefined', () => {
const out = renderReminder('{description}|', {
title: 'T',
event_time: '2026-09-03T14:30:00+08:00'
});
expect(out).toBe('|');
});
it('没有变量的模板原样返回', () => {
const out = renderReminder('纯文本提醒', {
title: 'T',
event_time: '2026-09-03T14:30:00+08:00'
});
expect(out).toBe('纯文本提醒');
});
it('时间解析失败时回退到原始串', () => {
const out = renderReminder('{time}', { title: 'T', event_time: '坏时间' });
expect(out).toBe('坏时间');
});
});
describe('datetime-local 往返', () => {
it('toLocalInput 给本地时区的 YYYY-MM-DDTHH:mm', () => {
const d = new Date(2026, 8, 3, 14, 30);
expect(toLocalInput(d)).toBe('2026-09-03T14:30');
});
it('补零', () => {
const d = new Date(2026, 0, 5, 9, 5);
expect(toLocalInput(d)).toBe('2026-01-05T09:05');
});
it('fromLocalInput 按本地时区解析(人写的就是本地时间)', () => {
const iso = fromLocalInput('2026-09-03T14:30');
// 结果应与本地构造的 Date 一致
expect(iso).toBe(new Date(2026, 8, 3, 14, 30).toISOString());
});
it('往返不丢分钟', () => {
const original = new Date(2026, 8, 3, 14, 30);
expect(toLocalInput(new Date(fromLocalInput(toLocalInput(original))))).toBe('2026-09-03T14:30');
});
it('非法输入给空串', () => {
expect(fromLocalInput('')).toBe('');
expect(fromLocalInput('坏值')).toBe('');
});
});
describe('人类可读描述', () => {
it('重复规则', () => {
expect(describeRecurrence(ev({ recurrence: 'none' }))).toBe('不重复');
expect(describeRecurrence(ev({ recurrence: 'daily' }))).toBe('每天');
expect(describeRecurrence(ev({ recurrence: 'weekly' }))).toBe('每周');
expect(describeRecurrence(ev({ recurrence: 'monthly' }))).toBe('每月');
});
it('带终止时间', () => {
const e = ev({ recurrence: 'daily', recurrence_end: '2026-12-31T00:00:00+08:00' });
expect(describeRecurrence(e)).toBe('每天,至 2026-12-31');
});
it('none 时忽略 recurrence_end', () => {
const e = ev({ recurrence: 'none', recurrence_end: '2026-12-31T00:00:00+08:00' });
expect(describeRecurrence(e)).toBe('不重复');
});
it('提前提醒', () => {
expect(describeRemindBefore(0)).toBe('到点提醒');
expect(describeRemindBefore(15)).toBe('提前 15 分钟');
expect(describeRemindBefore(60)).toBe('提前 1 小时');
expect(describeRemindBefore(120)).toBe('提前 2 小时');
expect(describeRemindBefore(90)).toBe('提前 1 小时 30 分钟');
expect(describeRemindBefore(1440)).toBe('提前 24 小时');
});
});