package api import ( "slices" "testing" "time" ) func date(year int, month time.Month, day int) time.Time { return time.Date(year, month, day, 12, 0, 0, 0, time.UTC) } // The windows are tested at both edges rather than in the middle, because the middle is // never what breaks: a season that starts a day late is a season nobody sees the start of, // and one that ends a day late is a red launcher on the 27th of December. func TestSeasonalThemeWindows(t *testing.T) { cases := []struct { name string when time.Time want string }{ {"the day before Halloween opens", date(2026, time.October, 24), ""}, {"Halloween opens", date(2026, time.October, 25), themeHalloween}, {"Halloween itself", date(2026, time.October, 31), themeHalloween}, {"All Saints", date(2026, time.November, 1), themeHalloween}, {"the day after Halloween closes", date(2026, time.November, 2), ""}, {"the day before December", date(2026, time.November, 30), ""}, {"December opens", date(2026, time.December, 1), themeChristmas}, {"Boxing Day", date(2026, time.December, 26), themeChristmas}, {"the day after Boxing Day", date(2026, time.December, 27), ""}, {"New Year's Eve", date(2026, time.December, 31), ""}, {"an ordinary day", date(2026, time.June, 14), ""}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { if got := seasonalThemeFor(testCase.when); got != testCase.want { t.Fatalf("seasonalThemeFor(%s) = %q, want %q", testCase.when.Format(time.DateOnly), got, testCase.want) } }) } } // Easter moves, which is the entire reason it is computed rather than listed. These are the // real dates; if the computus is wrong the feature silently observes Easter on the wrong // weekend, which nobody would report as a bug. func TestEasterSunday(t *testing.T) { cases := map[int]string{ 2024: "2024-03-31", 2025: "2025-04-20", 2026: "2026-04-05", 2027: "2027-03-28", 2030: "2030-04-21", 2038: "2038-04-25", } for year, want := range cases { if got := easterSunday(year).Format(time.DateOnly); got != want { t.Fatalf("easterSunday(%d) = %s, want %s", year, got, want) } } } func TestEasterWindowIsTheLongWeekend(t *testing.T) { // Easter Sunday 2026 is 5 April, so the window is Good Friday the 3rd to Easter // Monday the 6th. cases := []struct { day int want string }{ {2, ""}, {3, themeEaster}, {4, themeEaster}, {5, themeEaster}, {6, themeEaster}, {7, ""}, } for _, testCase := range cases { if got := seasonalThemeFor(date(2026, time.April, testCase.day)); got != testCase.want { t.Fatalf("2026-04-%02d = %q, want %q", testCase.day, got, testCase.want) } } } // A season is the one thing on this feature nobody on a television can decline, so the // tests that matter most are the ones asserting that no argument suppresses it. func TestSeasonOutranksTheViewer(t *testing.T) { resolved := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20)) if resolved.ID != themeChristmas { t.Fatalf("applied theme = %q, want %q", resolved.ID, themeChristmas) } if !resolved.Seasonal || !resolved.Locked { t.Fatalf("seasonal=%v locked=%v, want both true", resolved.Seasonal, resolved.Locked) } if resolved.Reason == "" { t.Fatal("a locked theme must say why; the picker has nothing else to print") } // The viewer's own choice survives underneath, or the picker would show nothing // selected for the length of the season and look as though it had been forgotten. if resolved.Chosen != themePlum { t.Fatalf("chosen = %q, want %q", resolved.Chosen, themePlum) } } // An allowlist narrows what somebody may pick. It must not narrow a season: those are not // grantable per person, and an operator restricting a viewer to one palette must not be a // way of exempting them from Christmas. func TestAllowlistDoesNotApplyToSeasons(t *testing.T) { resolved := resolveTheme(themePlum, []string{themeEmber}, true, true, date(2026, time.October, 31)) if resolved.ID != themeHalloween { t.Fatalf("applied theme = %q, want %q", resolved.ID, themeHalloween) } // The disallowed choice falls back to the default rather than being kept, so the // moment the season ends this set paints itself something the operator permits. if resolved.Chosen != defaultThemeID { t.Fatalf("chosen = %q, want the default %q", resolved.Chosen, defaultThemeID) } } func TestSeasonsOffLeavesTheViewersChoice(t *testing.T) { resolved := resolveTheme(themeEmber, nil, false, true, date(2026, time.December, 20)) if resolved.ID != themeEmber { t.Fatalf("applied theme = %q, want %q", resolved.ID, themeEmber) } if resolved.Seasonal || resolved.Locked { t.Fatal("with seasonal themes off nothing is locked") } } func TestResolveThemeFallbacks(t *testing.T) { ordinary := date(2026, time.June, 14) cases := []struct { name string chosen string allowed []string want string }{ {"nothing chosen", "", nil, defaultThemeID}, {"a theme this server does not have", "chartreuse", nil, defaultThemeID}, {"a theme the operator withheld", themePlum, []string{themeEmber}, defaultThemeID}, {"a theme the operator allowed", themePlum, []string{themePlum, themeEmber}, themePlum}, {"no restriction at all", themeForest, []string{}, themeForest}, // A season named directly is not a way in. It is not selectable, so it is not a // legal stored value however it got into the document. {"a season asked for out of season", themeHalloween, nil, defaultThemeID}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { got := resolveTheme(testCase.chosen, testCase.allowed, true, true, ordinary) if got.ID != testCase.want { t.Fatalf("resolveTheme(%q, %v) = %q, want %q", testCase.chosen, testCase.allowed, got.ID, testCase.want) } }) } } // The revision is the whole delivery mechanism: a television refetches the palette only when // this moves. If it did not move when a season began, no set in the house would repaint. func TestThemeRevisionTracksTheAnswer(t *testing.T) { ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14)) christmas := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20)) if ordinary.Revision == christmas.Revision { t.Fatal("the revision must change when the season does, or nothing refetches") } // And it must be stable, or every poll would look like a change and every set would // fetch the palette six times a minute. again := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 15)) if ordinary.Revision != again.Revision { t.Fatalf("the revision moved on an ordinary day: %s then %s", ordinary.Revision, again.Revision) } } // The decoration is the operator's second switch. It must be able to come off without // taking the palette with it, and it must never appear on a theme somebody chose to look at // every day of the year. func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) { christmas := date(2026, time.December, 20) if got := resolveTheme(themePlum, nil, true, true, christmas); got.Decoration != decorationSnow { t.Fatalf("decoration = %q, want %q", got.Decoration, decorationSnow) } off := resolveTheme(themePlum, nil, true, false, christmas) if off.Decoration != "" { t.Fatalf("decorations off still returned %q", off.Decoration) } if off.ID != themeChristmas { t.Fatal("turning decorations off must keep the seasonal palette") } // The revision has to move, or a set already snowing is never told to stop. if off.Revision == resolveTheme(themePlum, nil, true, true, christmas).Revision { t.Fatal("the revision must change when the decoration does") } ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14)) if ordinary.Decoration != "" { t.Fatalf("a chosen theme carries a decoration: %q", ordinary.Decoration) } } func TestOnlySeasonalThemesDeclareADecoration(t *testing.T) { for _, theme := range selectableThemes() { if theme.Decoration != "" { t.Fatalf("%q is selectable and must not decorate: %q", theme.ID, theme.Decoration) } } for _, theme := range themeCatalogue { if theme.Seasonal && theme.Decoration == "" { t.Fatalf("%q is a season with nothing to draw", theme.ID) } } } func TestSelectableThemesExcludeSeasons(t *testing.T) { for _, theme := range selectableThemes() { if theme.Seasonal { t.Fatalf("%q is seasonal and must never be offered as a choice", theme.ID) } } if !slices.Contains(selectableThemeIDs(), defaultThemeID) { t.Fatalf("the default theme %q must be selectable", defaultThemeID) } } // Every theme must set every token. A palette missing one is a theme that half-applies, // which reads on a television as a rendering fault rather than as a colour scheme. func TestEveryThemeSetsEveryColour(t *testing.T) { for _, theme := range themeCatalogue { palette := theme.Palette for name, value := range map[string]string{ "surface": palette.Surface, "surfaceRaised": palette.SurfaceRaised, "accent": palette.Accent, "onSurface": palette.OnSurface, "mutedText": palette.MutedText, "quietText": palette.QuietText, "hairline": palette.Hairline, "ratingsSurface": palette.RatingsSurface, } { if len(value) != 9 || value[0] != '#' { t.Fatalf("%s.%s = %q, want #AARRGGBB", theme.ID, name, value) } for _, digit := range value[1:] { if !((digit >= '0' && digit <= '9') || (digit >= 'A' && digit <= 'F')) { t.Fatalf("%s.%s = %q is not upper-case hex", theme.ID, name, value) } } } if theme.Name == "" || theme.Description == "" { t.Fatalf("%s must carry a name and a line for the picker", theme.ID) } } } // The catalogue and the preference vocabulary have to agree, or a viewer can be offered a // theme the settings write will reject — which reads as a picker that does not work. func TestThemePreferenceOptionsMatchTheCatalogue(t *testing.T) { definition, ok := preferenceDefinitionFor("themeId") if !ok { t.Fatal("themeId is missing from the preference catalogue") } if len(definition.Options) != len(selectableThemes()) { t.Fatalf("themeId offers %d options for %d selectable themes", len(definition.Options), len(selectableThemes())) } for _, theme := range selectableThemes() { if !hasOption(definition.Options, theme.ID) { t.Fatalf("themeId does not offer %q", theme.ID) } } if normalizePreference(definition, themeHalloween) != defaultThemeID { t.Fatal("a seasonal id must not normalise to itself as a stored choice") } } func TestNormalizeThemeAllowlist(t *testing.T) { everything := normalizeThemeAllowlist(selectableThemeIDs()) if len(everything) != 0 { // "Every box ticked" and "never configured" are the same decision, and storing // them differently is how the two would drift apart as themes are added. t.Fatalf("an unrestricted list must store as empty, got %v", everything) } kept := normalizeThemeAllowlist([]string{themeEmber, "chartreuse", themeHalloween, themeEmber}) if len(kept) != 1 || kept[0] != themeEmber { t.Fatalf("normalizeThemeAllowlist dropped the wrong things: %v", kept) } // Catalogue order, not request order, so two operators ticking the same boxes in a // different sequence store the same row. ordered := normalizeThemeAllowlist([]string{themePlum, themeMidnight}) if !slices.Equal(ordered, []string{themeMidnight, themePlum}) { t.Fatalf("allowlist is not in catalogue order: %v", ordered) } }