269 lines
9.7 KiB
TypeScript
269 lines
9.7 KiB
TypeScript
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);
|
||
});
|
||
});
|