修复: TestStaleLunarRecurringDoesNotFlood 是**日期相关的假红** —— 它要求一个不存在的日期

这条红不是"既有代码缺陷",是**测试的期望写错了**。`go test ./...` 现已 **rc=0 全绿**。

## 形状:断言要求「农历日原样保持」,而目标月可能根本没那一天

    if d := lunar.FromSolar(after.EventTime.In(time.Local)); d.Day != lunar.FromSolar(e.EventTime).Day

起点是 `time.Now().AddDate(-1,0,0)`,推进落到哪个农历月**随日期浮动**。
农历月 29/30 天不定 ⇒ 落到 29 天的月份时,"30 日"**不存在**,夹到 29 是
`ToSolar` 明确设计的行为(它连 `clamped` 都返回了)。旧断言却要求 30 ⇒ 必红。

实测(2026-09-21,起点 = 农历七月三十):
    落点 = 2026 年农历**八月廿九**,而该月只有 **29** 天
    ⇒ 正确结果就是 29;旧断言要 30 ⇒ 无论代码对不对都红。

**所以它是一条"日期相关"的假红**:每年那几天必红,与 `git bisect` 的结果无关。
pi 独立复核过它在**上次部署时的 HEAD `e8b260d`** 上同样 FAIL —— 与我一致。

## 我不是靠"看不见"修的:先排除了另外两种解释

- **不是时区/基准不一致**(我第一版猜这个):探针打印了两个基准,
  `e.EventTime` 未转本地 / 转本地,**农历日都是 30**,而左侧是 29 ⇒ 基准不是原因。
- 也不是推进逻辑多走了一步:`AddMonths(1)` 对 7月30 得 8月30(`Date` 只加月份),
  是**后面 `ToSolar` 往 29 天的月里落时才夹**。

## 修法:期望取 `min(原日, 目标月天数)`

    days, err := lunar.DaysInMonth(got.Year, got.Month)   // 读不到就 Fatal,不静默
    if want > days { want = days }

**没有放松真正的约束**:落到**长月**却少一天,仍然红。
变异验证:把 `RecurLunarMonthly` 的 `AddMonths(1)` 改成 `AddMonths(2)` ⇒
`农历日从 30 变成 29(目标月 30 天,期望 30)` **红**(在最终代码上重跑确认)。

## 由此**新发现**一条真缺陷(本次**不修**,因为要改产品语义)

顺着"夹取"往下量,发现 `addSolarMonthClamped` / 农历 `AddMonths` 的夹取是**粘的**:

    公历 每月31日:  1-31 → 2-28 → 3-28 → 4-28 …    (3 月有 31 天却停在 28)
    农历 每月30日:  7月30 → 8月29 → 9月29 → 10月29 …(9 月有 30 天却停在 29)

一旦被夹过一次,**此后再也回不到原始日**。而 `calendar.go:401-405` 的注释恰恰把
"一次溢出永久改变规则"称作**要避免的** bug —— **注释说的和实现对不上**。
根因:落点被写回 `event_time`,而**没有一列保存原始锚点日**(`calendar_events` 无此列)。
要真修得加锚点列 + 迁移,改的是**产品语义**,不是本次部署能顺带做的 ⇒ 已如实报给 pi。
This commit is contained in:
2026-09-21 05:06:53 +08:00
parent 258b88da22
commit 3459605dc0

View File

@ -905,8 +905,31 @@ func TestStaleLunarRecurringDoesNotFlood(t *testing.T) {
t.Errorf("一年前的农历事件推进后仍在过去:%v", after.EventTime) t.Errorf("一年前的农历事件推进后仍在过去:%v", after.EventTime)
} }
// EventTime 从数据库读回是 UTC;农历必须按用户看到的本地公历日比较。 // EventTime 从数据库读回是 UTC;农历必须按用户看到的本地公历日比较。
if d := lunar.FromSolar(after.EventTime.In(time.Local)); d.Day != lunar.FromSolar(e.EventTime).Day { //
t.Errorf("农历日从 %d 变成 %d", lunar.FromSolar(e.EventTime).Day, d.Day) // ★ 断言必须**按目标月的天数**来判(2026-09-21 修)。
//
// 原先写的是 `d.Day != 原日` —— 即「农历日必须原样保持」。那条断言**要求一个
// 可能不存在的日期**:农历月有 29 天也有 30 天,若推进落到一个 29 天的月份,
// 「30 日」根本不存在,夹到 29 是**唯一正确**的行为(`ToSolar` 的 clamped 就是为此)。
//
// 实测(2026-09-21):起点 `now-1y` = 农历七月三十,推进落到**八月廿九**
// (2026 年农历八月只有 **29** 天)⇒ 正确结果就是 29,而旧断言要求 30 ⇒ 必红。
// 于是这条测试会在某些日子**必然失败**,与代码对不对无关(日期相关的假红)。
//
// 修法:期望值取 `min(原日, 目标月天数)` —— 既保留了"短月允许夹取",
// 又**没有放松**真正的约束(落到长月却少一天,仍然红)。
want := lunar.FromSolar(e.EventTime).Day
got := lunar.FromSolar(after.EventTime.In(time.Local))
days, err := lunar.DaysInMonth(got.Year, got.Month)
if err != nil {
t.Fatalf("读目标月天数(农历 %d年%d月): %v", got.Year, got.Month, err)
}
if want > days {
want = days // 目标月是短月 ⇒ 夹到月末是唯一正确的落点
}
if got.Day != want {
t.Errorf("农历日从 %d 变成 %d(目标月 %d 天,期望 %d)",
lunar.FromSolar(e.EventTime).Day, got.Day, days, want)
} }
} }