From 8e66581f6c11ff29bf85a21168ed9c83dbc38a39 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 5 Jun 2026 16:15:00 +0200 Subject: [PATCH] test: add weighted taste profile and search query tests --- internal/anime/recommendations_test.go | 99 +++++++++++++++- static/top_pick_carousel.ts | 153 +++++++++++++++++++++++++ templates/renderer_test.go | 9 ++ 3 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 static/top_pick_carousel.ts create mode 100644 templates/renderer_test.go diff --git a/internal/anime/recommendations_test.go b/internal/anime/recommendations_test.go index 002dd72..7c9c5ea 100644 --- a/internal/anime/recommendations_test.go +++ b/internal/anime/recommendations_test.go @@ -59,15 +59,110 @@ func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) { Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}}, Popularity: 100, Score: 8.0, - }, 5.0) + }, 5.0, 0) nonMatching := scoreRecommendationCandidate(now, profile, jikan.Anime{ MalID: 11, Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}}, Popularity: 100, Score: 8.0, - }, 5.0) + }, 5.0, 0) if matching.score <= nonMatching.score { t.Fatalf("expected matching candidate to score higher, got matching=%f nonMatching=%f", matching.score, nonMatching.score) } } + +func TestBuildTasteProfileUsesSeedWeights(t *testing.T) { + now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC) + + profile := buildTasteProfile( + now, + []recommendationSeed{ + {animeID: 1, weight: 2.0}, + {animeID: 2, weight: 0.5}, + }, + []jikan.Anime{ + { + MalID: 1, + Airing: true, + Year: 2026, + Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}}, + Themes: []jikan.NamedEntity{{MalID: 10, Name: "Team Sports"}}, + Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}}, + Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}}, + }, + { + MalID: 2, + Year: 2001, + Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}}, + Themes: []jikan.NamedEntity{{MalID: 11, Name: "School"}}, + Studios: []jikan.NamedEntity{{MalID: 21, Name: "Madhouse"}}, + Demographics: []jikan.NamedEntity{{MalID: 31, Name: "Seinen"}}, + }, + }, + ) + + if profile.genres[1] <= profile.genres[2] { + t.Fatalf("expected stronger seed genre to carry more weight, got profile=%+v", profile.genres) + } + if !profile.prefersAiring { + t.Fatal("expected weighted profile to prefer airing anime") + } + if !profile.prefersRecent { + t.Fatal("expected weighted profile to prefer recent anime") + } +} + +func TestBuildProfileSearchQueriesIncludesTasteSignals(t *testing.T) { + profile := userTasteProfile{ + genres: map[int]float64{ + 1: 2.0, + 2: 1.5, + 3: 0.2, + }, + themes: map[int]float64{ + 10: 1.4, + }, + studios: map[int]float64{ + 20: 1.0, + }, + demographics: map[int]float64{ + 30: 1.2, + }, + } + + queries := buildProfileSearchQueries(profile) + + if !hasGenreSearchQuery(queries, 1) { + t.Fatalf("expected strongest genre query, got %+v", queries) + } + if !hasGenreSearchQuery(queries, 10) { + t.Fatalf("expected theme query, got %+v", queries) + } + if !hasGenreSearchQuery(queries, 30) { + t.Fatalf("expected demographic query, got %+v", queries) + } + if !hasStudioSearchQuery(queries, 20) { + t.Fatalf("expected studio query, got %+v", queries) + } +} + +func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool { + for _, query := range queries { + for _, id := range query.genreIDs { + if id == genreID { + return true + } + } + } + return false +} + +func hasStudioSearchQuery(queries []profileSearchQuery, studioID int) bool { + for _, query := range queries { + if query.studioID == studioID { + return true + } + } + return false +} diff --git a/static/top_pick_carousel.ts b/static/top_pick_carousel.ts new file mode 100644 index 0000000..d23c4d0 --- /dev/null +++ b/static/top_pick_carousel.ts @@ -0,0 +1,153 @@ +const carouselScrollEpsilon = 2; +const fallbackCarouselOverlap = 96; +const itemOverlapRatio = 0.45; + +type TopPickCarousel = { + track: HTMLElement; + previous: HTMLButtonElement; + next: HTMLButtonElement; + previousFade: HTMLElement; + nextFade: HTMLElement; +}; + +const getTopPickCarousel = (root: HTMLElement): TopPickCarousel | null => { + const track = root.querySelector("[data-top-pick-track]"); + const previous = root.querySelector("[data-top-pick-prev]"); + const next = root.querySelector("[data-top-pick-next]"); + const previousFade = root.querySelector("[data-top-pick-prev-fade]"); + const nextFade = root.querySelector("[data-top-pick-next-fade]"); + + if (!track || !previous || !next || !previousFade || !nextFade) { + return null; + } + + return { track, previous, next, previousFade, nextFade }; +}; + +const topPickCarousels = (): HTMLElement[] => + Array.from(document.querySelectorAll("[data-top-pick-carousel]")); + +const maxScrollLeft = (track: HTMLElement): number => + Math.max(0, track.scrollWidth - track.clientWidth); + +const updateTopPickCarousel = (root: HTMLElement): void => { + const carousel = getTopPickCarousel(root); + if (!carousel) { + return; + } + + const maxScroll = maxScrollLeft(carousel.track); + const canScroll = maxScroll > carouselScrollEpsilon; + const hasPrevious = canScroll && carousel.track.scrollLeft > carouselScrollEpsilon; + const hasNext = canScroll && carousel.track.scrollLeft < maxScroll - carouselScrollEpsilon; + + carousel.previous.disabled = false; + carousel.next.disabled = false; + carousel.previous.classList.toggle("hidden", !hasPrevious); + carousel.previous.classList.toggle("inline-flex", hasPrevious); + carousel.previous.setAttribute("aria-hidden", String(!hasPrevious)); + carousel.previous.tabIndex = hasPrevious ? 0 : -1; + carousel.previousFade.classList.toggle("hidden", !hasPrevious); + + carousel.next.classList.toggle("hidden", !hasNext); + carousel.next.classList.toggle("inline-flex", hasNext); + carousel.next.setAttribute("aria-hidden", String(!hasNext)); + carousel.next.tabIndex = hasNext ? 0 : -1; + carousel.nextFade.classList.toggle("hidden", !hasNext); +}; + +const updateTopPickCarousels = (): void => { + topPickCarousels().forEach(updateTopPickCarousel); +}; + +const carouselScrollAmount = (track: HTMLElement): number => { + const firstItem = track.querySelector("[data-top-pick-item]"); + if (!firstItem) { + return Math.max(160, track.clientWidth - fallbackCarouselOverlap); + } + + const itemWidth = firstItem.getBoundingClientRect().width; + const overlap = Math.max(fallbackCarouselOverlap, itemWidth * itemOverlapRatio); + + return Math.max(itemWidth, track.clientWidth - Math.min(itemWidth, overlap)); +}; + +const scrollTopPickCarousel = (root: HTMLElement, direction: -1 | 1): void => { + const carousel = getTopPickCarousel(root); + if (!carousel) { + return; + } + + const currentScroll = carousel.track.scrollLeft; + const targetScroll = + direction < 0 + ? Math.max(0, currentScroll - carouselScrollAmount(carousel.track)) + : Math.min( + maxScrollLeft(carousel.track), + currentScroll + carouselScrollAmount(carousel.track), + ); + + carousel.track.scrollTo({ + left: targetScroll, + behavior: "smooth", + }); + + window.setTimeout(() => updateTopPickCarousel(root), 350); +}; + +document.addEventListener( + "click", + (event: MouseEvent): void => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const previous = target.closest("[data-top-pick-prev]"); + if (previous) { + event.preventDefault(); + event.stopPropagation(); + const root = previous.closest("[data-top-pick-carousel]"); + if (root) { + scrollTopPickCarousel(root, -1); + } + return; + } + + const next = target.closest("[data-top-pick-next]"); + if (!next) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const root = next.closest("[data-top-pick-carousel]"); + if (root) { + scrollTopPickCarousel(root, 1); + } + }, + true, +); + +document.addEventListener( + "scroll", + (event: Event): void => { + const target = event.target; + if (!(target instanceof HTMLElement) || !target.matches("[data-top-pick-track]")) { + return; + } + + const root = target.closest("[data-top-pick-carousel]"); + if (root) { + updateTopPickCarousel(root); + } + }, + true, +); + +document.addEventListener("DOMContentLoaded", updateTopPickCarousels); +document.addEventListener("htmx:afterSwap", updateTopPickCarousels); +document.addEventListener("htmx:afterSettle", updateTopPickCarousels); +window.addEventListener("resize", updateTopPickCarousels); + +updateTopPickCarousels(); diff --git a/templates/renderer_test.go b/templates/renderer_test.go new file mode 100644 index 0000000..7caaf68 --- /dev/null +++ b/templates/renderer_test.go @@ -0,0 +1,9 @@ +package templates + +import "testing" + +func TestProvideRendererParsesTemplates(t *testing.T) { + if _, err := ProvideRenderer(); err != nil { + t.Fatalf("parse templates: %v", err) + } +}