package calendar import ( "fmt" "testing" "time" ) // 1582 改历:反向的 +10 天窗口必须与正向的 −10 天修正对称(1582-10-05..1583-01-13)。 // formatAncientSolar 统一负年份的显示。 func formatAncientSolar(t time.Time) string { year, sign := t.Year(), "" if year < 0 { year, sign = -year, "-" } return fmt.Sprintf("%s%04d-%02d-%02d", sign, year, int(t.Month()), t.Day()) } func TestGregorianReformReverseMatchesForward(t *testing.T) { cases := []struct { ly, lm, ld int leap bool want string }{ {1582, 12, 8, false, "1583-01-01"}, // 连续坐标 1582-12-22,原有 1582 规则覆盖 {1582, 12, 18, false, "1583-01-11"}, // 连续坐标 1583-01-01,修复前返回 1583-01-01 {1582, 12, 30, false, "1583-01-23"}, // 连续坐标 1583-01-13,修复前返回 1583-01-13 {1583, 1, 1, false, "1583-01-24"}, // 连续坐标 1583-01-14,本就不该 +10 } for _, tc := range cases { got, err := LunarToSolarByYMD(tc.ly, tc.lm, tc.ld, tc.leap) if err != nil { t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", tc.ly, tc.lm, tc.ld, err) } if s := got.Solar().Format("2006-01-02"); s != tc.want { t.Fatalf("LunarToSolarByYMD(%d,%d,%d) = %s, want %s", tc.ly, tc.lm, tc.ld, s, tc.want) } } // 改历前后整段往返一致(跳过被删除的 1582-10-05..14,它们在公历里不存在)。 // The whole span round-trips apart from the ten days the reform deleted. for day := time.Date(1582, 10, 15, 0, 0, 0, 0, getCst()); day.Before(time.Date(1583, 3, 1, 0, 0, 0, 0, getCst())); day = day.AddDate(0, 0, 1) { y, m, d := day.Year(), int(day.Month()), day.Day() lunar, err := SolarToLunarByYMD(y, m, d) if err != nil { t.Fatalf("SolarToLunarByYMD(%d,%d,%d) err=%v", y, m, d, err) } l := lunar.Lunar() back, err := LunarToSolarByYMD(l.LunarYear(), l.LunarMonth(), l.LunarDay(), l.IsLeap()) if err != nil { t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", l.LunarYear(), l.LunarMonth(), l.LunarDay(), err) } if s := back.Solar(); s.Year() != y || int(s.Month()) != m || s.Day() != d { t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 %s(改历往返不一致)", y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), s.Format("2006-01-02")) } } } // 先秦岁首跨界:正向按公历年选默认古历、反向按农历年选,交界处必须自洽。 // 农历 -479 年正月落在公历 -480 年 12 月(春秋历),此前反向按农历年选了周历,整体差 1 天。 // The pre-Qin year-start crossing: the forward picks the default ancient calendar by civil // year while the reverse picks by lunar year. Lunar year -479 month 1 falls in civil December // -480 (Chunqiu calendar); the reverse used to pick Zhou and shift those 20 days by one day. func TestAncientYearStartCrossingRoundTrips(t *testing.T) { cases := []struct { ly, lm, ld int want string }{ {-479, 1, 1, "-0480-12-12"}, {-479, 1, 20, "-0480-12-31"}, {-479, 1, 22, "-0479-01-01"}, {-479, 1, 29, "-0479-01-08"}, } for _, tc := range cases { got, err := LunarToSolarByYMD(tc.ly, tc.lm, tc.ld, false) if err != nil { t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", tc.ly, tc.lm, tc.ld, err) } if s := formatAncientSolar(got.Solar()); s != tc.want { t.Fatalf("LunarToSolarByYMD(%d,%d,%d) = %s, want %s", tc.ly, tc.lm, tc.ld, s, tc.want) } } // 该农历日在正向可达的范围内没有对应公历日(春秋历说是 -479-01-01、周历说是 -480-12-31, // 而正向对这两个公历日分别给出周历 1/22 与春秋历 1/20),默认口径必须报错而不是给错日子。 // That lunar day has no civil date in the default frame (the forward maps -479-01-01 to Zhou // 1/22 and -480-12-31 to Chunqiu 1/20), so the default entry point must report an error. if _, err := LunarToSolarByYMD(-479, 1, 21, false); err == nil { t.Fatal("LunarToSolarByYMD(-479,1,21) 应报错:该农历日在默认历法口径下没有公历日") } // 交界窗口往返一致。 // The junction window round-trips. for day := time.Date(-480, 12, 1, 0, 0, 0, 0, getCst()); day.Before(time.Date(-479, 2, 1, 0, 0, 0, 0, getCst())); day = day.AddDate(0, 0, 1) { y, m, d := day.Year(), int(day.Month()), day.Day() lunar, err := SolarToLunarByYMD(y, m, d) if err != nil { continue } l := lunar.Lunar() back, err := LunarToSolarByYMD(l.LunarYear(), l.LunarMonth(), l.LunarDay(), l.IsLeap()) if err != nil { t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 err=%v", y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), err) } if s := back.Solar(); s.Year() != y || int(s.Month()) != m || s.Day() != d { t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 %s(岁首跨界往返不一致)", y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), formatAncientSolar(s)) } } } // 王莽改历的 23/24 年交界:同一套月序偏移会把"腊月三十"反解到腊月初一(标签碰撞), // 此时应改用另一套偏移下能正向回到该农历日的那一天,并把主答案换成它。 // At the Wang Mang 23/24 boundary the primary month offset resolves "12th month, day 30" onto the // 1st of that month (a label collision); the reverse must switch to the offset whose civil day // converts forward to the requested lunar date and make that day the primary answer. func TestWangMangBoundaryReverseIsSelfConsistent(t *testing.T) { cases := []struct { ly, lm, ld int wantSolar string selfConsist bool }{ {23, 12, 29, "0023-12-30", true}, {23, 12, 30, "0024-01-29", true}, {24, 1, 1, "0024-01-30", true}, // 23/12/31 在两套月序下都没有对应的公历日(该年有两个腊月,第二个腊月只有 30 天), // 保持既有行为返回偏移 1 的结果,只断言主答案不变成空值。 {23, 12, 31, "0024-01-01", false}, } for _, c := range cases { res, err := LunarToSolarByYMD(c.ly, c.lm, c.ld, false) if err != nil { t.Fatalf("LunarToSolarByYMD(%d,%d,%d) error: %v", c.ly, c.lm, c.ld, err) } if got := formatAncientSolar(res.Solar()); got != c.wantSolar { t.Errorf("lunar %d/%d/%d -> %s, want %s", c.ly, c.lm, c.ld, got, c.wantSolar) } if !c.selfConsist { continue } fwd, err := SolarToLunarByYMD(res.Solar().Year(), int(res.Solar().Month()), res.Solar().Day()) if err != nil { t.Fatalf("SolarToLunarByYMD(%s) error: %v", c.wantSolar, err) } l := fwd.Lunar() if l.LunarYear() != c.ly || l.LunarMonth() != c.lm || l.LunarDay() != c.ld || l.IsLeap() { t.Errorf("lunar %d/%d/%d -> %s converts forward to %d/%d/%d", c.ly, c.lm, c.ld, c.wantSolar, l.LunarYear(), l.LunarMonth(), l.LunarDay()) } if res.JDE() != fwd.JDE() { t.Errorf("lunar %d/%d/%d: JDE=%.1f, forward gives %.1f", c.ly, c.lm, c.ld, res.JDE(), fwd.JDE()) } for _, cand := range res.SolarCandidates() { candFwd, err := SolarToLunarByYMD(cand.Year(), int(cand.Month()), cand.Day()) if err != nil { t.Errorf("lunar %d/%d/%d candidate %s does not convert: %v", c.ly, c.lm, c.ld, formatAncientSolar(cand), err) continue } cl := candFwd.Lunar() if cl.LunarYear() != c.ly || cl.LunarMonth() != c.lm || cl.LunarDay() != c.ld || cl.IsLeap() { t.Errorf("lunar %d/%d/%d candidate %s converts to %d/%d/%d", c.ly, c.lm, c.ld, formatAncientSolar(cand), cl.LunarYear(), cl.LunarMonth(), cl.LunarDay()) } } } }