Files
MailUI4Agents/client/electron/test/components/lunar.test.tsx

269 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 {
fromSolar, toSolar, leapMonth, daysInMonth,
addLunarMonths, addLunarYears,
formatLunarFull, formatLunarShort, cellLunarLabel, lunarDayName,
formatSolarWithLunar, isLunarRecurrence,
nextOccurrence, addSolarMonthsClamped, upcomingOccurrences,
describeRecurrenceRule
} from '../../src/lib/lunar';
describe('公历 ↔ 农历', () => {
it('已知锚点', () => {
const d = fromSolar(new Date(2026, 8, 3));
expect(d).toEqual({ year: 2026, month: 7, day: 22 });
});
it('闰月用负数月份表示', () => {
// 2025 有闰六月
expect(leapMonth(2025)).toBe(6);
const d = fromSolar(new Date(2025, 6, 25)); // 2025-07-25
expect(d.month).toBe(-6);
expect(d.day).toBe(1);
});
it('无闰月的年份返回 0', () => {
expect(leapMonth(2026)).toBe(0);
expect(leapMonth(2027)).toBe(0);
});
it('往返不丢日期', () => {
for (const [y, m, dd] of [[2026, 8, 3], [2027, 1, 14], [2025, 6, 25]] as const) {
const solar = new Date(y, m, dd);
const lunar = fromSolar(solar);
const back = toSolar(lunar, 9, 30);
expect(back).not.toBeNull();
expect(back!.clamped).toBe(false);
expect(back!.date.getFullYear()).toBe(y);
expect(back!.date.getMonth()).toBe(m);
expect(back!.date.getDate()).toBe(dd);
// 时钟原样带过去(农历只定义到「日」)
expect(back!.date.getHours()).toBe(9);
expect(back!.date.getMinutes()).toBe(30);
}
});
});
describe('短月份夹取', () => {
it('2027 农历九月只有 29 天', () => {
expect(daysInMonth(2027, 9)).toBe(29);
expect(daysInMonth(2026, 1)).toBe(30);
});
// 库在 Lunar.fromYmd(2027,9,30) 上直接 throw。
// 「每月农历三十」必然撞上 29 天的月份,不夹住就是整片白屏。
it('要三十而该月只有廿九时夹到廿九,不抛错', () => {
const r = toSolar({ year: 2027, month: 9, day: 30 }, 9, 0);
expect(r).not.toBeNull();
expect(r!.clamped).toBe(true);
// 夹取后必须仍在同一个农历月内(滚到下月初一是错的)
expect(fromSolar(r!.date).month).toBe(9);
expect(fromSolar(r!.date).day).toBe(29);
});
it('不存在的闰月返回 null 而不是猜一个日子', () => {
expect(daysInMonth(2026, -6)).toBe(0);
expect(toSolar({ year: 2026, month: -6, day: 1 })).toBeNull();
});
it('非法日返回 null', () => {
expect(toSolar({ year: 2026, month: 1, day: 0 })).toBeNull();
expect(toSolar({ year: 2026, month: 13, day: 1 })).toBeNull();
});
});
describe('农历推进', () => {
it('加月跨年', () => {
expect(addLunarMonths({ year: 2026, month: 12, day: 5 }, 1)).toEqual({ year: 2027, month: 1, day: 5 });
});
it('推 12 次回到次年同月', () => {
expect(addLunarMonths({ year: 2026, month: 7, day: 22 }, 12))
.toEqual({ year: 2027, month: 7, day: 22 });
});
// 「每月十五」的期望是一年 12 次,把闰月算进去会让闰年多一次
it('从闰月出发先归正月份', () => {
expect(addLunarMonths({ year: 2025, month: -6, day: 15 }, 1).month).toBe(7);
});
it('加年保持月日', () => {
expect(addLunarYears({ year: 2026, month: 7, day: 22 }, 1))
.toEqual({ year: 2027, month: 7, day: 22 });
});
// 静默让重复事件消失比退回正月份更糟
it('目标年无同一闰月时退回正月份', () => {
expect(addLunarYears({ year: 2025, month: -6, day: 15 }, 1))
.toEqual({ year: 2026, month: 6, day: 15 });
});
});
describe('中文表示', () => {
it('完整表示', () => {
expect(formatLunarFull({ year: 2026, month: 7, day: 22 })).toBe('二〇二六年七月廿二');
});
it('闰月带「闰」字', () => {
expect(formatLunarFull({ year: 2025, month: -6, day: 1 })).toBe('二〇二五年闰六月初一');
});
it('短表示去掉年份', () => {
expect(formatLunarShort({ year: 2026, month: 7, day: 22 })).toBe('七月廿二');
});
// 日名有五种前缀形态(初一/十一/二十/廿一/三十),
// 用正则截取时「二十」曾被漏掉
it('日名查表覆盖全部 30 天', () => {
const names = Array.from({ length: 30 }, (_, i) => lunarDayName(i + 1));
expect(names[0]).toBe('初一');
expect(names[9]).toBe('初十');
expect(names[10]).toBe('十一');
expect(names[19]).toBe('二十');
expect(names[20]).toBe('廿一');
expect(names[29]).toBe('三十');
expect(new Set(names).size).toBe(30); // 无重复
expect(names.every(n => n.length === 2)).toBe(true);
});
it('格子标记:初一显示月名,其余显示日名', () => {
// 2026-08-13 是农历七月初一
const firstDay = toSolar({ year: 2026, month: 7, day: 1 })!.date;
expect(cellLunarLabel(firstDay)).toBe('七月');
expect(cellLunarLabel(new Date(2026, 8, 3))).toBe('廿二');
});
it('闰月初一的格子标记带「闰」', () => {
const leapFirst = toSolar({ year: 2025, month: -6, day: 1 })!.date;
expect(cellLunarLabel(leapFirst)).toBe('闰六月');
});
it('公历+农历合并显示', () => {
expect(formatSolarWithLunar(new Date(2026, 8, 3))).toBe('2026-09-03农历七月廿二');
});
});
describe('nextOccurrence 与后端 repo.NextOccurrence 对齐', () => {
const base = new Date(2026, 8, 3, 9, 30);
it('公历三种', () => {
expect(nextOccurrence('daily', base)!.getDate()).toBe(4);
expect(nextOccurrence('weekly', base)!.getDate()).toBe(10);
expect(nextOccurrence('monthly', base)!.getMonth()).toBe(9);
});
it('公历每年', () => {
const n = nextOccurrence('yearly', base)!;
expect(n.getFullYear()).toBe(2027);
expect(n.getMonth()).toBe(8);
expect(n.getDate()).toBe(3);
});
it('none 与未知值给 null', () => {
expect(nextOccurrence('none', base)).toBeNull();
expect(nextOccurrence('每隔一个蓝月亮', base)).toBeNull();
});
it('时钟保留', () => {
for (const r of ['daily', 'weekly', 'monthly', 'yearly', 'lunar_monthly', 'lunar_yearly']) {
const n = nextOccurrence(r, base);
expect(n).not.toBeNull();
expect(n!.getHours()).toBe(9);
expect(n!.getMinutes()).toBe(30);
}
});
});
// setMonth 的溢出3月31日 +1月 = 5月1日会永久改变规则
// 31 日的事件在 2 月变成 3 月 3 日,然后从此每月 3 日提醒
describe('公历月末夹取', () => {
it.each([
['2026-01-31', 1, '2026-02-28'],
['2026-03-31', 1, '2026-04-30'],
['2028-01-31', 1, '2028-02-29'],
['2028-02-29', 12, '2029-02-28'],
['2026-01-15', 1, '2026-02-15']
])('%s + %i 月 → %s', (from, n, want) => {
const [y, m, d] = from.split('-').map(Number);
const got = addSolarMonthsClamped(new Date(y, m - 1, d), n);
const pad = (x: number) => String(x).padStart(2, '0');
expect(`${got.getFullYear()}-${pad(got.getMonth() + 1)}-${pad(got.getDate())}`).toBe(want);
});
});
describe('农历重复的公历漂移', () => {
it('农历月间隔在 29~30 天之间浮动,不是固定值', () => {
let cur = new Date(2026, 8, 3, 9, 0);
const gaps = new Set<number>();
for (let i = 0; i < 6; i++) {
const next = nextOccurrence('lunar_monthly', cur)!;
expect(next.getTime()).toBeGreaterThan(cur.getTime());
gaps.add(Math.round((next.getTime() - cur.getTime()) / 86400000));
// 农历「日」保持不变
expect(fromSolar(next).day).toBe(22);
cur = next;
}
expect(gaps.size).toBeGreaterThan(1);
for (const g of gaps) expect(g).toBeGreaterThanOrEqual(28);
for (const g of gaps) expect(g).toBeLessThanOrEqual(31);
});
// 这是农历规则存在的理由:用公历 yearly 日子会固定,
// 与「过农历生日/祭日」的期望不符
it('农历年推进时公历月日每年都变', () => {
let cur = new Date(2026, 8, 3, 9, 0);
const seen = new Set<string>();
for (let i = 0; i < 5; i++) {
const next = nextOccurrence('lunar_yearly', cur)!;
const d = fromSolar(next);
expect(d.month).toBe(7);
expect(d.day).toBe(22);
seen.add(`${next.getMonth() + 1}-${next.getDate()}`);
cur = next;
}
expect(seen.size).toBeGreaterThanOrEqual(3);
});
});
describe('upcomingOccurrences', () => {
it('给出连续递增的 n 次', () => {
const list = upcomingOccurrences('lunar_monthly', new Date(2026, 8, 3, 9, 0), 3);
expect(list).toHaveLength(3);
for (let i = 1; i < list.length; i++) {
expect(list[i].getTime()).toBeGreaterThan(list[i - 1].getTime());
}
});
it('不重复时给空数组', () => {
expect(upcomingOccurrences('none', new Date(), 3)).toEqual([]);
});
});
describe('describeRecurrenceRule', () => {
it('公历规则', () => {
expect(describeRecurrenceRule('none')).toBe('不重复');
expect(describeRecurrenceRule('daily')).toBe('每天');
expect(describeRecurrenceRule('weekly')).toBe('每周');
expect(describeRecurrenceRule('monthly')).toBe('每月');
expect(describeRecurrenceRule('yearly')).toBe('每年');
});
it('农历规则把农历日子写出来', () => {
const t = new Date(2026, 8, 3);
expect(describeRecurrenceRule('lunar_monthly', t)).toBe('每农历月廿二');
expect(describeRecurrenceRule('lunar_yearly', t)).toBe('每年农历七月廿二');
});
it('无事件时间时退回泛化描述', () => {
expect(describeRecurrenceRule('lunar_monthly')).toBe('每农历月同一日');
expect(describeRecurrenceRule('lunar_yearly')).toBe('每农历年同月同日');
});
it('isLunarRecurrence', () => {
expect(isLunarRecurrence('lunar_monthly')).toBe(true);
expect(isLunarRecurrence('lunar_yearly')).toBe(true);
expect(isLunarRecurrence('monthly')).toBe(false);
expect(isLunarRecurrence('yearly')).toBe(false);
});
});