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(); 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(); 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); }); });