| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | |
| 7 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 8 | ) |
| 9 | |
| 10 | // Six defects in the recurrence engine, found by review of the booking |
| 11 | // spec (its §4.1) and owned by it: a booking page decides FREE time from |
| 12 | // what this engine says is busy, so a dropped occurrence is a |
| 13 | // double-booking, not a cosmetic gap. |
| 14 | |
| 15 | // A daily 01:30 Dublin meeting whose 29 March instance falls in the |
| 16 | // spring gap resolves to 02:30 that day. It must NOT start at 02:30 for |
| 17 | // the rest of its life: advancement carries the ORIGINAL civil clock |
| 18 | // (booking spec §4.1, r7 f1). |
| 19 | func TestTheSpringGapDoesNotShiftLaterOccurrences(t *testing.T) { |
| 20 | got := expandLocal(t, "2026-03-28T01:30", "2026-03-28T03:00", "Europe/Dublin", |
| 21 | "FREQ=DAILY", "", "2026-03-28", "2026-04-01") |
| 22 | want := []string{ |
| 23 | "2026-03-28T01:30", |
| 24 | "2026-03-29T02:30", // the gap day ALONE resolves forward, as clock.go documents |
| 25 | "2026-03-30T01:30", |
| 26 | "2026-03-31T01:30", |
| 27 | } |
| 28 | assertKeys(t, got, want) |
| 29 | |
| 30 | // And in September the instant is 01:30 IST, not 02:30. |
| 31 | sept := expandLocal(t, "2026-03-28T01:30", "2026-03-28T03:00", "Europe/Dublin", |
| 32 | "FREQ=DAILY", "", "2026-09-10", "2026-09-11") |
| 33 | assertKeys(t, sept, []string{"2026-09-10T01:30"}) |
| 34 | } |
| 35 | |
| 36 | // A two-day event across the spring transition is 47 elapsed hours and |
| 37 | // TWO civil days. Dividing hours by 24 made it one, so every later |
| 38 | // occurrence ended a day early (r4 B2). |
| 39 | func TestAMultiDaySpanIsCountedInCivilDays(t *testing.T) { |
| 40 | r, err := calendar.ParseRule("FREQ=WEEKLY") |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | from, _ := calendar.ToUTC("2026-04-04", "Europe/Dublin", true) |
| 45 | to, _ := calendar.ToUTC("2026-04-07", "Europe/Dublin", true) |
| 46 | occs := calendar.Expand("2026-03-28T09:00", "2026-03-30T09:00", "Europe/Dublin", false, r, "", from, to) |
| 47 | if len(occs) != 1 { |
| 48 | t.Fatalf("got %d occurrences, want 1: %+v", len(occs), occs) |
| 49 | } |
| 50 | dublin, _ := time.LoadLocation("Europe/Dublin") |
| 51 | end := time.Unix(occs[0].EndUTC, 0).In(dublin) |
| 52 | if end.Format("2006-01-02T15:04") != "2026-04-06T09:00" { |
| 53 | t.Errorf("the second occurrence ends %s, want 2026-04-06T09:00 (two civil days later)", end.Format("2006-01-02T15:04")) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // Fortnightly on Monday and Wednesday from a Wednesday: the next active |
| 58 | // week has BOTH days. The old search looked only seven days ahead, found |
| 59 | // the intervening week ineligible, and jumped straight to the Wednesday |
| 60 | // (r8 f1). |
| 61 | func TestAFortnightlyRuleKeepsEveryWeekdayOfAnEligibleWeek(t *testing.T) { |
| 62 | got := expandLocal(t, "2026-09-02T09:00", "2026-09-02T09:30", "UTC", |
| 63 | "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE", "", "2026-09-01", "2026-10-01") |
| 64 | want := []string{ |
| 65 | "2026-09-02T09:00", |
| 66 | "2026-09-14T09:00", "2026-09-16T09:00", |
| 67 | "2026-09-28T09:00", "2026-09-30T09:00", |
| 68 | } |
| 69 | assertKeys(t, got, want) |
| 70 | } |
| 71 | |
| 72 | // A rule the parser can read but the expander cannot faithfully run is |
| 73 | // FOREIGN, not a silent misreading (r8 f2). Every Monday of the month is |
| 74 | // a real rule; this subset does not carry it, and must say so rather than |
| 75 | // expand it as "the 7th of every month". |
| 76 | func TestCombinationsTheExpanderCannotRunAreForeign(t *testing.T) { |
| 77 | for _, rule := range []string{ |
| 78 | "FREQ=MONTHLY;BYDAY=MO", |
| 79 | "FREQ=DAILY;BYDAY=MO", |
| 80 | "FREQ=YEARLY;BYMONTHDAY=3", |
| 81 | "FREQ=WEEKLY;BYMONTHDAY=3", |
| 82 | "FREQ=DAILY;BYMONTHDAY=3", |
| 83 | } { |
| 84 | _, err := calendar.ParseRule(rule) |
| 85 | if err == nil || !calendar.IsForeignRule(err) { |
| 86 | t.Errorf("ParseRule(%q) = %v, want a foreign-rule signal", rule, err) |
| 87 | } |
| 88 | } |
| 89 | // And the shapes the subset DOES carry still parse. |
| 90 | for _, rule := range []string{ |
| 91 | "FREQ=WEEKLY;BYDAY=MO,WE", "FREQ=MONTHLY;BYMONTHDAY=15", "FREQ=MONTHLY;BYDAY=2FR", |
| 92 | "FREQ=MONTHLY", "FREQ=DAILY;INTERVAL=3", "FREQ=YEARLY", |
| 93 | } { |
| 94 | if _, err := calendar.ParseRule(rule); err != nil { |
| 95 | t.Errorf("ParseRule(%q) refused a supported rule: %v", rule, err) |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // Expand says when it did not finish. A forever-daily rule from 1700 |
| 101 | // runs out of steps before 2026 and contributes nothing to a window it |
| 102 | // should fill; the tolerant reader keeps rendering, the flag is for |
| 103 | // anyone deciding free time (r4 B5). |
| 104 | func TestExpandReportsWhenItRanOutOfSteps(t *testing.T) { |
| 105 | r, _ := calendar.ParseRule("FREQ=DAILY") |
| 106 | from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix() |
| 107 | to := time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC).Unix() |
| 108 | _, complete := calendar.ExpandComplete("1700-01-01T09:00", "1700-01-01T09:30", "UTC", false, r, "", from, to) |
| 109 | if complete { |
| 110 | t.Error("a 1700 daily rule expanded to 2026 reported complete") |
| 111 | } |
| 112 | _, complete = calendar.ExpandComplete("2026-01-01T09:00", "2026-01-01T09:30", "UTC", false, r, "", from, to) |
| 113 | if !complete { |
| 114 | t.Error("an ordinary daily rule reported incomplete") |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // WKST decides which week a Sunday and a Monday share. Fortnightly on |
| 119 | // Sunday and Monday from a Sunday: under the RFC's Monday default, that |
| 120 | // Sunday's Monday is in the NEXT week, so the next eligible pair is 20 |
| 121 | // and 21 September, and 7 September is not an occurrence (code review |
| 122 | // finding 2). |
| 123 | func TestWKSTDecidesTheWeekAnIntervalCounts(t *testing.T) { |
| 124 | got := expandLocal(t, "2026-09-06T09:00", "2026-09-06T09:30", "UTC", |
| 125 | "FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,MO;WKST=MO", "", "2026-09-01", "2026-09-30") |
| 126 | assertKeys(t, got, []string{"2026-09-06T09:00", "2026-09-14T09:00", "2026-09-20T09:00", "2026-09-28T09:00"}) |
| 127 | // The same rule with Sunday weeks keeps Sunday and Monday together. |
| 128 | got = expandLocal(t, "2026-09-06T09:00", "2026-09-06T09:30", "UTC", |
| 129 | "FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,MO;WKST=SU", "", "2026-09-01", "2026-09-30") |
| 130 | assertKeys(t, got, []string{"2026-09-06T09:00", "2026-09-07T09:00", "2026-09-20T09:00", "2026-09-21T09:00"}) |
| 131 | } |
| 132 | |
| 133 | // The output cap is not completion: a window that needs more than the |
| 134 | // cap is reported incomplete, so a strict reader does not take a |
| 135 | // truncated month as the whole of it. |
| 136 | func TestTheOutputCapIsNotCompletion(t *testing.T) { |
| 137 | r, _ := calendar.ParseRule("FREQ=DAILY") |
| 138 | from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Unix() |
| 139 | to := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC).Unix() |
| 140 | _, complete := calendar.ExpandComplete("2026-01-01T09:00", "2026-01-01T09:30", "UTC", false, r, "", from, to) |
| 141 | if complete { |
| 142 | t.Error("a window past the output cap reported complete") |
| 143 | } |
| 144 | } |
| 145 | |