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) } } // --- Icon packs --------------------------------------------------------------------------- // The gateway's half of the wire contract with ui/theme/MembyIconPacks.kt. Its own // MembyIconPackTest pins the same three slugs from the television's end; change one // without the other and a household is sent marks nothing can draw. func TestIconPackOptionsAreTheKnownPacks(t *testing.T) { for _, option := range iconPackOptions() { if !knownIconPack(option.Value) { t.Fatalf("offered icon pack %q is not one the resolver will accept", option.Value) } } if !knownIconPack(defaultIconPackID) { t.Fatalf("the default icon pack %q is not selectable", defaultIconPackID) } } // A slug the resolver does not recognise must never reach a television. The set would fall // back on its own, but a gateway echoing a retired pack would make every screen in the // house look wrong in the same way while reporting it had done what it was asked. func TestUnknownIconPackFallsBackToTheShippedMarks(t *testing.T) { ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC) for _, chosen := range []string{"", "tabler", "material "} { got := resolveTheme(themeMidnight, chosen, nil, true, true, ordinary) if got.IconSet != defaultIconPackID { t.Fatalf("resolveTheme(icon %q) = %q, want %q", chosen, got.IconSet, defaultIconPackID) } } } // The viewer's marks stand on every scheme they can choose, and only a season may replace // them — the same asymmetry decorations have, and for the same reason: a season is a look, // where a scheme somebody picked to live with all year must not silently take their marks // away with no way to tell which choice did it. func TestOnlyASeasonMayReplaceTheViewersMarks(t *testing.T) { ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC) if got := resolveTheme(themePlum, iconPackLucide, nil, true, true, ordinary); got.IconSet != iconPackLucide { t.Fatalf("an ordinary day = %q, want the viewer's %q", got.IconSet, iconPackLucide) } halloween := time.Date(2026, time.October, 30, 20, 0, 0, 0, time.UTC) got := resolveTheme(themePlum, iconPackLucide, nil, true, true, halloween) if !got.Seasonal { t.Fatalf("expected the Halloween window to be seasonal") } if got.IconSet != iconPackFontAwesome { t.Fatalf("Halloween = %q, want its own %q", got.IconSet, iconPackFontAwesome) } if got.Chosen != themePlum { t.Fatalf("the viewer's own choice should survive underneath a season, got %q", got.Chosen) } // Seasons off: the viewer keeps both halves. if off := resolveTheme(themePlum, iconPackLucide, nil, false, true, halloween); off.IconSet != iconPackLucide { t.Fatalf("with seasons off = %q, want the viewer's %q", off.IconSet, iconPackLucide) } } // Only seasonal themes may declare marks of their own, the rule that holds the asymmetry // above in place by construction rather than by a check at the point of use. func TestOnlySeasonalThemesDeclareAnIconSet(t *testing.T) { for _, theme := range themeCatalogue { if theme.Seasonal { if theme.IconSet != "" && !knownIconPack(theme.IconSet) { t.Fatalf("%s names an icon pack %q nothing can draw", theme.ID, theme.IconSet) } continue } if theme.IconSet != "" { t.Fatalf("selectable theme %s takes the viewer's marks away", theme.ID) } } } // The revision is the entire delivery mechanism: televisions compare it and refetch only // when it moves. A pack change the hash did not notice would reach nobody until something // else about the theme happened to change. func TestThemeRevisionTracksTheIconSet(t *testing.T) { ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC) material := resolveTheme(themeMidnight, iconPackMaterial, nil, true, true, ordinary) lucide := resolveTheme(themeMidnight, iconPackLucide, nil, true, true, ordinary) if material.Revision == lucide.Revision { t.Fatalf("the same revision %q for two different icon packs", material.Revision) } }