oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
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).
19func 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).
39func 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).
61func 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".
76func 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).
104func 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).
123func 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.
136func 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