用户定的方案:「加 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 留痕。区间按**网格**取(不只本月 —— 月视图首尾显示上/下月格子)。
199 lines
7.5 KiB
Go
199 lines
7.5 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|