Release v0.2.34
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user