package calendartest import ( "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // The two Sundays a year that break slot pages, with a 00:00–04:00 // window laid straight across the transition. Europe/Dublin 2026: DST // starts 29 March and ends 25 October, both at 01:00 UTC. // // As in clock_test.go, this does not assert correctness — there is no // correct answer for an hour that does not exist or happens twice, and // Go promises none. It asserts B17's four-part check holds of every // slot that IS offered, and then pins THE LIST, so a Go or tzdata // change that moves it fails here rather than double-booking somebody. // dstSunday computes one Dublin Sunday's 00:00–04:00 hourly slots, from // a clock at the Saturday before. func dstSunday(t *testing.T, y int, mo time.Month, d int) []calendar.Slot { t.Helper() day := time.Date(y, mo, d, 0, 0, 0, 0, zoneOf(t, dublin)) rule := calendar.SlotRule{DurationMin: 60, HorizonDays: 7, Zone: dublin, Windows: []calendar.Window{{Weekday: time.Sunday, StartMin: 0, EndMin: 4 * 60}}} return compute(t, rule, nil, day.AddDate(0, 0, -1), day, day.AddDate(0, 0, 1)) } // honest asserts B17 of every slot: nothing straddles the transition, // both ends round-trip through the stored form, and elapsed length is // exactly the duration. func honest(t *testing.T, slots []calendar.Slot, transitionUTC int64) { t.Helper() loc := zoneOf(t, dublin) for _, s := range slots { if s.StartUTC < transitionUTC && s.EndUTC > transitionUTC { t.Errorf("slot %v straddles the transition at %d", s.Local, transitionUTC) } if s.EndUTC-s.StartUTC != 3600 { t.Errorf("slot %v is %d elapsed seconds, want 3600", s.Local, s.EndUTC-s.StartUTC) } if s.Local.Unix() != s.StartUTC { t.Errorf("slot Local %v is not its own StartUTC %d", s.Local, s.StartUTC) } for _, u := range []int64{s.StartUTC, s.EndUTC} { back, err := calendar.ToUTC(calendar.FormatLocal(time.Unix(u, 0).In(loc), false), dublin, false) if err != nil || back != u { t.Errorf("slot %v: %d does not round-trip through its wall clock (got %d, %v)", s.Local, u, back, err) } } } } func pinned(t *testing.T, slots []calendar.Slot, want ...int64) { t.Helper() got := make([]int64, 0, len(slots)) for _, s := range slots { got = append(got, s.StartUTC) } if len(got) != len(want) { t.Fatalf("starts = %v, want %v", got, want) } for i := range got { if got[i] != want[i] { t.Fatalf("starts = %v, want %v", got, want) } } } // Spring forward: 01:00 GMT jumps to 02:00 IST at 1774746000. // // What Go does, observed: time.Date resolves a wall time inside the gap // with the offset in force BEFORE the transition, so "01:00" lands at // 01:00 UTC and reads back as 02:00. Hence: // // - 00:00 (m=0) exists, but its END is the gap's "01:00", which reads // back as 02:00 — wall minute 120, not 60 — so it is refused. // - 01:00 (m=60) is itself in the gap and reads back as 02:00: refused. // - 02:00 IST = 1774746000 and 03:00 IST = 1774749600 are ordinary // hours on both clocks: offered. func TestSpringForwardSundayOffersOnlyHonestHours(t *testing.T) { slots := dstSunday(t, 2026, time.March, 29) honest(t, slots, 1774746000) pinned(t, slots, 1774746000, 1774749600) } // Fall back: 02:00 IST returns to 01:00 GMT at 1792890000, so the wall // clock reads 01:xx twice. // // What Go does, observed: an ambiguous wall time resolves to the SECOND // occurrence (clock.go, TestDSTGapAndFold). Hence: // // - 00:00 IST (m=0, 1792882800) exists, and its end 1792886400 is the // FIRST 01:00 — but "01:00" round-trips to the second one, an hour // later, so the stored form would name a different instant: refused. // This is B17's "two-hour hour". // - 01:00 (m=60) resolves to the second 01:00, 01:00 GMT = 1792890000, // a plain hour to 02:00 GMT: offered. // - 02:00 GMT = 1792893600 and 03:00 GMT = 1792897200: offered. func TestFallBackSundayOffersOnlyHonestHours(t *testing.T) { slots := dstSunday(t, 2026, time.October, 25) honest(t, slots, 1792890000) pinned(t, slots, 1792890000, 1792893600, 1792897200) }