| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func TestLargeRepresentableWeeklyIntervalIsExact(t *testing.T) { |
| 9 | proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC) |
| 10 | r, _ := ParseRule("FREQ=WEEKLY;INTERVAL=400000;BYDAY=MO") |
| 11 | next, state := advance(proto, proto, r, time.UTC) |
| 12 | want := time.Date(9692, 10, 27, 9, 0, 0, 0, time.UTC) |
| 13 | if state != stepCandidate || !next.Equal(want) || next.Weekday() != time.Monday { |
| 14 | t.Fatalf("next=%v state=%v want=%v", next, state, want) |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | func TestHugeIntervalsRefuseWithoutOverflow(t *testing.T) { |
| 19 | proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC) |
| 20 | for _, freq := range []string{FreqDaily, FreqWeekly, FreqMonthly, FreqYearly} { |
| 21 | for _, interval := range []int{int(^uint(0) >> 1), int(^uint(0)>>1) / 2} { |
| 22 | next, state := advance(proto, proto, Rule{Freq: freq, Interval: interval}, time.UTC) |
| 23 | if state != stepIncomplete { |
| 24 | t.Errorf("%s interval%d next%v state%v", freq, interval, next, state) |
| 25 | } |
| 26 | } |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func TestHorizonAllowsItsLastDayAndRefusesTheNext(t *testing.T) { |
| 31 | proto := time.Date(9999, 12, 30, 9, 0, 0, 0, time.UTC) |
| 32 | r, _ := ParseRule("FREQ=DAILY") |
| 33 | next, state := advance(proto, proto, r, time.UTC) |
| 34 | if state != stepCandidate || next.Format(LayoutLocal) != "9999-12-31T09:00" { |
| 35 | t.Fatalf("last day=%v state=%v", next, state) |
| 36 | } |
| 37 | if _, state = advance(next, proto, r, time.UTC); state != stepIncomplete { |
| 38 | t.Fatal("accepted date beyond four-digit record horizon") |
| 39 | } |
| 40 | weekly, _ := ParseRule("FREQ=WEEKLY;BYDAY=SA") |
| 41 | if _, state = advance(next, next, weekly, time.UTC); state != stepIncomplete { |
| 42 | t.Fatal("accepted weekly day beyond horizon") |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func TestCountCompletionDoesNotRequireAnotherRepresentableStep(t *testing.T) { |
| 47 | from := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC).Unix() |
| 48 | to := time.Date(10000, 1, 2, 0, 0, 0, 0, time.UTC).Unix() |
| 49 | r, _ := ParseRule("FREQ=YEARLY;INTERVAL=99999999;COUNT=1") |
| 50 | got, complete := ExpandComplete("9999-12-31T09:00", "9999-12-31T10:00", "UTC", false, r, "", from, to) |
| 51 | if len(got) != 1 || !complete { |
| 52 | t.Fatalf("completed COUNT=%v complete=%v", got, complete) |
| 53 | } |
| 54 | r.Count = 0 |
| 55 | if _, complete = ExpandComplete("9999-12-31T09:00", "9999-12-31T10:00", "Pacific/Kiritimati", false, r, "", from-86400, to); complete { |
| 56 | t.Fatal("horizon is not proof of completion, including zones east of UTC") |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | func TestCalendarExhaustionDoesNotUseZoneNormalization(t *testing.T) { |
| 61 | proto := time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC) |
| 62 | r, _ := ParseRule("FREQ=MONTHLY;INTERVAL=12;BYMONTHDAY=31") |
| 63 | if next, state := advance(proto, proto, r, time.UTC); state != stepExhausted { |
| 64 | t.Fatalf("impossible February31=%v state=%v", next, state) |
| 65 | } |
| 66 | for _, ordinal := range []int{int(^uint(0) >> 1), -int(^uint(0) >> 1)} { |
| 67 | r := Rule{Freq: FreqMonthly, Interval: 1, Ordinal: ordinal, OrdinalDay: time.Monday} |
| 68 | if _, state := advance(proto, proto, r, time.UTC); state != stepExhausted { |
| 69 | t.Fatalf("impossible ordinal%d state%v", ordinal, state) |
| 70 | } |
| 71 | } |
| 72 | leap := time.Date(2000, 2, 29, 9, 0, 0, 0, time.UTC) |
| 73 | r, _ = ParseRule("FREQ=YEARLY;INTERVAL=100") |
| 74 | if next, state := advance(leap, leap, r, time.UTC); state != stepCandidate || next.Year() != 2400 || next.Day() != 29 { |
| 75 | t.Fatalf("century leap skip=%v state=%v", next, state) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestInvalidInputsCannotBecomeCompleteAtCountOrWindow(t *testing.T) { |
| 80 | from := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC).Unix() |
| 81 | for _, rule := range []Rule{{Freq: FreqDaily, Interval: 0}, {Freq: "HOURLY", Interval: 1}, {Freq: FreqWeekly, Interval: 1, ByDay: []time.Weekday{9}}, {Freq: FreqMonthly, Interval: 1, MonthDay: 32}} { |
| 82 | if _, complete := ExpandComplete("2026-09-07T09:00", "2026-09-07T10:00", "UTC", false, rule, "", from, from+86400); complete { |
| 83 | t.Errorf("invalid direct rule complete: %+v", rule) |
| 84 | } |
| 85 | } |
| 86 | r, _ := ParseRule("FREQ=DAILY;COUNT=1") |
| 87 | for _, end := range []string{"invalid", "2026-09-07T08:00"} { |
| 88 | if _, complete := ExpandComplete("2026-09-07T09:00", end, "UTC", false, r, "", from, from+86400); complete { |
| 89 | t.Errorf("invalid end complete: %s", end) |
| 90 | } |
| 91 | } |
| 92 | if _, complete := ExpandComplete("2026-09-07T09:00", "2026-09-07T10:00", "UTC", false, r, "", from+86400, from); complete { |
| 93 | t.Fatal("reversed window complete") |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestGapOccurrenceDoesNotTruncateLaterWindows(t *testing.T) { |
| 98 | r, _ := ParseRule("FREQ=DAILY") |
| 99 | from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC).Unix() |
| 100 | got, complete := ExpandComplete("2026-03-01T01:30", "2026-03-01T02:00", "Europe/Dublin", false, r, "", from, from+86400) |
| 101 | if len(got) != 1 || !complete { |
| 102 | t.Fatalf("April window after spring gap: got=%v complete=%v", got, complete) |
| 103 | } |
| 104 | if got[0].Local != "2026-04-01T01:30" || got[0].EndUTC-got[0].StartUTC != 1800 { |
| 105 | t.Fatalf("April occurrence=%+v", got[0]) |
| 106 | } |
| 107 | } |
| 108 | |