test: add weighted taste profile and search query tests
This commit is contained in:
@@ -59,15 +59,110 @@ func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
|
|||||||
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
||||||
Popularity: 100,
|
Popularity: 100,
|
||||||
Score: 8.0,
|
Score: 8.0,
|
||||||
}, 5.0)
|
}, 5.0, 0)
|
||||||
nonMatching := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
nonMatching := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||||
MalID: 11,
|
MalID: 11,
|
||||||
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
|
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
|
||||||
Popularity: 100,
|
Popularity: 100,
|
||||||
Score: 8.0,
|
Score: 8.0,
|
||||||
}, 5.0)
|
}, 5.0, 0)
|
||||||
|
|
||||||
if matching.score <= nonMatching.score {
|
if matching.score <= nonMatching.score {
|
||||||
t.Fatalf("expected matching candidate to score higher, got matching=%f nonMatching=%f", 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
153
static/top_pick_carousel.ts
Normal 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();
|
||||||
9
templates/renderer_test.go
Normal file
9
templates/renderer_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user