diff --git a/server/backend/internal/scheduler/scheduler.go b/server/backend/internal/scheduler/scheduler.go index ab052f3..dcf6efe 100644 --- a/server/backend/internal/scheduler/scheduler.go +++ b/server/backend/internal/scheduler/scheduler.go @@ -113,6 +113,35 @@ func desiredState(onTime, offTime, now string) string { return "off" } +// resolveDesiredState ermittelt den Soll-Zustand eines Screens unter Berücksichtigung +// aller Prioritätsstufen: +// 1. per-Screen override_on_until (höchste Priorität) +// 2. globaler Override +// 3. Wochenende (Sa/So) +// 4. normaler Zeitplan +// +// Gibt ("", false) zurück wenn keine Automatisierung aktiv ist. +func resolveDesiredState(sc store.ScreenSchedule, globalOverride *store.GlobalOverride, now time.Time) (desired string, shouldControl bool) { + // 1. Per-Screen-Override: überschreibt alles + if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) { + return "on", true + } + // 2. Globaler Override + if globalOverride != nil && now.Before(globalOverride.Until) { + return globalOverride.Type, true + } + // 3. Wochenende: Zeitpläne werden ignoriert, Monitore bleiben aus + wd := now.Weekday() + if wd == time.Saturday || wd == time.Sunday { + return "off", true + } + // 4. Normaler Zeitplan + if !sc.ScheduleEnabled || (sc.PowerOnTime == "" && sc.PowerOffTime == "") { + return "", false + } + return desiredState(sc.PowerOnTime, sc.PowerOffTime, now.Format("15:04")), true +} + // check prüft alle aktiven Zeitpläne und sendet ggf. Befehle. func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) { // Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin). diff --git a/server/backend/internal/scheduler/scheduler_test.go b/server/backend/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..ffb6066 --- /dev/null +++ b/server/backend/internal/scheduler/scheduler_test.go @@ -0,0 +1,129 @@ +package scheduler + +import ( + "testing" + "time" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +func ptrTime(t time.Time) *time.Time { return &t } + +func TestResolveDesiredState(t *testing.T) { + // Donnerstag 10:00 UTC + thu := time.Date(2026, 3, 26, 10, 0, 0, 0, time.UTC) + // Samstag 10:00 UTC + sat := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + sc store.ScreenSchedule + override *store.GlobalOverride + now time.Time + wantDesired string + wantControl bool + }{ + { + name: "per-screen override aktiv → on", + sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(thu.Add(time.Hour))}, + now: thu, + wantDesired: "on", wantControl: true, + }, + { + name: "per-screen override abgelaufen → fällt durch zu Zeitplan", + sc: store.ScreenSchedule{ + ScheduleEnabled: true, + PowerOnTime: "09:00", PowerOffTime: "17:00", + OverrideOnUntil: ptrTime(thu.Add(-time.Hour)), // abgelaufen + }, + now: thu, + wantDesired: "on", wantControl: true, // Zeitplan: 09:00–17:00, jetzt 10:00 + }, + { + name: "per-screen override schlägt globalen Override off", + sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(thu.Add(time.Hour))}, + override: &store.GlobalOverride{ + Type: "off", + Until: thu.Add(time.Hour), + }, + now: thu, + wantDesired: "on", wantControl: true, + }, + { + name: "globaler Override off aktiv → off", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + override: &store.GlobalOverride{ + Type: "off", + Until: thu.Add(time.Hour), + }, + now: thu, + wantDesired: "off", wantControl: true, + }, + { + name: "globaler Override on aktiv → on", + sc: store.ScreenSchedule{}, + override: &store.GlobalOverride{ + Type: "on", + Until: thu.Add(time.Hour), + }, + now: thu, + wantDesired: "on", wantControl: true, + }, + { + name: "globaler Override abgelaufen → fällt durch", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + override: &store.GlobalOverride{ + Type: "off", + Until: thu.Add(-time.Hour), // abgelaufen + }, + now: thu, + wantDesired: "on", wantControl: true, + }, + { + name: "Wochenende → off", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + now: sat, + wantDesired: "off", wantControl: true, + }, + { + name: "Wochenende + per-screen override → on", + sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(sat.Add(time.Hour))}, + now: sat, + wantDesired: "on", wantControl: true, + }, + { + name: "normaler Zeitplan: innerhalb → on", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + now: thu, // 10:00 + wantDesired: "on", wantControl: true, + }, + { + name: "normaler Zeitplan: außerhalb → off", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + now: time.Date(2026, 3, 26, 8, 0, 0, 0, time.UTC), // 08:00 + wantDesired: "off", wantControl: true, + }, + { + name: "kein Zeitplan, kein Override → keine Steuerung", + sc: store.ScreenSchedule{}, + now: thu, + wantDesired: "", wantControl: false, + }, + { + name: "Zeitplan deaktiviert → keine Steuerung", + sc: store.ScreenSchedule{ScheduleEnabled: false, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + now: thu, + wantDesired: "", wantControl: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotDesired, gotControl := resolveDesiredState(tt.sc, tt.override, tt.now) + if gotDesired != tt.wantDesired || gotControl != tt.wantControl { + t.Errorf("resolveDesiredState() = (%q, %v), want (%q, %v)", + gotDesired, gotControl, tt.wantDesired, tt.wantControl) + } + }) + } +}