Files
MailUI4Agents/gateway/internal/lunar/lunar_test.go
JianFeeeee 4d211c84d6 feat(lunar): 双端农历换算层(Go + TS,同作者同算法)
日历要支持「每农历月十五」「农历生日」这类规则。公历与农历的换算不能
自己算,两端各引一个库:Go 用 6tail/lunar-go v1.4.6,前端用同作者的
lunar-javascript 1.7.7 —— 同算法保证两端结果一致(前端要在格子上显示
农历日、在编辑器里预览接下来几次触发)。

为什么要包一层而不直接用库:

**1. 库在非法日期上 panic 而不是返回 error。**
`NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar year
2027 month 9")。农历月是 29 或 30 天不定,「每月农历三十」这条规则必然
撞上短月份。调度器里一次 panic 就让那条提醒永久卡住。
修法是夹到该月实际天数并返回 clamped 标记 —— 夹而不滚:「每月三十」的
语义是「月末那天」,滚到下月初一会让提醒与前一次只隔一天。

**2. 闰月用负数月份表示**(-6 = 闰六月),这个约定藏在库内部。
2025 有闰六月、2028 有闰五月,2026/2027 没有。AddYears 从闰月出发而
目标年没有同一闰月时退回正月份 —— 静默让重复事件消失更糟。

**3. 按农历推进不能加固定天数。**
农历月 29~30 天、农历年 353~385 天(闰年多一整月),AddDate 近似一年
能偏半个月。

前端另有一个 TS 陷阱:日名有五种前缀形态(初一/十一/二十/廿一/三十),
原来用正则从 toString() 截取时漏了「二十」,20 号会显示整串「七月二十」。
改成查表。

测试:Go 12 例 / TS 37 例。含「同一农历日在六年公历里落到至少 4 个不同
月日上」—— 那正是农历重复存在的理由(公历 yearly 会固定在同一天)。
2026-09-04 06:27:15 +08:00

217 lines
7.2 KiB
Go
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.

package lunar
import (
"testing"
"time"
)
// 已知对照点。农历换算错了不会报错,只会让提醒发在错误的日子,
// 因此必须钉住几个可人工核对的锚点。
func TestFromSolarKnownDates(t *testing.T) {
cases := []struct {
solar string
year int
month int
day int
}{
{"2026-09-03", 2026, 7, 22},
{"2026-01-01", 2025, 11, 13},
// 2025 有闰六月:闰月里的日子 Month 应为负
{"2025-07-25", 2025, -6, 1},
{"2025-06-25", 2025, 6, 1},
}
for _, c := range cases {
st, err := time.Parse("2006-01-02", c.solar)
if err != nil {
t.Fatalf("解析 %s: %v", c.solar, err)
}
got := FromSolar(st)
if got.Year != c.year || got.Month != c.month || got.Day != c.day {
t.Errorf("%s → 农历 %d-%d-%d期望 %d-%d-%d",
c.solar, got.Year, got.Month, got.Day, c.year, c.month, c.day)
}
}
}
func TestToSolarRoundtrip(t *testing.T) {
loc := time.Local
for _, solar := range []string{"2026-09-03", "2027-02-14", "2028-06-30", "2025-07-25"} {
st, _ := time.Parse("2006-01-02", solar)
d := FromSolar(st)
back, clamped, err := d.ToSolar(loc, 9, 30, 0, 0)
if err != nil {
t.Errorf("%s 往返失败: %v", solar, err)
continue
}
if clamped {
t.Errorf("%s 往返不该发生夹取", solar)
}
if back.Format("2006-01-02") != solar {
t.Errorf("%s 往返得到 %s", solar, back.Format("2006-01-02"))
}
if back.Hour() != 9 || back.Minute() != 30 {
t.Errorf("%s 往返丢了时分:%v", solar, back)
}
}
}
// 农历月是 29 或 30 天不定,「每月农历三十」必然撞上 29 天的月份。
// lunar-go 在这种输入上**panic** 而不是返回 error —— 必须被夹住。
func TestToSolarClampsShortMonth(t *testing.T) {
// 2027 农历九月只有 29 天
days, err := DaysInMonth(2027, 9)
if err != nil {
t.Fatalf("查天数: %v", err)
}
if days != 29 {
t.Fatalf("前提变了2027 农历九月现在是 %d 天", days)
}
got, clamped, err := Date{Year: 2027, Month: 9, Day: 30}.ToSolar(time.Local, 9, 0, 0, 0)
if err != nil {
t.Fatalf("三十日应被夹到廿九而不是报错,得到 %v", err)
}
if !clamped {
t.Error("应报告发生了夹取")
}
// 夹取后应等于该月廿九
want, _, _ := Date{Year: 2027, Month: 9, Day: 29}.ToSolar(time.Local, 9, 0, 0, 0)
if !got.Equal(want) {
t.Errorf("夹取后 %v期望与廿九相同 %v", got, want)
}
// 且必须仍在同一个农历月内 —— 滚到下月初一是错的
if FromSolar(got).Month != 9 {
t.Errorf("夹取后跑出了农历九月:%s", FromSolar(got).String())
}
}
func TestToSolarRejectsNonexistentLeapMonth(t *testing.T) {
// 2026 无闰月
if LeapMonth(2026) != 0 {
t.Fatalf("前提变了2026 闰月 = %d", LeapMonth(2026))
}
_, _, err := Date{Year: 2026, Month: -6, Day: 1}.ToSolar(time.Local, 9, 0, 0, 0)
if err == nil {
t.Error("不存在的闰月应返回错误而不是猜一个日子")
}
}
func TestLeapMonth(t *testing.T) {
cases := map[int]int{2025: 6, 2026: 0, 2027: 0, 2028: 5}
for y, want := range cases {
if got := LeapMonth(y); got != want {
t.Errorf("LeapMonth(%d) = %d期望 %d", y, got, want)
}
}
}
func TestDaysInMonth(t *testing.T) {
if d, err := DaysInMonth(2026, 1); err != nil || d != 30 {
t.Errorf("2026 正月应 30 天,得到 %d err=%v", d, err)
}
if d, err := DaysInMonth(2027, 9); err != nil || d != 29 {
t.Errorf("2027 九月应 29 天,得到 %d err=%v", d, err)
}
if _, err := DaysInMonth(2026, -6); err == nil {
t.Error("2026 没有闰六月,应返回错误")
}
if _, err := DaysInMonth(2026, 13); err == nil {
t.Error("13 月应返回错误")
}
}
// 按农历推进不能靠加固定天数:农历月 29~30 天,闰年 13 个月。
func TestAddMonths(t *testing.T) {
d := Date{Year: 2026, Month: 7, Day: 22}
if got := d.AddMonths(1); got.Month != 8 || got.Year != 2026 {
t.Errorf("+1 月 = %d-%d期望 2026-8", got.Year, got.Month)
}
// 跨年
if got := (Date{Year: 2026, Month: 12, Day: 5}).AddMonths(1); got.Year != 2027 || got.Month != 1 {
t.Errorf("腊月 +1 = %d-%d期望 2027-1", got.Year, got.Month)
}
// 推 12 次回到同月次年
if got := d.AddMonths(12); got.Year != 2027 || got.Month != 7 {
t.Errorf("+12 月 = %d-%d期望 2027-7", got.Year, got.Month)
}
// 从闰月出发先归正:闰六月 +1 → 七月(一年 12 次,不因闰年多一次)
if got := (Date{Year: 2025, Month: -6, Day: 15}).AddMonths(1); got.Month != 7 {
t.Errorf("闰六月 +1 = %d 月,期望 7 月", got.Month)
}
}
func TestAddYears(t *testing.T) {
if got := (Date{Year: 2026, Month: 7, Day: 22}).AddYears(1); got.Year != 2027 || got.Month != 7 {
t.Errorf("+1 年 = %d-%d", got.Year, got.Month)
}
// 从闰月出发、目标年没有同一闰月 → 退回正月份而不是静默消失
got := (Date{Year: 2025, Month: -6, Day: 15}).AddYears(1)
if got.Year != 2026 || got.Month != 6 {
t.Errorf("闰六月 +1 年 = %d-%d期望 2026-6退回正六月", got.Year, got.Month)
}
// 目标年恰好也有同一闰月 → 保持闰月
// 2025 闰六月 → 2028 闰五月,所以这里构造 2028 的闰五月 +0 年
if LeapMonth(2028) == 5 {
keep := (Date{Year: 2028, Month: -5, Day: 1}).AddYears(0)
if keep.Month != -5 {
t.Errorf("目标年有同一闰月时应保持,得到 %d", keep.Month)
}
}
}
// 「每年农历某月某日」的公历日期每年都在漂移 —— 这正是需要农历重复的理由。
// 如果用公历 yearly日子会固定与用户的期望过农历生日/祭日)不符。
func TestYearlyLunarDriftsInSolar(t *testing.T) {
seen := map[string]bool{}
d := Date{Year: 2026, Month: 7, Day: 22}
for i := 0; i < 6; i++ {
st, _, err := d.AddYears(i).ToSolar(time.Local, 9, 0, 0, 0)
if err != nil {
t.Fatalf("第 %d 年转换失败: %v", i, err)
}
seen[st.Format("01-02")] = true
}
if len(seen) < 4 {
t.Errorf("六年里公历月日只有 %d 种,农历重复应当漂移", len(seen))
}
}
func TestStringChinese(t *testing.T) {
got := (Date{Year: 2026, Month: 7, Day: 22}).String()
if got != "二〇二六年七月廿二" {
t.Errorf("String() = %q期望 二〇二六年七月廿二", got)
}
// 闰月要带「闰」字,否则人分不清是哪个月
leap := (Date{Year: 2025, Month: -6, Day: 1}).String()
if leap != "二〇二五年闰六月初一" {
t.Errorf("闰月 String() = %q", leap)
}
// 非法日期不 panic
bad := (Date{Year: 2026, Month: 13, Day: 1}).String()
if bad == "" {
t.Error("非法日期应返回可读文本而不是空串")
}
}
func TestFormatSolar(t *testing.T) {
st, _ := time.Parse("2006-01-02", "2026-09-03")
got := FormatSolar(st)
if got != "2026-09-03农历七月廿二" {
t.Errorf("FormatSolar = %q期望 2026-09-03农历七月廿二", got)
}
}
// 时区:农历只定义到「日」,时分秒是公历时钟的概念,必须原样带过去。
func TestToSolarKeepsClockTime(t *testing.T) {
got, _, err := (Date{Year: 2026, Month: 7, Day: 22}).ToSolar(time.Local, 14, 45, 30, 0)
if err != nil {
t.Fatalf("转换: %v", err)
}
if got.Hour() != 14 || got.Minute() != 45 || got.Second() != 30 {
t.Errorf("时钟被改动:%v", got)
}
if got.Location() != time.Local {
t.Errorf("时区被改动:%v", got.Location())
}
}