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 { 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 小时'); }); });