chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,268 @@
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);
});
});