Files
memby/server/internal/api/themes_test.go
T
2026-08-09 08:25:50 +12:00

280 lines
11 KiB
Go

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)
}
}