| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | |
| 7 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 8 | ) |
| 9 | |
| 10 | // The two Sundays a year that break slot pages, with a 00:00–04:00 |
| 11 | // window laid straight across the transition. Europe/Dublin 2026: DST |
| 12 | // starts 29 March and ends 25 October, both at 01:00 UTC. |
| 13 | // |
| 14 | // As in clock_test.go, this does not assert correctness — there is no |
| 15 | // correct answer for an hour that does not exist or happens twice, and |
| 16 | // Go promises none. It asserts B17's four-part check holds of every |
| 17 | // slot that IS offered, and then pins THE LIST, so a Go or tzdata |
| 18 | // change that moves it fails here rather than double-booking somebody. |
| 19 | |
| 20 | // dstSunday computes one Dublin Sunday's 00:00–04:00 hourly slots, from |
| 21 | // a clock at the Saturday before. |
| 22 | func dstSunday(t *testing.T, y int, mo time.Month, d int) []calendar.Slot { |
| 23 | t.Helper() |
| 24 | day := time.Date(y, mo, d, 0, 0, 0, 0, zoneOf(t, dublin)) |
| 25 | rule := calendar.SlotRule{DurationMin: 60, HorizonDays: 7, Zone: dublin, |
| 26 | Windows: []calendar.Window{{Weekday: time.Sunday, StartMin: 0, EndMin: 4 * 60}}} |
| 27 | return compute(t, rule, nil, day.AddDate(0, 0, -1), day, day.AddDate(0, 0, 1)) |
| 28 | } |
| 29 | |
| 30 | // honest asserts B17 of every slot: nothing straddles the transition, |
| 31 | // both ends round-trip through the stored form, and elapsed length is |
| 32 | // exactly the duration. |
| 33 | func honest(t *testing.T, slots []calendar.Slot, transitionUTC int64) { |
| 34 | t.Helper() |
| 35 | loc := zoneOf(t, dublin) |
| 36 | for _, s := range slots { |
| 37 | if s.StartUTC < transitionUTC && s.EndUTC > transitionUTC { |
| 38 | t.Errorf("slot %v straddles the transition at %d", s.Local, transitionUTC) |
| 39 | } |
| 40 | if s.EndUTC-s.StartUTC != 3600 { |
| 41 | t.Errorf("slot %v is %d elapsed seconds, want 3600", s.Local, s.EndUTC-s.StartUTC) |
| 42 | } |
| 43 | if s.Local.Unix() != s.StartUTC { |
| 44 | t.Errorf("slot Local %v is not its own StartUTC %d", s.Local, s.StartUTC) |
| 45 | } |
| 46 | for _, u := range []int64{s.StartUTC, s.EndUTC} { |
| 47 | back, err := calendar.ToUTC(calendar.FormatLocal(time.Unix(u, 0).In(loc), false), dublin, false) |
| 48 | if err != nil || back != u { |
| 49 | t.Errorf("slot %v: %d does not round-trip through its wall clock (got %d, %v)", s.Local, u, back, err) |
| 50 | } |
| 51 | } |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func pinned(t *testing.T, slots []calendar.Slot, want ...int64) { |
| 56 | t.Helper() |
| 57 | got := make([]int64, 0, len(slots)) |
| 58 | for _, s := range slots { |
| 59 | got = append(got, s.StartUTC) |
| 60 | } |
| 61 | if len(got) != len(want) { |
| 62 | t.Fatalf("starts = %v, want %v", got, want) |
| 63 | } |
| 64 | for i := range got { |
| 65 | if got[i] != want[i] { |
| 66 | t.Fatalf("starts = %v, want %v", got, want) |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // Spring forward: 01:00 GMT jumps to 02:00 IST at 1774746000. |
| 72 | // |
| 73 | // What Go does, observed: time.Date resolves a wall time inside the gap |
| 74 | // with the offset in force BEFORE the transition, so "01:00" lands at |
| 75 | // 01:00 UTC and reads back as 02:00. Hence: |
| 76 | // |
| 77 | // - 00:00 (m=0) exists, but its END is the gap's "01:00", which reads |
| 78 | // back as 02:00 — wall minute 120, not 60 — so it is refused. |
| 79 | // - 01:00 (m=60) is itself in the gap and reads back as 02:00: refused. |
| 80 | // - 02:00 IST = 1774746000 and 03:00 IST = 1774749600 are ordinary |
| 81 | // hours on both clocks: offered. |
| 82 | func TestSpringForwardSundayOffersOnlyHonestHours(t *testing.T) { |
| 83 | slots := dstSunday(t, 2026, time.March, 29) |
| 84 | honest(t, slots, 1774746000) |
| 85 | pinned(t, slots, 1774746000, 1774749600) |
| 86 | } |
| 87 | |
| 88 | // Fall back: 02:00 IST returns to 01:00 GMT at 1792890000, so the wall |
| 89 | // clock reads 01:xx twice. |
| 90 | // |
| 91 | // What Go does, observed: an ambiguous wall time resolves to the SECOND |
| 92 | // occurrence (clock.go, TestDSTGapAndFold). Hence: |
| 93 | // |
| 94 | // - 00:00 IST (m=0, 1792882800) exists, and its end 1792886400 is the |
| 95 | // FIRST 01:00 — but "01:00" round-trips to the second one, an hour |
| 96 | // later, so the stored form would name a different instant: refused. |
| 97 | // This is B17's "two-hour hour". |
| 98 | // - 01:00 (m=60) resolves to the second 01:00, 01:00 GMT = 1792890000, |
| 99 | // a plain hour to 02:00 GMT: offered. |
| 100 | // - 02:00 GMT = 1792893600 and 03:00 GMT = 1792897200: offered. |
| 101 | func TestFallBackSundayOffersOnlyHonestHours(t *testing.T) { |
| 102 | slots := dstSunday(t, 2026, time.October, 25) |
| 103 | honest(t, slots, 1792890000) |
| 104 | pinned(t, slots, 1792890000, 1792893600, 1792897200) |
| 105 | } |
| 106 | |