* feat(sections): fix broken home section templates and add six new ones Fixes templates that silently produced nothing: - award_winners: hide from gallery (resolver is a stub until award data exists); saved sections keep resolving - seasonal_themed: christmas/st_patricks/thanksgiving get an interim title-keyword resolver, and multi-theme selection skips themes without an executable query so a data-less theme can no longer black out the section during its own window (previously killed the section all of December) - taste_match: empty genre now auto-picks the profile's strongest taste cluster (fallback: server top genre); the default preset was permanently empty - because_you_watched: honor the recipe's anchor_item_id key (fetcher only read legacy source_item_id, so pinning an anchor did nothing) - editorial_spotlight: reject subject_type=franchise (validated but could never resolve); fix drawer misrepresenting pinned presets as auto-rotate - admin_curated_list: add a catalog-search item picker so Editor's Picks is actually addable; block saving an empty list; hide admin_only recipes from profile-facing galleries - discovery fetchers (hidden_gems, forgotten_favorites, critically_acclaimed): honor single/multi library scope, intersected with viewer access; implement hidden_gems max_play_count New templates: returning_shows (new season of shows you've watched), genre_roulette (rotating top-genre spotlight with title override), anniversaries (milestone release anniversaries this month), short_watches (well-rated movies under a runtime cap), family_movie_night seasonal theme (Fri/Sat evenings), and a "New in 4K" format_showcase preset via a new sort=recent param. Adds a blanket test asserting every visible gallery preset's defaults pass its own recipe validation — the gap that let taste_match and Editor's Picks ship broken. New SQL shapes validated with EXPLAIN against the dev database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sections): address PR #332 review findings Codex review: - returning_shows: the new-season file check now applies the effective library scope (section scope ∩ viewer-allowed, minus disabled) to media_files.media_folder_id, so an episode file that only exists in an out-of-scope folder can no longer surface the series - buildLibraryScope: replaced the media_item_libraries row join with EXISTS / NOT EXISTS semi-joins. An item in several in-scope libraries now yields exactly one row in the non-GROUP BY rails (short_watches, anniversaries, seasonal keyword, format_showcase, new_to_library, ...), and the disabled-library check is item-level, closing the join-row leak where membership in an allowed library masked membership in a disabled one. Deny-only mode keeps the positive-membership guard, mirroring catalog's appendDiscoveryLibraryScope. CodeRabbit review: - recommendations reader: a taste cluster whose cached items are entirely filtered out now falls through to the next cluster / global fallback instead of returning an empty row - genre_roulette: multi-library scopes get distinct rotation seeds - returning_shows: reject negative lookback_days at validation - shared oneOf() enum validator replaces per-recipe switch duplication - SeasonalTitleOverride usable-filter contract covered by a direct test - web NumberParamField: integer-only guard + step=1 (backend fields are Go ints; fractional values failed unmarshalling at save) - curated list picker: search failures show an error instead of a misleading "No matches."; pre-existing item_ids hydrate display titles via the watch-detail endpoint instead of rendering raw ids Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
159 lines
5.5 KiB
Go
159 lines
5.5 KiB
Go
package recipes
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- RotationIndex tests ---
|
|
|
|
func TestRotationIndexStableWithinWeek(t *testing.T) {
|
|
// ISO week 18 of 2026 runs Mon 2026-04-27 through Sun 2026-05-03.
|
|
t1 := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) // ISO week 18 (Mon)
|
|
t2 := time.Date(2026, 5, 3, 23, 59, 0, 0, time.UTC) // ISO week 18 (Sun)
|
|
if RotationIndex(t1, "director", 12, 7) != RotationIndex(t2, "director", 12, 7) {
|
|
t.Fatal("rotation drifted within a week")
|
|
}
|
|
}
|
|
|
|
func TestRotationIndexAdvancesOnWeekBoundary(t *testing.T) {
|
|
// Sun 2026-05-03 is in ISO week 18; Mon 2026-05-04 starts ISO week 19.
|
|
// These are deterministic, pinned dates; the FNV-64a buckets for
|
|
// "director|202618" and "director|202619" hash to distinct indices
|
|
// modulo 12. If this test ever flakes, double-check the hash function
|
|
// hasn't changed — the indices below are pinned to a known-good run.
|
|
idx18 := RotationIndex(time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC), "director", 12, 7)
|
|
idx19 := RotationIndex(time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC), "director", 12, 7)
|
|
if idx18 == idx19 {
|
|
t.Fatalf("expected indices to differ across ISO weeks: both = %d", idx18)
|
|
}
|
|
const wantIdx18, wantIdx19 = 4, 11
|
|
if idx18 != wantIdx18 || idx19 != wantIdx19 {
|
|
t.Fatalf("pinned hash drift: idx18=%d (want %d), idx19=%d (want %d) — did the hash change?",
|
|
idx18, wantIdx18, idx19, wantIdx19)
|
|
}
|
|
}
|
|
|
|
func TestRotationKeyIsValueBased(t *testing.T) {
|
|
// Simulate two process runs that both scope to library 42. The fetcher
|
|
// builds the key from the integer value (not the *int pointer), so the
|
|
// rotation must be stable across restarts.
|
|
keyA := "director|42"
|
|
keyB := "director|42"
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
if RotationIndex(now, keyA, 12, 7) != RotationIndex(now, keyB, 12, 7) {
|
|
t.Fatal("rotation must be stable for the same value-based key")
|
|
}
|
|
}
|
|
|
|
func TestRotationIndexBoundsByCandidateCount(t *testing.T) {
|
|
for i := 0; i < 100; i++ {
|
|
idx := RotationIndex(time.Now().Add(time.Duration(i)*24*time.Hour), "director", 5, 0)
|
|
if idx < 0 || idx >= 5 {
|
|
t.Fatalf("index %d out of [0,5)", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Recipe registration and definition tests ---
|
|
|
|
func TestEditorialSpotlightRecipeRegistered(t *testing.T) {
|
|
rec, ok := Get("editorial_spotlight")
|
|
if !ok {
|
|
t.Fatal("editorial_spotlight not registered")
|
|
}
|
|
if !rec.Definition().SupportsRotation {
|
|
t.Error("editorial_spotlight should advertise rotation support")
|
|
}
|
|
}
|
|
|
|
func TestEditorialSpotlightDefinition(t *testing.T) {
|
|
rec, ok := Get("editorial_spotlight")
|
|
if !ok {
|
|
t.Fatal("editorial_spotlight not registered")
|
|
}
|
|
def := rec.Definition()
|
|
if def.Category != CategoryEditorial {
|
|
t.Errorf("category = %v, want editorial", def.Category)
|
|
}
|
|
if !def.SupportsRotation {
|
|
t.Error("SupportsRotation should be true")
|
|
}
|
|
if len(def.Presets) < 4 {
|
|
t.Errorf("expected at least 4 presets, got %d", len(def.Presets))
|
|
}
|
|
}
|
|
|
|
// --- Validate tests ---
|
|
|
|
func TestEditorialSpotlightAcceptsDirectorAutoRotate(t *testing.T) {
|
|
rec, _ := Get("editorial_spotlight")
|
|
raw := json.RawMessage(`{"subject_type":"director","auto_rotate":true,"rotation_cadence":"weekly"}`)
|
|
if err := rec.Validate(raw); err != nil {
|
|
t.Errorf("valid params rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEditorialSpotlightAcceptsEraWithSubject(t *testing.T) {
|
|
rec, _ := Get("editorial_spotlight")
|
|
raw := json.RawMessage(`{"subject_type":"era","subject":"1980s"}`)
|
|
if err := rec.Validate(raw); err != nil {
|
|
t.Errorf("valid params rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEditorialSpotlightRejectsFranchise(t *testing.T) {
|
|
rec, _ := Get("editorial_spotlight")
|
|
// No franchise data source exists, so the fetcher can never resolve a
|
|
// franchise subject — accepting it here would let admins save a section
|
|
// that errors at fetch time. Reject until the data lands.
|
|
raw := json.RawMessage(`{"subject_type":"franchise","subject":"Marvel"}`)
|
|
if err := rec.Validate(raw); err == nil {
|
|
t.Error("franchise subject_type should be rejected until franchise data exists")
|
|
}
|
|
}
|
|
|
|
func TestEditorialSpotlightRejectsEmptyRaw(t *testing.T) {
|
|
rec, _ := Get("editorial_spotlight")
|
|
if err := rec.Validate(nil); err == nil {
|
|
t.Error("empty raw should be rejected (subject_type required)")
|
|
}
|
|
if err := rec.Validate(json.RawMessage(``)); err == nil {
|
|
t.Error("empty raw should be rejected (subject_type required)")
|
|
}
|
|
}
|
|
|
|
func TestEditorialSpotlightRejectsUnknownSubjectType(t *testing.T) {
|
|
rec, _ := Get("editorial_spotlight")
|
|
raw := json.RawMessage(`{"subject_type":"unknown"}`)
|
|
if err := rec.Validate(raw); err == nil {
|
|
t.Error("unknown subject_type should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestEditorialSpotlightRejectsAutoRotateFalseWithEmptySubject(t *testing.T) {
|
|
rec, _ := Get("editorial_spotlight")
|
|
raw := json.RawMessage(`{"subject_type":"director","auto_rotate":false,"subject":""}`)
|
|
if err := rec.Validate(raw); err == nil {
|
|
t.Error("auto_rotate=false with empty subject should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestEditorialSpotlightRejectsInvalidCadence(t *testing.T) {
|
|
rec, _ := Get("editorial_spotlight")
|
|
raw := json.RawMessage(`{"subject_type":"director","auto_rotate":true,"rotation_cadence":"hourly"}`)
|
|
if err := rec.Validate(raw); err == nil {
|
|
t.Error("rotation_cadence=hourly should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestEditorialSpotlightEmptyCadenceIsWeekly(t *testing.T) {
|
|
rec, _ := Get("editorial_spotlight")
|
|
// empty cadence should default to weekly and pass validation
|
|
raw := json.RawMessage(`{"subject_type":"director","auto_rotate":true}`)
|
|
if err := rec.Validate(raw); err != nil {
|
|
t.Errorf("empty rotation_cadence should be treated as weekly: %v", err)
|
|
}
|
|
}
|