package scheduler import ( "context" "io" "log/slog" "reflect" "testing" "time" ) func TestParseTimes(t *testing.T) { cases := []struct { in string want []TimeOfDay wantErr bool }{ {"04:00", []TimeOfDay{{4, 0}}, false}, {" 18:30 , 06:00,06:00 ", []TimeOfDay{{6, 0}, {18, 30}}, false}, {"off", nil, false}, {"OFF", nil, false}, {"", nil, true}, {"25:00", nil, true}, {"04:60", nil, true}, {"daily", nil, true}, {"04:00,", nil, true}, } for _, tc := range cases { got, err := ParseTimes(tc.in) if (err != nil) != tc.wantErr { t.Errorf("ParseTimes(%q): err = %v, wantErr %v", tc.in, err, tc.wantErr) continue } if !reflect.DeepEqual(got, tc.want) { t.Errorf("ParseTimes(%q) = %v, want %v", tc.in, got, tc.want) } } } func TestNext(t *testing.T) { prague, err := time.LoadLocation("Europe/Prague") if err != nil { t.Fatal(err) } at := func(s string) time.Time { v, err := time.ParseInLocation("2006-01-02 15:04:05", s, prague) if err != nil { t.Fatal(err) } return v } four := []TimeOfDay{{4, 0}} cases := []struct { name string now time.Time times []TimeOfDay want time.Time }{ {"before today's time", at("2026-10-08 03:59:59"), four, at("2026-10-08 04:00:00")}, {"exactly at the time moves to tomorrow", at("2026-10-08 04:00:00"), four, at("2026-10-09 04:00:00")}, {"after today's time", at("2026-10-08 12:00:00"), four, at("2026-10-09 04:00:00")}, {"picks the next of several", at("2026-10-08 12:00:00"), []TimeOfDay{{6, 0}, {18, 0}}, at("2026-10-08 18:00:00")}, {"year rollover", at("2026-12-31 23:00:00"), four, at("2027-01-01 04:00:00")}, {"now given in UTC", at("2026-10-08 03:00:00").UTC(), four, at("2026-10-08 04:00:00")}, // Spring forward (2026-03-29 02:00 → 03:00): still 04:00 local, 23h after the previous run. {"spring forward", at("2026-03-28 04:00:00"), four, at("2026-03-29 04:00:00")}, // Fall back (2026-10-25 03:00 → 02:00): still 04:00 local, 25h after the previous run. {"fall back", at("2026-10-24 04:00:00"), four, at("2026-10-25 04:00:00")}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := Next(tc.now, tc.times, prague) if !got.Equal(tc.want) { t.Errorf("Next = %s, want %s", got, tc.want) } }) } if got := Next(at("2026-10-08 12:00:00"), nil, prague); !got.IsZero() { t.Errorf("Next with empty schedule = %s, want zero", got) } if d := at("2026-03-29 04:00:00").Sub(at("2026-03-28 04:00:00")); d != 23*time.Hour { t.Errorf("spring-forward gap = %s, want 23h", d) } } func TestRunStopsOnCancelAndEmptySchedule(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) called := false job := func(context.Context) { called = true } done := make(chan struct{}) go func() { Run(context.Background(), nil, time.UTC, logger, job); close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("Run with empty schedule did not return") } ctx, cancel := context.WithCancel(context.Background()) done = make(chan struct{}) far := time.Now().UTC().Add(12 * time.Hour) go func() { Run(ctx, []TimeOfDay{{far.Hour(), far.Minute()}}, time.UTC, logger, job) close(done) }() cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("Run did not return after cancel") } if called { t.Error("job must not run before its scheduled time") } }