package handler import ( "strings" "testing" "time" "github.com/agentmail/gateway/internal/lunar" ) /* * 农历标签的判据。 * * # 为什么这些断言值得写(而不是"跑通就行") * * 农历换算错的表现是**日期错位,不是报错**:格子上照样有字, * 只是那个字对应的是别的日子。这类错在界面上最难发现(用户得自己去查日历才知道), * 所以这里把几类会静默出错的点各自钉一条。 * * ★ 参照物是 `internal/lunar`(服务端的 Go 实现,同一个作者 6tail 的库)—— * 判据不去抄一份"正确答案表",而是**要求两边一致**,这样库升级时判据跟着走。 */ // 真值取自 `lunar.Date.String()`(服务端权威实现),不是手抄的。 func TestLunarLabelsMatchAuthority(t *testing.T) { dates := []string{ "2026-09-18", // 实测:农历七月廿二 "2026-02-17", // 春节(正月初一)—— 月名与日名同时要换的那一天 "2024-02-10", // 春节(闰年附近) "2027-09-30", // 农历月只有 29 天的那类边界年份 "2000-01-01", // 跨世纪 } for _, key := range dates { at, err := time.Parse("2006-01-02", key) if err != nil { t.Fatalf("测试用例本身写错了: %s", key) } got, ok := labelsOf(at) if !ok { t.Errorf("%s: 应当换得出来,实际 ok=false", key) continue } want := lunar.FromSolar(at).String() if got.Full != want { t.Errorf("%s: Full 要与 lunar 包一致\n got %q\nwant %q", key, got.Full, want) } } } // 日名必须覆盖 1..30 且**五种前缀形态都在**。 // // WebUI 那版就漏了「二十」,20 号会退化成显示整串「七月二十」—— // 一个正则从 `String()` 里截的典型漏法。查表版要保证表本身是全的。 func TestLunarDayNamesCoverAllPrefixForms(t *testing.T) { if len(lunarDayNames) != 31 { t.Fatalf("日名表要有 31 项(下标 0 占位 + 1..30),实际 %d", len(lunarDayNames)) } // 五种前缀形态各至少要有一个代表 —— 少任何一种就是漏了一类日子 for _, pair := range []struct{ day int; want string }{ {1, "初一"}, // 初 {11, "十一"}, // 十 {20, "二十"}, // 二(这一条是 WebUI 漏过的那个) {21, "廿一"}, // 廿 {30, "三十"}, // 三 } { if got := lunarDayNames[pair.day]; got != pair.want { t.Errorf("第 %d 天应为 %q,实际 %q", pair.day, pair.want, got) } } // 表里不许有空项(除了下标 0) for i := 1; i <= 30; i++ { if lunarDayNames[i] == "" { t.Errorf("日名表第 %d 项是空的 —— 那天会显示不出农历", i) } } } // 初一显示月名、其余显示日名(与 WebUI `cellLunarLabel()` 同一口径)。 // // 两端必须是同一个串:同一天在两边日历上长得不一样,是对齐工作里的低级漏。 func TestLunarCellTextIsMonthNameOnFirstDay(t *testing.T) { // 2026-02-17 是农历正月初一;2026-09-18 是七月廿二 newYear, _ := time.Parse("2006-01-02", "2026-02-17") got, ok := labelsOf(newYear) if !ok { t.Fatal("春节应当换得出来") } if !got.IsFirstDay { t.Errorf("2026-02-17 应当是初一,实际 IsFirstDay=false(%s)", got.Full) } if got.Text != got.MonthName { t.Errorf("初一的格子文案要是**月名**,实际 %q(月名 %q)", got.Text, got.MonthName) } ordinary, _ := time.Parse("2006-01-02", "2026-09-18") g, ok := labelsOf(ordinary) if !ok { t.Fatal("2026-09-18 应当换得出来") } if g.IsFirstDay { t.Error("2026-09-18 不该是初一") } if g.Text != g.DayName { t.Errorf("非初一的格子文案要是**日名**,实际 %q(日名 %q)", g.Text, g.DayName) } } // 闰月必须带「闰」字。 // // 不带的话闰六月与六月在格子里长得一模一样 —— 而"过生日到底过哪个六月" // 正是农历里最需要区分的一件事。 func TestLunarLeapMonthIsMarked(t *testing.T) { // 2025 年有闰六月(业界共识;用权威实现扫一遍确认,而不是背下来) found := false start, _ := time.Parse("2006-01-02", "2025-06-01") for i := 0; i < 400; i++ { d := start.AddDate(0, 0, i) labels, ok := labelsOf(d) if ok && labels.Leap { found = true if labels.MonthName[0:len("闰")] != "闰" { t.Errorf("%s 是闰月但月名没带「闰」:%q", d.Format("2006-01-02"), labels.MonthName) } // 权威实现也要认为它是闰月(双实现一致,不是我这边认错) if lunar.FromSolar(d).Month >= 0 { t.Errorf("%s: 我说是闰月,lunar 包说是 %d(正数=非闰)", d.Format("2006-01-02"), lunar.FromSolar(d).Month) } break } } if !found { t.Error("2025 年前后应当扫得到一个闰月 —— 扫不到说明闰月判断坏了(或测试范围选错了)") } } // 越界/极端值**不许 panic**(这是 recover 那层的实质),且真换不出来时必须 ok=false 而不是给个假日子。 // // ★ 这条我第一版写错了:我断言「time.Time{} 应当换不出来」,实测库**换得出来** // (0001-01-01 → 「〇年冬月十八」)—— 断言的是我的**想象**而不是实际行为。 // 现在改成断言真正的契约:① 不 panic;② 换不出来就不给(不猜); // ③ **给出来的时候文字里不许出现“无效/NaN”这类失败标记**。 func TestLunarLabelsRefuseRatherThanGuess(t *testing.T) { cases := []time.Time{ time.Time{}, // 零值(公元 1 年) time.Date(1900, 6, 15, 0, 0, 0, 0, time.UTC), // 库的传统下限附近 time.Date(2100, 6, 15, 0, 0, 0, 0, time.UTC), // 上限 time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC), // 极端上限 } for _, d := range cases { got, ok := func() (l LunarLabels, ok bool) { defer func() { if r := recover(); r != nil { t.Errorf("%s: 换算是天文算法,极端值不许 panic(实测可 recover),实际 panic: %v", d.Format("2006-01-02"), r) ok = false } }() return labelsOf(d) }() if !ok { continue // 换不出来是合法的(调用方据此不显示农历) } /* * 换出来了就要求它是个**真结果**:`lunar.Date.String()` 在无效输入时会 * fallback 成「农历 %d-%d-%d」或带「无效」字样 —— 那种串不能当标签发给客户端, * 否则格子上会直接显示出错信息。 */ for _, marker := range []string{"无效", "NaN", "农历 "} { if got.Full == "" || strings.Contains(got.Full, marker) { t.Errorf("%s: 换出来的 Full 含失败标记或为空(%q)—— 失败就该 ok=false,不该发个假日子", d.Format("2006-01-02"), got.Full) } } if got.Text == "" || got.DayName == "" || got.MonthName == "" { t.Errorf("%s: 换出来了但字段为空(%+v)", d.Format("2006-01-02"), got) } } } // 日期键是 UTC 口径,不许被本机时区挪一天。 // // 这个端点的入参是"格子的键"(日期字面量),不是"瞬间"—— // 混用会让 UTC+8 的凌晨把标签挂到前一天,而界面看起来完全正常。 func TestParseDateKeyIsTimezoneIndependent(t *testing.T) { at, ok := parseDateKey("2026-09-18") if !ok { t.Fatal("合法的日期键应当解析成功") } if got := at.Format("2006-01-02"); got != "2026-09-18" { t.Errorf("日期键往返必须不变,实际 %s", got) } for _, bad := range []string{"2026-9-18", "2026/09/18", "", "2026-09-18T00:00:00Z", "not-a-date"} { if _, ok := parseDateKey(bad); ok { t.Errorf("%q 不该被接受为日期键(只收 YYYY-MM-DD)", bad) } } }