package schedule import ( "testing" "time" ) func stamp(v string) time.Time { t, err := time.Parse(time.RFC3339Nano, v) if err != nil { panic(err) } return t } func calendar(t *testing.T, expr, zone, start string) *Calendar { t.Helper() c, err := Compile(Rule{Kind: "CRON", Cron: expr, Timezone: zone, StartAt: stamp(start)}) if err != nil { t.Fatal(err) } return c } func TestCronCalendarUTCAndDST(t *testing.T) { for _, tc := range []struct { name, expr, zone, from string want []string }{ {"five_fields", "*/15 9-10 * * MON-FRI", "Asia/Shanghai", "2026-09-18T00:59:59Z", []string{"2026-09-18T01:00:00Z", "2026-09-18T01:15:00Z"}}, {"spring_missing", "30 2 * * *", "America/New_York", "2026-03-08T00:00:00Z", []string{"2026-03-09T06:30:00Z"}}, {"autumn_repeated", "30 1 * * *", "America/New_York", "2026-11-01T00:00:00Z", []string{"2026-11-01T05:30:00Z", "2026-11-01T06:30:00Z", "2026-11-02T06:30:00Z"}}, {"half_hour_spring", "45 2 * * *", "Australia/Lord_Howe", "2026-10-03T14:59:00Z", []string{"2026-10-03T15:45:00Z", "2026-10-04T15:45:00Z"}}, {"half_hour_autumn", "45 1 * * *", "Australia/Lord_Howe", "2026-04-04T13:00:00Z", []string{"2026-04-04T14:45:00Z", "2026-04-04T15:15:00Z"}}, {"skipped_date", "0 0 * * *", "Pacific/Apia", "2011-12-29T10:00:00Z", []string{"2011-12-29T10:00:00Z", "2011-12-30T10:00:00Z"}}, {"century_leap", "0 0 29 2 *", "UTC", "2096-03-01T00:00:00Z", []string{"2104-02-29T00:00:00Z", "2108-02-29T00:00:00Z"}}, {"day_or_weekday", "0 0 13 * MON", "UTC", "2026-09-12T00:00:00Z", []string{"2026-09-13T00:00:00Z", "2026-09-14T00:00:00Z"}}, } { t.Run(tc.name, func(t *testing.T) { c := calendar(t, tc.expr, tc.zone, tc.from) at := stamp(tc.from) for _, want := range tc.want { got, err := c.AtOrAfter(at) if err != nil || got == nil || !got.Equal(stamp(want)) { t.Fatalf("at %s got %v %v want %s", at, got, err, want) } at = got.Add(time.Microsecond) } }) } } func TestRuleBoundsAndFixedRate(t *testing.T) { start := stamp("2026-09-17T00:00:00.000001Z") end := start.Add(3 * time.Second) c, err := Compile(Rule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: start, EndAt: &end, IntervalMS: 1000}) if err != nil { t.Fatal(err) } for _, at := range []time.Time{start.Add(-time.Hour), start, start.Add(time.Nanosecond), start.Add(1500 * time.Millisecond), end} { got, err := c.AtOrAfter(at) if err != nil { t.Fatal(err) } if at.Equal(end) { if got != nil { t.Fatal("end is inclusive") } continue } if got == nil || got.Before(at) || !got.Before(end) || (got.UnixMicro()-start.UnixMicro())%1000000 != 0 { t.Fatal("fixed rate drift") } } // 规则副本不能修改已编译日历。 copy := c.Rule() *copy.EndAt = start if got, _ := c.AtOrAfter(start); got == nil { t.Fatal("rule alias") } c, err = Compile(Rule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: stamp("2000-01-01T00:00:00Z"), IntervalMS: 1000}) if err != nil { t.Fatal(err) } if got, err := c.AtOrAfter(stamp("9998-12-31T23:59:59.000001Z")); err != nil || got != nil { t.Fatal("calendar overflow") } } func TestRuleRejectsAmbiguityAndImpossibleDates(t *testing.T) { base := Rule{Kind: "CRON", Timezone: "UTC", StartAt: stamp("2026-01-01T00:00:00Z"), Cron: "* * * * *"} for _, expr := range []string{"@every 1s", "0 0 0 * * *", "TZ=UTC 0 0 * * *", "0 0 31 2 *", "60 * * * *", "0 0 * * 7", "*/0 * * * *"} { in := base in.Cron = expr if _, err := Compile(in); err == nil { t.Fatalf("accepted %q", expr) } } for _, zone := range []string{"", "Local", "../UTC", "Unknown/Zone"} { in := base in.Timezone = zone if _, err := Compile(in); err == nil { t.Fatalf("accepted zone %s", zone) } } in := base in.IntervalMS = 1000 if _, err := Compile(in); err == nil { t.Fatal("accepted mixed rule") } }