test: add weighted taste profile and search query tests

This commit is contained in:
2026-06-05 16:15:00 +02:00
parent b4061bc9b1
commit 8b26e5f036
3 changed files with 259 additions and 2 deletions

View File

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

153
static/top_pick_carousel.ts Normal file
View File

@@ -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<HTMLElement>("[data-top-pick-track]");
const previous = root.querySelector<HTMLButtonElement>("[data-top-pick-prev]");
const next = root.querySelector<HTMLButtonElement>("[data-top-pick-next]");
const previousFade = root.querySelector<HTMLElement>("[data-top-pick-prev-fade]");
const nextFade = root.querySelector<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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();

View File

@@ -0,0 +1,9 @@
package templates
import "testing"
func TestProvideRendererParsesTemplates(t *testing.T) {
if _, err := ProvideRenderer(); err != nil {
t.Fatalf("parse templates: %v", err)
}
}