2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
294 lines
11 KiB
Go
294 lines
11 KiB
Go
package calendar
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"b612.me/astro/basic"
|
|
)
|
|
|
|
// L2 契约:儒略历独有闰日(1582 年前百年非 400 闰年的 2 月 29 日)的 Solar 取后继日标签,
|
|
// JDE 取精确儒略日,JulianOnly 为真,农历身份字段描述这一天本身。
|
|
var julianOnlyYears = []int{
|
|
100, 200, 300, 500, 600, 700, 900, 1000, 1100, 1300, 1400, 1500,
|
|
-700, -600, -500, -300, -200, -100,
|
|
}
|
|
|
|
// lunarIdentity 是农历记录的身份字段,用于比较正向与反向是否描述同一天。
|
|
type lunarIdentity struct {
|
|
year, month, day int
|
|
leap bool
|
|
desc string
|
|
ganzhiDay string
|
|
ganzhiMonth string
|
|
calendars int
|
|
}
|
|
|
|
func identityOf(t Time) lunarIdentity {
|
|
l := t.Lunar()
|
|
return lunarIdentity{
|
|
year: l.LunarYear(),
|
|
month: l.LunarMonth(),
|
|
day: l.LunarDay(),
|
|
leap: l.IsLeap(),
|
|
desc: l.MonthDay(),
|
|
ganzhiDay: l.GanZhiDay(),
|
|
ganzhiMonth: l.GanZhiMonth(),
|
|
calendars: len(t.LunarInfo()),
|
|
}
|
|
}
|
|
|
|
func TestJulianOnlyLeapDayForwardReverseAgree(t *testing.T) {
|
|
for _, year := range julianOnlyYears {
|
|
phantom := basic.JDECalc(year, 2, 29)
|
|
if phantom != phantom { // NaN 保护:这些年份必须被历法层承认
|
|
t.Fatalf("year %d: basic.JDECalc(%d,2,29) is NaN", year, year)
|
|
}
|
|
label := basic.JDE2DateByZone(phantom, time.FixedZone("CST", 8*3600), true)
|
|
fwd, err := SolarToLunarByYMD(year, 2, 29)
|
|
if err != nil {
|
|
t.Fatalf("year %d: SolarToLunarByYMD(%d,2,29) error: %v", year, year, err)
|
|
}
|
|
if !fwd.JulianOnly() || fwd.JDE() != phantom {
|
|
t.Errorf("year %d: forward JulianOnly=%v JDE=%.1f, want true %.1f",
|
|
year, fwd.JulianOnly(), fwd.JDE(), phantom)
|
|
}
|
|
if got := fwd.Solar().Format("2006-01-02"); got != label.Format("2006-01-02") {
|
|
t.Errorf("year %d: forward Solar=%s, want canonical label %s", year, got, label.Format("2006-01-02"))
|
|
}
|
|
want := identityOf(fwd)
|
|
|
|
rev, err := LunarToSolarByYMD(want.year, want.month, want.day, want.leap)
|
|
if err != nil {
|
|
t.Fatalf("year %d: LunarToSolarByYMD(%d,%d,%d,%v) error: %v",
|
|
year, want.year, want.month, want.day, want.leap, err)
|
|
}
|
|
if !rev.JulianOnly() || rev.JDE() != phantom {
|
|
t.Errorf("year %d: reverse JulianOnly=%v JDE=%.1f, want true %.1f",
|
|
year, rev.JulianOnly(), rev.JDE(), phantom)
|
|
}
|
|
if got := rev.Solar().Format("2006-01-02"); got != label.Format("2006-01-02") {
|
|
t.Errorf("year %d: reverse Solar=%s, want canonical label %s", year, got, label.Format("2006-01-02"))
|
|
}
|
|
if got := identityOf(rev); got != want {
|
|
t.Errorf("year %d: reverse identity %+v, want %+v (requested lunar %d/%d/%d leap=%v)",
|
|
year, got, want, want.year, want.month, want.day, want.leap)
|
|
}
|
|
info := rev.LunarInfo()[0]
|
|
if !info.JulianOnly || info.JDE != phantom {
|
|
t.Errorf("year %d: LunarInfo JulianOnly=%v JDE=%.1f, want true %.1f", year, info.JulianOnly, info.JDE, phantom)
|
|
}
|
|
// 日干支必须取自闰日自己的儒略日,而不是标签日 Solar()。
|
|
// The sexagenary day name must come from the leap day's own Julian day, not from the label day.
|
|
if got, labelGanzhi := rev.Lunar().GanZhiDay(), GanZhiOfDay(rev.Solar()); got != ganZhiOfJDE(phantom) {
|
|
t.Errorf("year %d: GanZhiDay=%s, want %s (label %s gives %s)",
|
|
year, got, ganZhiOfJDE(phantom), rev.Solar().Format("2006-01-02"), labelGanzhi)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 儒略历闰日两侧仍是普通日子:往返正常且农历日不与闰日重复。
|
|
func TestJulianOnlyLeapDayNeighboursStayOrdinary(t *testing.T) {
|
|
for _, year := range julianOnlyYears {
|
|
phantom := basic.JDECalc(year, 2, 29)
|
|
fwd, err := SolarToLunarByYMD(year, 2, 29)
|
|
if err != nil {
|
|
t.Fatalf("year %d: SolarToLunarByYMD(%d,2,29) error: %v", year, year, err)
|
|
}
|
|
leapLunar := identityOf(fwd)
|
|
for _, c := range []struct {
|
|
month, day int
|
|
wantJDE float64
|
|
}{
|
|
{2, 28, phantom - 1},
|
|
{3, 1, phantom + 1},
|
|
} {
|
|
near, err := SolarToLunarByYMD(year, c.month, c.day)
|
|
if err != nil {
|
|
t.Fatalf("year %d: SolarToLunarByYMD(%d,%d,%d) error: %v", year, year, c.month, c.day, err)
|
|
}
|
|
nearID := identityOf(near)
|
|
if nearID == leapLunar {
|
|
t.Errorf("year %d: %d-%02d shares the leap day's lunar date %d/%d/%d leap=%v",
|
|
year, year, c.day, nearID.year, nearID.month, nearID.day, nearID.leap)
|
|
}
|
|
rev, err := LunarToSolarByYMD(nearID.year, nearID.month, nearID.day, nearID.leap)
|
|
if err != nil {
|
|
t.Fatalf("year %d: reverse of %d-%02d lunar %d/%d/%d error: %v",
|
|
year, year, c.day, nearID.year, nearID.month, nearID.day, err)
|
|
}
|
|
if rev.JulianOnly() {
|
|
t.Errorf("year %d: %d-%02d flagged JulianOnly", year, year, c.day)
|
|
}
|
|
if rev.JDE() != c.wantJDE {
|
|
t.Errorf("year %d: %d-%02d JDE=%.1f, want %.1f", year, year, c.day, rev.JDE(), c.wantJDE)
|
|
}
|
|
if got := identityOf(rev); got != nearID {
|
|
t.Errorf("year %d: %d-%02d reverse identity %+v, want %+v", year, year, c.day, got, nearID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// julianOnlyCivilDay 的分类必须只覆盖"仅儒略历存在"的闰日。
|
|
func TestJulianOnlyCivilDayClassification(t *testing.T) {
|
|
for _, year := range append(append([]int{}, julianOnlyYears...), 400, 800, 1200, 1600, 2000) {
|
|
want := false
|
|
for _, v := range julianOnlyYears {
|
|
if v == year {
|
|
want = true
|
|
break
|
|
}
|
|
}
|
|
if got := julianOnlyCivilDay(year, 2, 29); got != want {
|
|
t.Errorf("julianOnlyCivilDay(%d,2,29)=%v, want %v", year, got, want)
|
|
}
|
|
}
|
|
// 不存在的日期与普通闰日都不属于这一类。
|
|
for _, c := range []struct{ y, m, d int }{
|
|
{1900, 2, 29}, {1582, 2, 29}, {2024, 2, 29}, {2023, 2, 29}, {100, 2, 28}, {100, 3, 1},
|
|
} {
|
|
if julianOnlyCivilDay(c.y, c.m, c.d) {
|
|
t.Errorf("julianOnlyCivilDay(%d,%d,%d)=true, want false", c.y, c.m, c.d)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 普通日期不受 L2 影响:JulianOnly 为假、JDE 与 Solar 一致、身份与正向一致。
|
|
func TestOrdinaryDatesCarryNoJulianOnlyFlag(t *testing.T) {
|
|
cases := []struct{ y, m, d int }{
|
|
{2025, 1, 1}, {2025, 6, 15}, {1912, 2, 12}, {1900, 5, 5}, {1582, 10, 15},
|
|
{1000, 7, 7}, {700, 3, 2}, {500, 3, 1}, {1, 1, 1}, {0, 6, 1}, {-100, 3, 2}, {-500, 2, 28},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(time.Date(c.y, time.Month(c.m), c.d, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), func(t *testing.T) {
|
|
fwd, err := SolarToLunarByYMD(c.y, c.m, c.d)
|
|
if err != nil {
|
|
t.Fatalf("SolarToLunarByYMD(%d,%d,%d) error: %v", c.y, c.m, c.d, err)
|
|
}
|
|
id := identityOf(fwd)
|
|
rev, err := LunarToSolarByYMD(id.year, id.month, id.day, id.leap)
|
|
if err != nil {
|
|
t.Fatalf("reverse of %d/%d/%d leap=%v error: %v", id.year, id.month, id.day, id.leap, err)
|
|
}
|
|
if rev.JulianOnly() {
|
|
t.Errorf("%d-%02d-%02d flagged JulianOnly", c.y, c.m, c.d)
|
|
}
|
|
if want := Date2JDE(rev.Solar()); rev.JDE() != want {
|
|
t.Errorf("%d-%02d-%02d JDE=%.1f, want %.1f (Solar 推出值)", c.y, c.m, c.d, rev.JDE(), want)
|
|
}
|
|
if got := identityOf(rev); got != id {
|
|
t.Errorf("%d-%02d-%02d reverse identity %+v, want %+v", c.y, c.m, c.d, got, id)
|
|
}
|
|
info := rev.LunarInfo()
|
|
for _, v := range info {
|
|
if v.JulianOnly {
|
|
t.Errorf("%d-%02d-%02d LunarInfo flagged JulianOnly", c.y, c.m, c.d)
|
|
}
|
|
if v.JDE != rev.JDE() {
|
|
t.Errorf("%d-%02d-%02d LunarInfo JDE=%.1f, want %.1f", c.y, c.m, c.d, v.JDE, rev.JDE())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 字符串入口 LunarToSolar 曾把儒略历独有闰日整体偏一天(先解析成 time.Time 再回代);
|
|
// 现在按"结果紧邻闰日且闰日记录正是描述所指"复核。用例含武则天年号写法。
|
|
func TestLunarToSolarTextHandlesJulianOnlyLeapDays(t *testing.T) {
|
|
cases := []struct {
|
|
desc string
|
|
year int
|
|
month int
|
|
day int
|
|
leap bool
|
|
}{
|
|
{"100年二月初二", 100, 2, 2, false},
|
|
{"200年正月廿七", 200, 1, 27, false},
|
|
{"300年正月廿三", 300, 1, 23, false},
|
|
{"500年正月十五", 500, 1, 15, false},
|
|
{"600年闰正月初十", 600, 1, 10, true},
|
|
{"700年四月初五", 700, 4, 5, false},
|
|
{"900年正月廿六", 900, 1, 26, false},
|
|
{"1000年正月廿二", 1000, 1, 22, false},
|
|
{"1100年正月十八", 1100, 1, 18, false},
|
|
{"1300年二月初九", 1300, 2, 9, false},
|
|
{"1400年二月初五", 1400, 2, 5, false},
|
|
{"1500年二月初一", 1500, 2, 1, false},
|
|
{"前701年二月初八", -700, 2, 8, false},
|
|
{"前601年三月初三", -600, 3, 3, false},
|
|
{"前501年三月廿八", -500, 3, 28, false},
|
|
{"前301年三月二十", -300, 3, 20, false},
|
|
{"前201年正月十五", -200, 1, 15, false},
|
|
{"前101年正月十一", -100, 1, 11, false},
|
|
{"圣历三年二月初五", 700, 4, 5, false},
|
|
{"久视元年二月初五", 700, 4, 5, false},
|
|
{"永元十二年二月初二", 100, 2, 2, false},
|
|
}
|
|
for _, c := range cases {
|
|
phantom := basic.JDECalc(c.year, 2, 29)
|
|
label := basic.JDE2DateByZone(phantom, time.FixedZone("CST", 8*3600), true)
|
|
results, err := LunarToSolar(c.desc)
|
|
if err != nil {
|
|
t.Fatalf("%s: LunarToSolar error: %v", c.desc, err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Errorf("%s: got %d results, want 1", c.desc, len(results))
|
|
continue
|
|
}
|
|
got := results[0]
|
|
if !got.JulianOnly() || got.JDE() != phantom {
|
|
t.Errorf("%s: JulianOnly=%v JDE=%.1f, want true %.1f", c.desc, got.JulianOnly(), got.JDE(), phantom)
|
|
}
|
|
if solar := got.Solar().Format("2006-01-02"); solar != label.Format("2006-01-02") {
|
|
t.Errorf("%s: Solar=%s, want canonical label %s", c.desc, solar, label.Format("2006-01-02"))
|
|
}
|
|
lunar := got.Lunar()
|
|
if lunar.LunarYear() != c.year || lunar.LunarMonth() != c.month || lunar.LunarDay() != c.day || lunar.IsLeap() != c.leap {
|
|
t.Errorf("%s: lunar %d/%d/%d leap=%v, want %d/%d/%d leap=%v", c.desc,
|
|
lunar.LunarYear(), lunar.LunarMonth(), lunar.LunarDay(), lunar.IsLeap(), c.year, c.month, c.day, c.leap)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 闰日前后的描述必须仍是普通日期。
|
|
func TestLunarToSolarTextKeepsLeapDayNeighboursOrdinary(t *testing.T) {
|
|
for _, year := range julianOnlyYears {
|
|
phantom := basic.JDECalc(year, 2, 29)
|
|
for _, offset := range []int{-1, 1} {
|
|
// 取闰日前后各一天,用它们自己的农历日构造描述。
|
|
neighbour := basic.JDE2DateByZone(phantom+float64(offset), time.FixedZone("CST", 8*3600), true)
|
|
fwd, err := SolarToLunarByYMD(neighbour.Year(), int(neighbour.Month()), neighbour.Day())
|
|
if err != nil {
|
|
t.Fatalf("year %d: SolarToLunarByYMD(%s) error: %v", year, neighbour.Format("2006-01-02"), err)
|
|
}
|
|
id := identityOf(fwd)
|
|
prefix := ""
|
|
if id.year < 0 {
|
|
prefix = "前" + number2Chinese(1-id.year, true)
|
|
} else {
|
|
prefix = number2Chinese(id.year, true)
|
|
}
|
|
desc := prefix + "年" + formatLunarDateString(id.month, id.day, id.leap, 0)
|
|
results, err := LunarToSolar(desc)
|
|
if err != nil {
|
|
t.Fatalf("%s: LunarToSolar error: %v", desc, err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Errorf("%s: got %d results, want 1", desc, len(results))
|
|
continue
|
|
}
|
|
got := results[0]
|
|
if got.JulianOnly() {
|
|
t.Errorf("%s: unexpectedly flagged JulianOnly", desc)
|
|
}
|
|
if want := phantom + float64(offset); got.JDE() != want {
|
|
t.Errorf("%s: JDE=%.1f, want %.1f", desc, got.JDE(), want)
|
|
}
|
|
if got := identityOf(results[0]); got != id {
|
|
t.Errorf("%s: identity %+v, want %+v", desc, got, id)
|
|
}
|
|
}
|
|
}
|
|
}
|