343 lines
11 KiB
Go
343 lines
11 KiB
Go
package recipes
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestHalloweenInOctoberMatches verifies the halloween predicate matches October
|
|
// and rejects November.
|
|
func TestHalloweenInOctoberMatches(t *testing.T) {
|
|
pred, ok := SeasonalPredicates["halloween"]
|
|
if !ok {
|
|
t.Fatal("halloween predicate not found")
|
|
}
|
|
|
|
oct15 := time.Date(2024, time.October, 15, 12, 0, 0, 0, time.UTC)
|
|
if !pred(oct15) {
|
|
t.Error("halloween predicate should match Oct 15")
|
|
}
|
|
|
|
nov1 := time.Date(2024, time.November, 1, 12, 0, 0, 0, time.UTC)
|
|
if pred(nov1) {
|
|
t.Error("halloween predicate should reject Nov 1")
|
|
}
|
|
}
|
|
|
|
// TestChristmasDateRangeMatches verifies the christmas predicate matches Dec 24
|
|
// and rejects Nov 30. Also pins the boundaries: the window is Dec 1 inclusive
|
|
// through Dec 31 inclusive.
|
|
func TestChristmasDateRangeMatches(t *testing.T) {
|
|
pred, ok := SeasonalPredicates["christmas"]
|
|
if !ok {
|
|
t.Fatal("christmas predicate not found")
|
|
}
|
|
|
|
dec24 := time.Date(2024, time.December, 24, 12, 0, 0, 0, time.UTC)
|
|
if !pred(dec24) {
|
|
t.Error("christmas predicate should match Dec 24")
|
|
}
|
|
|
|
nov30 := time.Date(2024, time.November, 30, 12, 0, 0, 0, time.UTC)
|
|
if pred(nov30) {
|
|
t.Error("christmas predicate should reject Nov 30")
|
|
}
|
|
|
|
// Boundary: start of window (Dec 1 00:00:00) must match.
|
|
dec1Start := time.Date(2026, time.December, 1, 0, 0, 0, 0, time.UTC)
|
|
if !pred(dec1Start) {
|
|
t.Error("christmas predicate should match Dec 1 00:00:00 (start boundary)")
|
|
}
|
|
|
|
// Boundary: end of window (Dec 31 23:59:59) must match.
|
|
dec31End := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.UTC)
|
|
if !pred(dec31End) {
|
|
t.Error("christmas predicate should match Dec 31 23:59:59 (end boundary)")
|
|
}
|
|
|
|
// Boundary: just past end of window (Jan 1 of next year) must reject.
|
|
jan1Next := time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC)
|
|
if pred(jan1Next) {
|
|
t.Error("christmas predicate should reject Jan 1 (just past window)")
|
|
}
|
|
}
|
|
|
|
// TestSaturdayMorningPredicate verifies the saturday_morning predicate matches
|
|
// Saturday before 13:00, rejects Monday at 10am, and rejects Saturday at 3pm.
|
|
func TestSaturdayMorningPredicate(t *testing.T) {
|
|
pred, ok := SeasonalPredicates["saturday_morning"]
|
|
if !ok {
|
|
t.Fatal("saturday_morning predicate not found")
|
|
}
|
|
|
|
// Saturday at 10am — should match
|
|
sat10am := time.Date(2024, time.October, 5, 10, 0, 0, 0, time.UTC) // Oct 5, 2024 is a Saturday
|
|
if !pred(sat10am) {
|
|
t.Error("saturday_morning predicate should match Saturday 10am")
|
|
}
|
|
|
|
// Monday at 10am — should reject
|
|
mon10am := time.Date(2024, time.October, 7, 10, 0, 0, 0, time.UTC) // Oct 7, 2024 is a Monday
|
|
if pred(mon10am) {
|
|
t.Error("saturday_morning predicate should reject Monday 10am")
|
|
}
|
|
|
|
// Saturday at 3pm (15:00) — should reject
|
|
sat3pm := time.Date(2024, time.October, 5, 15, 0, 0, 0, time.UTC)
|
|
if pred(sat3pm) {
|
|
t.Error("saturday_morning predicate should reject Saturday 3pm")
|
|
}
|
|
|
|
// Boundary: Saturday at exactly 13:00 must reject (predicate is `t.Hour() < 13`).
|
|
// May 2, 2026 is a Saturday.
|
|
sat13 := time.Date(2026, time.May, 2, 13, 0, 0, 0, time.UTC)
|
|
if pred(sat13) {
|
|
t.Error("saturday_morning predicate should reject Saturday at exactly 13:00")
|
|
}
|
|
|
|
// Boundary: Saturday at 12:59:59 must match.
|
|
sat1259 := time.Date(2026, time.May, 2, 12, 59, 59, 0, time.UTC)
|
|
if !pred(sat1259) {
|
|
t.Error("saturday_morning predicate should match Saturday 12:59:59")
|
|
}
|
|
}
|
|
|
|
// TestSeasonalRecipeRegistered verifies the recipe is in the registry.
|
|
func TestSeasonalRecipeRegistered(t *testing.T) {
|
|
_, ok := Get("seasonal_themed")
|
|
if !ok {
|
|
t.Fatal("seasonal_themed not registered")
|
|
}
|
|
}
|
|
|
|
// TestSeasonalValidatesTheme verifies that an unknown theme is rejected and a
|
|
// known theme with mode "auto" is accepted.
|
|
func TestSeasonalValidatesTheme(t *testing.T) {
|
|
rec, ok := Get("seasonal_themed")
|
|
if !ok {
|
|
t.Fatal("seasonal_themed not registered")
|
|
}
|
|
|
|
// Unknown theme must be rejected.
|
|
badTheme := json.RawMessage(`{"theme":"unicorn"}`)
|
|
if err := rec.Validate(badTheme); err == nil {
|
|
t.Error("unknown theme should be rejected")
|
|
}
|
|
|
|
// Known theme with mode "auto" must be accepted.
|
|
good := json.RawMessage(`{"theme":"halloween","mode":"auto"}`)
|
|
if err := rec.Validate(good); err != nil {
|
|
t.Errorf("valid params rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSeasonalValidatesMode verifies that only valid mode values are accepted.
|
|
func TestSeasonalValidatesMode(t *testing.T) {
|
|
rec, ok := Get("seasonal_themed")
|
|
if !ok {
|
|
t.Fatal("seasonal_themed not registered")
|
|
}
|
|
|
|
cases := []struct {
|
|
mode string
|
|
wantErr bool
|
|
}{
|
|
{"", false}, // empty string → defaults to "auto", valid
|
|
{"auto", false}, // explicit auto
|
|
{"pinned", false}, // pinned (always show in-season items)
|
|
{"forced", true}, // unknown mode — rejected
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
raw := json.RawMessage(`{"theme":"halloween","mode":"` + tc.mode + `"}`)
|
|
if tc.mode == "" {
|
|
raw = json.RawMessage(`{"theme":"halloween"}`)
|
|
}
|
|
err := rec.Validate(raw)
|
|
if tc.wantErr && err == nil {
|
|
t.Errorf("mode=%q should be rejected", tc.mode)
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Errorf("mode=%q should be accepted, got: %v", tc.mode, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSeasonalRequiresTheme verifies that missing/empty theme is rejected.
|
|
func TestSeasonalRequiresTheme(t *testing.T) {
|
|
rec, ok := Get("seasonal_themed")
|
|
if !ok {
|
|
t.Fatal("seasonal_themed not registered")
|
|
}
|
|
|
|
// Empty raw
|
|
if err := rec.Validate(nil); err == nil {
|
|
t.Error("nil raw should be rejected")
|
|
}
|
|
if err := rec.Validate(json.RawMessage(``)); err == nil {
|
|
t.Error("empty raw should be rejected")
|
|
}
|
|
|
|
// Empty JSON object
|
|
if err := rec.Validate(json.RawMessage(`{}`)); err == nil {
|
|
t.Error("empty object should be rejected (theme required)")
|
|
}
|
|
|
|
// Explicit empty theme string
|
|
if err := rec.Validate(json.RawMessage(`{"theme":""}`)); err == nil {
|
|
t.Error(`{"theme":""} should be rejected`)
|
|
}
|
|
}
|
|
|
|
// TestAllSeasonalPredicatesPresent verifies every theme referenced by a preset
|
|
// (in either the legacy single-theme form or the new EnabledThemes list) has a
|
|
// corresponding predicate in SeasonalPredicates.
|
|
func TestAllSeasonalPredicatesPresent(t *testing.T) {
|
|
rec, ok := Get("seasonal_themed")
|
|
if !ok {
|
|
t.Fatal("seasonal_themed not registered")
|
|
}
|
|
|
|
for _, preset := range rec.Definition().Presets {
|
|
var p SeasonalThemedParams
|
|
if err := json.Unmarshal(preset.DefaultParams, &p); err != nil {
|
|
t.Errorf("preset %q: failed to unmarshal DefaultParams: %v", preset.Key, err)
|
|
continue
|
|
}
|
|
themes := append([]string{}, p.EnabledThemes...)
|
|
if p.Theme != "" {
|
|
themes = append(themes, p.Theme)
|
|
}
|
|
if len(themes) == 0 {
|
|
t.Errorf("preset %q: neither enabled_themes nor theme set", preset.Key)
|
|
continue
|
|
}
|
|
for _, th := range themes {
|
|
if _, hasPred := SeasonalPredicates[th]; !hasPred {
|
|
t.Errorf("preset %q: theme %q has no predicate in SeasonalPredicates", preset.Key, th)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestActiveSeasonalThemePicksMatchingTheme verifies the auto-cycle resolver
|
|
// returns the highest-priority enabled theme whose predicate matches now.
|
|
func TestActiveSeasonalThemePicksMatchingTheme(t *testing.T) {
|
|
enabled := []string{"halloween", "christmas", "saturday_morning"}
|
|
|
|
// Mid-October Wednesday — only halloween fires.
|
|
got := ActiveSeasonalTheme(enabled, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC))
|
|
if got != "halloween" {
|
|
t.Errorf("Oct 14 → %q, want halloween", got)
|
|
}
|
|
|
|
// October Saturday at 10am — both halloween and saturday_morning fire;
|
|
// halloween wins because it's earlier in SeasonalThemeOrder.
|
|
got = ActiveSeasonalTheme(enabled, time.Date(2026, 10, 17, 10, 0, 0, 0, time.UTC))
|
|
if got != "halloween" {
|
|
t.Errorf("Oct 17 Sat 10am → %q, want halloween (priority over saturday_morning)", got)
|
|
}
|
|
|
|
// March Saturday at 10am — only saturday_morning fires.
|
|
got = ActiveSeasonalTheme(enabled, time.Date(2026, 3, 7, 10, 0, 0, 0, time.UTC))
|
|
if got != "saturday_morning" {
|
|
t.Errorf("March 7 Sat 10am → %q, want saturday_morning", got)
|
|
}
|
|
|
|
// July 4 with only halloween and christmas enabled — no themes fire.
|
|
// (Excluding saturday_morning here because July 4 2026 happens to be a
|
|
// Saturday, which would otherwise match.)
|
|
got = ActiveSeasonalTheme([]string{"halloween", "christmas"}, time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC))
|
|
if got != "" {
|
|
t.Errorf("July 4 with halloween/christmas enabled → %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestActiveSeasonalThemeIgnoresDisabledThemes confirms a theme not in the
|
|
// enabled list never wins, even when its predicate matches.
|
|
func TestActiveSeasonalThemeIgnoresDisabledThemes(t *testing.T) {
|
|
// Mid-October but halloween is NOT enabled — auto-cycle returns "".
|
|
got := ActiveSeasonalTheme([]string{"christmas"}, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC))
|
|
if got != "" {
|
|
t.Errorf("halloween disabled → got %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestSeasonalTitleOverridePicksActiveThemeTitle verifies the helper returns
|
|
// the per-theme title configured for whichever theme is currently active.
|
|
func TestSeasonalTitleOverridePicksActiveThemeTitle(t *testing.T) {
|
|
p := SeasonalThemedParams{
|
|
EnabledThemes: []string{"halloween", "christmas"},
|
|
ThemeTitles: map[string]string{
|
|
"halloween": "Spooky Picks",
|
|
"christmas": "Festive Films",
|
|
},
|
|
}
|
|
|
|
// October — halloween fires.
|
|
got := SeasonalTitleOverride(p, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC))
|
|
if got != "Spooky Picks" {
|
|
t.Errorf("Oct 14 → %q, want Spooky Picks", got)
|
|
}
|
|
|
|
// December — christmas fires.
|
|
got = SeasonalTitleOverride(p, time.Date(2026, 12, 20, 12, 0, 0, 0, time.UTC))
|
|
if got != "Festive Films" {
|
|
t.Errorf("Dec 20 → %q, want Festive Films", got)
|
|
}
|
|
|
|
// April — nothing fires, no override.
|
|
got = SeasonalTitleOverride(p, time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC))
|
|
if got != "" {
|
|
t.Errorf("Apr 15 → %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestSeasonalTitleOverrideHandlesMissingEntry returns empty when the active
|
|
// theme has no title configured (the section's saved Title is the fallback).
|
|
func TestSeasonalTitleOverrideHandlesMissingEntry(t *testing.T) {
|
|
p := SeasonalThemedParams{
|
|
EnabledThemes: []string{"halloween", "christmas"},
|
|
ThemeTitles: map[string]string{"christmas": "Festive Films"},
|
|
}
|
|
got := SeasonalTitleOverride(p, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC))
|
|
if got != "" {
|
|
t.Errorf("halloween active but no entry → %q, want empty (caller falls back to section Title)", got)
|
|
}
|
|
}
|
|
|
|
// TestSeasonalTitleOverrideLegacyMode returns the override only when the
|
|
// legacy theme's predicate fires.
|
|
func TestSeasonalTitleOverrideLegacyMode(t *testing.T) {
|
|
p := SeasonalThemedParams{
|
|
Theme: "halloween",
|
|
Mode: "auto",
|
|
ThemeTitles: map[string]string{"halloween": "Spooky"},
|
|
}
|
|
got := SeasonalTitleOverride(p, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC))
|
|
if got != "Spooky" {
|
|
t.Errorf("legacy in-season → %q, want Spooky", got)
|
|
}
|
|
got = SeasonalTitleOverride(p, time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC))
|
|
if got != "" {
|
|
t.Errorf("legacy off-season → %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestSeasonalValidatesEnabledThemes verifies the validator accepts the new
|
|
// multi-theme shape and rejects unknown theme keys.
|
|
func TestSeasonalValidatesEnabledThemes(t *testing.T) {
|
|
rec, _ := Get("seasonal_themed")
|
|
|
|
good := json.RawMessage(`{"enabled_themes":["halloween","christmas"]}`)
|
|
if err := rec.Validate(good); err != nil {
|
|
t.Errorf("good params rejected: %v", err)
|
|
}
|
|
|
|
bad := json.RawMessage(`{"enabled_themes":["halloween","made_up"]}`)
|
|
if err := rec.Validate(bad); err == nil {
|
|
t.Error("expected error for unknown theme in enabled_themes")
|
|
}
|
|
}
|