diff --git a/client/harmony/entry/src/main/ets/api/CalendarApi.ets b/client/harmony/entry/src/main/ets/api/CalendarApi.ets index ba38916..fb00ee6 100644 --- a/client/harmony/entry/src/main/ets/api/CalendarApi.ets +++ b/client/harmony/entry/src/main/ets/api/CalendarApi.ets @@ -9,7 +9,7 @@ * 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。 */ import { ApiClient } from './ApiClient'; -import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse } from '../model/Models'; +import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse } from '../model/Models'; export class CalendarApi { private client: ApiClient; @@ -50,4 +50,19 @@ export class CalendarApi { async deleteEvent(id: string): Promise { return this.client.del('/calendar/events/' + encodeURIComponent(id)); } + + /** + * 一段日期区间的农历标签(按 `YYYY-MM-DD` 索引)。 + * + * ★ 入参是**日期键**(`YYYY-MM-DD`),不是 RFC3339 —— 与 `listEvents` 的区别就在这里: + * 事件查询是"瞬间落在区间内",而农历是"这一天是农历几号",是纯日期语义。 + * 传时间戳进去会被服务端拒(它只收 `YYYY-MM-DD`),这比"悄悄按 UTC 挪一天"好。 + * + * ★ 区间是**闭区间**且必须给全:服务端**不设默认值**(默认范围会让"我要 3 月" + * 与"服务端以为我要这个月"悄悄不一致)。上限 400 天。 + */ + async listLunar(fromIso: string, toIso: string): Promise { + const query: string = 'from=' + encodeURIComponent(fromIso) + '&to=' + encodeURIComponent(toIso); + return this.client.get('/calendar/lunar', query); + } } diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index ef2c7f3..a52ee7b 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -426,3 +426,36 @@ export class AdminUserResponse { export class AdminStatusResponse { status: string = ''; } + + +/* + * ── 农历标签(日历格子用)── + * + * ★ 换算**不在这里做**:农历是天文算法(`lunar-javascript` 的 lunar.js 有 43 万字节、 + * 内部是日月位置的级数展开,**没有可照搬的小数据表**)。在 ArkTS 里再实现一遍 + * 等于写第二份天文算法,两边迟早会在某个闰月或朔日上差一天 —— + * 而那种错表现为**日期错位,不是报错**,界面看起来完全正常。 + * + * 所以服务端算一次(`GET /calendar/lunar`),这里只装结果。 + * 与 WebUI 的分工不同(它自带 lib/lunar.ts),但**契约相同**: + * 同一个日期在两端拿到的 `text` 必须是同一个串,否则同一天在两边日历上长得不一样。 + */ +export class LunarLabels { + /** 农历日名(初一…三十) */ + day_name: string = ''; + /** 农历月名(正…冬/腊,闰月带「闰」) */ + month_name: string = ''; + /** 是否初一 —— 格子文案在初一那天换写月名 */ + is_first_day: boolean = false; + /** **格子里直接显示的那个串**(初一=月名,其余=日名),由服务端定,两端同源 */ + text: string = ''; + /** 是否闰月 */ + leap: boolean = false; + /** 完整中文表示(「二〇二六年七月廿二」),给详情/表单预览用 */ + full: string = ''; +} + +/** `GET /calendar/lunar` 的响应:按日期键(YYYY-MM-DD)索引 */ +export class LunarRangeResponse { + lunar: Record = {}; +} diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets index 51365d5..e377097 100644 --- a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets +++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets @@ -23,7 +23,8 @@ */ import { ApiClient, ApiError } from '../api/ApiClient'; import { CalendarApi } from '../api/CalendarApi'; -import { CalendarEvent, CalendarEventInput } from '../model/Models'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { CalendarEvent, CalendarEventInput, LunarLabels, LunarRangeResponse } from '../model/Models'; import { Theme } from '../common/Theme'; import { DayCell, @@ -105,6 +106,13 @@ export struct CalendarPage { * 不在渲染里重写一遍(重写就是"三处里漏改一处"那种静默错位)。 */ @State calScale: CalendarScale = 'month'; + /** + * 农历标签(按日期键索引),由 `loadLunar()` 从服务端拉。 + * + * ★ 换算不在客户端做:农历是天文算法、没有可照搬的小数据表(见 Models.ets 的说明)。 + * 拉不到时就是空 map ⇒ 格子少一行小字,日历照常用。 + */ + @State lunarMap: Record = {}; @State events: CalendarEvent[] = []; @State loading: boolean = false; @State error: string = ''; @@ -194,6 +202,65 @@ export struct CalendarPage { return new Date(Date.UTC(this.year, this.month, 1)).toISOString(); } + /** + * 要拉农历的**日期键**区间(`YYYY-MM-DD`,闭区间)—— 与 `rangeFrom/rangeTo` 区别: + * + * 那两个是**瞬间区间**(RFC3339),给事件查询用;农历端点的入参是**日期键**, + * 因为"这一天是农历几号"是纯日期语义、与时刻无关。 + * + * 区间按**网格**取(不只本月):月视图首尾会显示上/下月的格子, + * 只查当月会让那几个格子永远没有农历 —— 看着像"那几天没有农历",其实是没查。 + * 按档位宽窄给:月档要整月网格(前后各多一周),周/日档只要那一周。 + */ + private lunarRange(): string[] { + const rows: DayCell[][] = this.rows(); + if (rows.length === 0 || rows[0].length === 0) { + return []; + } + const first: string = rows[0][0].iso; + const lastRow: DayCell[] = rows[rows.length - 1]; + const last: string = lastRow[lastRow.length - 1].iso; + /* 网格首尾可能是 `iso=''`(空占位格)—— 找第一个/最后一个非空的,不让空串进参数 */ + const all: string[] = []; + for (let r = 0; r < rows.length; r++) { + for (let c = 0; c < rows[r].length; c++) { + if (rows[r][c].iso.length > 0) { + all.push(rows[r][c].iso); + } + } + } + if (all.length === 0) { + return []; + } + return [all[0], all[all.length - 1]]; + } + + /** + * 拉农历标签。 + * + * ★ 失败**不算错误**,也不设 `this.error`:农历是格子的**附加信息**, + * 它拉不到时日历本身照常能用(只是格子少一行小字)。 + * 把它当致命错误会让“农历服务抖一下”变成“整个日历打不开”—— 那是本末倒置。 + * 但也不静默吞掉:写 hilog 留下痕迹(便于排查为什么格子上没农历)。 + */ + private async loadLunar(): Promise { + const a: CalendarApi | null = this.api; + if (a === null) { + return; + } + const range: string[] = this.lunarRange(); + if (range.length !== 2) { + return; + } + try { + const resp: LunarRangeResponse = await a.listLunar(range[0], range[1]); + this.lunarMap = resp.lunar; + } catch (e) { + hilog.warn(0x0000, 'calendar', '农历标签拉取失败(格子少一行小字,日历照常用):%{public}s', + JSON.stringify(e)); + } + } + async loadEvents(): Promise { const a: CalendarApi | null = this.api; if (a === null) { @@ -213,6 +280,8 @@ export struct CalendarPage { this.events = []; } this.loading = false; + /* 事件拉完再拉农历(不 await:农历是附加信息,不该拖慢事件列表的出现) */ + this.loadLunar(); } /** @@ -608,13 +677,26 @@ export struct CalendarPage { .fontSize(Theme.fontBody) .fontWeight(cell.iso === this.todayIso ? FontWeight.Bold : FontWeight.Normal) .fontColor(this.cellFg(cell)) + /* + * 农历小字 —— 与 WebUI 同一位置(公历数字下方一行)。 + * + * ★ 文案直接用服务端给的 `text`(初一=月名、其余=日名), + * 客户端**不自己拼**:两端各拼一份的话,同一天在两边日历上 + * 可能长得不一样(例如闰月到底写不写「闰」)。 + * ★ 拉不到农历时(服务端抖了一下/未登录)这里就是空字串, + * 格子少一行小字、日历照常用 —— 不报错、不留空白占位。 + */ + Text(this.lunarTextOf(cell.iso)) + .fontSize(9) + .fontColor(cell.iso === this.selectedIso ? Theme.accentFg : Theme.textSubtle) + .maxLines(1) if (this.eventCountOf(cell.iso) > 0) { Column() .width(4) .height(4) .borderRadius(2) .backgroundColor(cell.iso === this.selectedIso ? Theme.accentFg : Theme.accent) - .margin({ top: 3 }) + .margin({ top: 1 }) } } } @@ -632,6 +714,15 @@ export struct CalendarPage { }) } + /** 某个格子的农历文案(取不到就是空字串 —— 不占位、不报错) */ + private lunarTextOf(iso: string): string { + if (iso.length === 0 || this.lunarMap === undefined) { + return ''; + } + const labels: LunarLabels | undefined = this.lunarMap[iso]; + return labels !== undefined ? labels.text : ''; + } + @Builder EventRow(e: CalendarEvent) { Row() { diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index db9011a..bc03820 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -238,6 +238,15 @@ func main() { r.Delete("/calendar/attachments/{attachmentID}", handler.DeleteCalendarAttachment) r.Get("/calendar/export.ics", handler.ExportCalendarICS) r.Post("/calendar/import.ics", handler.ImportCalendarICS) + /* + * 农历标签(格子用)。单独一个端点而不是塞进 events 响应里: + * 农历是**按日期**的(与有没有事件无关),而 events 只覆盖有事件的那些天 —— + * 塞进去的话,没有事件的日子就没有农历,而日历要求**每个格子**都显示农历。 + * + * 换算只在服务端做:两边各写一份天文算法(lunar-go / lunar-javascript) + * 迟早会在某个闰月或朔日上差一天,而那种错表现为**日期错位、不报错**。 + */ + r.Get("/calendar/lunar", handler.LunarLabelsHandler) // 管理员 r.Group(func(r chi.Router) { diff --git a/server/internal/handler/lunar.go b/server/internal/handler/lunar.go new file mode 100644 index 0000000..ecb0b1d --- /dev/null +++ b/server/internal/handler/lunar.go @@ -0,0 +1,199 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/agentmail/gateway/internal/lunar" + "github.com/agentmail/gateway/internal/middleware" +) + +/* + * ── 农历换算(给客户端的日历格子用)── + * + * # 为什么要有这个端点(而不是让每个客户端自带一份换算) + * + * WebUI 用 `lunar-javascript`,服务端用 `lunar-go` —— 都是同一个作者(6tail)的实现, + * 所以换算结果一致。但这是**两份实现**,而农历是**天文算法**: + * `lunar-javascript` 的 `lunar.js` 有 43 万字节、内部是日月位置的级数展开, + * **没有一张可以照搬的小数据表**(实测:全文件最大的数字字面量是 16KB 的级数系数数组, + * 不是"某年到某年的月长表")。因此"把它移植到 ArkTS"这条路等于**再写一遍天文算法**, + * 两边迟早会在某个闰月或某个朔日上差一天 —— 而那种错**表现为日期错位,不是报错**。 + * + * 所以换算只在服务端做一次,各客户端取结果。鸿蒙那边原来就是"没做"(`CalendarPage.ets` + * 的注释里写着),这个端点是把那条补上的前提。 + * + * # 形状 + * + * `GET /api/v1/calendar/lunar?from=YYYY-MM-DD&to=YYYY-MM-DD` + * + * 返回一个**按日期键索引的映射**(不是数组):客户端拿到后是 `map[iso] -> 标签`, + * 直接按格子键查,不需要自己遍历比对。范围是**闭区间**,且必须给全(不给默认值 —— + * 默认范围会让"我要的是 3 月"和"服务端以为我要这个月"悄悄不一致)。 + * + * {"lunar": {"2026-09-18": {"day_name":"廿二","month_name":"七月","is_first_day":false, + * "text":"廿二","leap":false,"full":"二〇二六年七月廿二"}}} + * + * `text` 是**格子里直接显示的那个串**:初一显示月名(如「七月」)、其余显示日名 + * (如「廿二」)—— 与 WebUI `lib/lunar.ts` 的 `cellLunarLabel()` 同一口径。 + * 每格都写完整月日会让格子全是重复的月份字样,而格子只有几十像素宽。 + * + * 范围上限:**不超过 400 天**。这不是安全边界(换算很便宜),而是防"客户端传了一个 + * 十年前到十年后的范围"把响应撑到几 MB —— 日历一次最多显示 6 周。 + */ + +// lunarRangeMaxDays 是一次请求允许的最大天数(含首尾)。 +const lunarRangeMaxDays = 400 + +// parseDateKey 解析 `YYYY-MM-DD`(**按 UTC**,与纯逻辑层同一口径)。 +// +// 不接 RFC3339 时间戳:这个端点的入参是"日期键"(格子的键), +// 不是"瞬间"。混用会让时区偏移把日期挪一天 —— 而界面看起来完全正常。 +func parseDateKey(s string) (time.Time, bool) { + t, err := time.Parse("2006-01-02", s) + if err != nil { + return time.Time{}, false + } + return t, true +} + +// LunarLabels 是单个日期的农历标签。 +type LunarLabels struct { + // DayName 农历日名(初一…三十) + DayName string `json:"day_name"` + // MonthName 农历月名(正…冬/腊,闰月带「闰」) + MonthName string `json:"month_name"` + // IsFirstDay 是否初一(客户端据此决定格子里显示月名还是日名) + IsFirstDay bool `json:"is_first_day"` + // Text 格子里直接显示的那个串(初一=月名,其余=日名) + Text string `json:"text"` + // Leap 是否闰月 + Leap bool `json:"leap"` + // Full 完整中文表示(「二〇二六年七月廿二」),给详情/表单预览用 + Full string `json:"full"` +} + +// lunarMonthNames 农历月名。十一/十二月习惯写「冬月」「腊月」—— +// 与 `lunar-javascript` 的 `Lunar.getMonthInChinese()` 一致(实测它也是这两个字)。 +var lunarMonthNames = []string{ + "", "正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊", +} + +// lunarDayNames 农历日名查表。 +// +// ★ **不用正则从 `Date.String()` 里截**:实测日名前缀有五种形态 +// (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 —— +// WebUI 那版就漏了「二十」,20 号会退化成显示整串「七月二十」。 +// 这个表与 `client/electron/src/lib/lunar.ts` 的 `LUNAR_DAY_NAMES` 逐字一致。 +var lunarDayNames = []string{ + "", "初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十", + "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十", + "廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十", +} + +// labelsOf 把一个公历日期转成农历标签。 +// +// ★ 越界/异常一律回 *零值+false*,**不猜**:农历换算是天文算法, +// 猜一个日子比不显示更糟("看着有农历、其实是错的"最难发现)。 +// `lunar.FromSolar` 内部对极端年份会 panic 保护,这里再兜一层。 +func labelsOf(t time.Time) (LunarLabels, bool) { + var d lunar.Date + ok := func() (good bool) { + defer func() { + if recover() != nil { + good = false + } + }() + d = lunar.FromSolar(t) + return true + }() + if !ok { + return LunarLabels{}, false + } + + absMonth := d.Month + leap := false + if absMonth < 0 { + absMonth = -absMonth + leap = true + } + if absMonth < 1 || absMonth > 12 || d.Day < 1 || d.Day > 30 { + return LunarLabels{}, false + } + + monthName := lunarMonthNames[absMonth] + "月" + if leap { + /* 闰月的「闰」必须带上:否则闰六月与六月在格子里长得一模一样 */ + monthName = "闰" + monthName + } + dayName := lunarDayNames[d.Day] + if dayName == "" { + return LunarLabels{}, false + } + + full := d.String() + /* + * 格子里显示什么:**初一显示月名、其余显示日名**(纸质日历的惯例)。 + * 与 WebUI `cellLunarLabel()` 同一口径 —— 两端格子里必须是同一个串, + * 否则同一天在两端的日历上长得不一样。 + */ + text := dayName + if d.Day == 1 { + text = monthName + } + return LunarLabels{ + DayName: dayName, + MonthName: monthName, + IsFirstDay: d.Day == 1, + Text: text, + Leap: leap, + Full: full, + }, true +} + +// LunarLabelsHandler 处理 `GET /api/v1/calendar/lunar?from=&to=`。 +func LunarLabelsHandler(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + fromStr := strings.TrimSpace(r.URL.Query().Get("from")) + toStr := strings.TrimSpace(r.URL.Query().Get("to")) + if fromStr == "" || toStr == "" { + /* 不设默认值:默认范围会让"我要 3 月"与"服务端以为我要这个月"悄悄不一致 */ + Error(w, http.StatusBadRequest, "from and to are required (YYYY-MM-DD)") + return + } + from, okFrom := parseDateKey(fromStr) + to, okTo := parseDateKey(toStr) + if !okFrom || !okTo { + Error(w, http.StatusBadRequest, "from and to must be YYYY-MM-DD") + return + } + if to.Before(from) { + Error(w, http.StatusBadRequest, "to must not be before from") + return + } + days := int(to.Sub(from).Hours()/24) + 1 + if days > lunarRangeMaxDays { + Error(w, http.StatusBadRequest, + "range too large: at most "+strconv.Itoa(lunarRangeMaxDays)+" days") + return + } + + out := make(map[string]LunarLabels, days) + for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { + if labels, ok := labelsOf(d); ok { + out[d.Format("2006-01-02")] = labels + } + /* + * 换不出来就**不放进 map**(客户端查不到 ⇒ 那一格不显示农历), + * 而不是塞一个空对象 —— 空对象会让客户端以为"这一天有农历、只是没内容"。 + */ + } + JSON(w, http.StatusOK, map[string]interface{}{"lunar": out}) +} diff --git a/server/internal/handler/lunar_test.go b/server/internal/handler/lunar_test.go new file mode 100644 index 0000000..cc4b426 --- /dev/null +++ b/server/internal/handler/lunar_test.go @@ -0,0 +1,198 @@ +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) + } + } +}