Files
MailUI4Agents/server/internal/handler/lunar_test.go
JianFeeeee 2da38bba83 农历走服务端端点:换算只在服务端做一次(两边各写一遍天文算法迟早差一天)
用户定的方案:「加 api 端点」。

## 为什么不移植到 ArkTS

`lunar-javascript` 的 `lunar.js` 有 **43 万字节**,内部是**日月位置的级数展开**
(实测:全文件最大的数字字面量是 16KB 的系数数组,**不是**"某年到某年的月长表")。
即"照搬一张小数据表"这条路**不存在** —— 移植等于在 ArkTS 里再实现一遍天文算法。
两份实现迟早会在某个闰月或某个朔日上差一天,而那种错**表现为日期错位、不是报错**,
界面上完全看不出(用户得自己去查日历才知道)。

所以:`GET /api/v1/calendar/lunar?from=&to=`(服务端 `internal/lunar`,同一作者的 lunar-go)。

## 形状是「按日期键索引的映射」,不是数组

客户端拿到 `map[iso] -> 标签` 直接按格子键查,不用自己遍历比对。
`text` 字段是**格子里直接显示的那个串**(初一=月名、其余=日名)——
由服务端定,两端同源。客户端各拼一份的话,同一天在两边日历上可能长得不一样
(例如闰月到底写不写「闰」)。

几个刻意的取舍:
- **不设默认 from/to**:默认范围会让「我要 3 月」与「服务端以为我要这个月」悄悄不一致;
- 入参只收 `YYYY-MM-DD`(**日期键**,不是 RFC3339):农历是"这一天是农历几号"的
  纯日期语义,混用时间戳会被时区挪一天;
- 换不出来的日子**不进 map**(客户端查不到 ⇒ 那格不显示农历),而不是塞空对象 ——
  空对象会让客户端以为"有农历、只是没内容";
- 区间上限 400 天(不是安全边界,是防客户端传十年前到十年后)。

## 判据(`server/internal/handler/lunar_test.go`)

参照物是服务端的 `internal/lunar`(权威实现),**不抄一份答案表** —— 库升级时判据跟着走。
钉的点各自对着一个会静默出错的错法:
- `Full` 与权威实现逐字一致(5 个日期,含春节、跨世纪、29 天月的边界年份);
- 日名表覆盖 1..30 且**五种前缀形态都在**(WebUI 那版漏过「二十」);
- ★ 初一显示**月名**、其余显示**日名**(与 WebUI `cellLunarLabel()` 同一口径);
- 闰月必须带「闰」字(不带的话闰六月与六月在格子里一样);
- 极端值(公元 1 年 / 1900 / 2100 / 9999)**不许 panic**,且换出来时文字里不许含
  「无效/NaN」这类失败标记。

★ 最后一条我第一版**写错了**:断言「`time.Time{}` 应当换不出来」,实测库**换得出来**
  (0001-01-01 → 「〇年冬月十八」)—— 我断言的是自己的想象而不是实际行为。
  改成断言真正的契约(不 panic / 失败就不给 / 给了就得是真结果)后才对。

## 客户端

`LunarLabels` / `LunarRangeResponse` 两个模型 + `CalendarApi.listLunar(fromIso, toIso)`;
`CalendarPage` 在 `loadEvents()` 之后**不 await** 地拉农历(附加信息不该拖慢事件列表),
失败**不算 `this.error`**(否则"农历服务抖一下"会变成"整个日历打不开"),
只写 hilog 留痕。区间按**网格**取(不只本月 —— 月视图首尾显示上/下月格子)。
2026-09-18 11:23:12 +08:00

199 lines
7.5 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 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)
}
}
}