Fix sleep bug, fix menu return
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { buildHeroMix, shuffle } from "./heroMix";
|
||||
|
||||
/** Minimal MediaItem for hero tests; `art: false` strips the image tags. */
|
||||
function item(id: string, art = true): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `Item ${id}`,
|
||||
type: "Movie",
|
||||
primaryImageTag: art ? `tag-${id}` : undefined,
|
||||
} as MediaItem;
|
||||
}
|
||||
|
||||
const hasArt = (i: MediaItem) => !!i.primaryImageTag;
|
||||
|
||||
describe("shuffle", () => {
|
||||
it("returns a permutation without mutating the input", () => {
|
||||
const input = [1, 2, 3, 4, 5];
|
||||
const copy = [...input];
|
||||
const result = shuffle(input);
|
||||
expect(input).toEqual(copy);
|
||||
expect([...result].sort()).toEqual([...input].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHeroMix", () => {
|
||||
it("returns empty for empty pools", () => {
|
||||
expect(buildHeroMix([[], []], hasArt)).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the rotation at count", () => {
|
||||
const pool = Array.from({ length: 20 }, (_, i) => item(`a${i}`));
|
||||
expect(buildHeroMix([pool], hasArt, 6)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("filters items without artwork", () => {
|
||||
const result = buildHeroMix([[item("a", false), item("b")]], hasArt);
|
||||
expect(result.map(i => i.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("de-duplicates across pools", () => {
|
||||
const a = item("a");
|
||||
const result = buildHeroMix([[a], [a, item("b")]], hasArt);
|
||||
const ids = result.map(i => i.id);
|
||||
expect(ids).toHaveLength(new Set(ids).size);
|
||||
expect(ids).toContain("a");
|
||||
expect(ids).toContain("b");
|
||||
});
|
||||
|
||||
it("leads with an item from the first non-empty pool", () => {
|
||||
const personal = [item("p1"), item("p2"), item("p3")];
|
||||
const rest = [item("r1"), item("r2"), item("r3")];
|
||||
for (let run = 0; run < 20; run++) {
|
||||
const result = buildHeroMix([personal, rest], hasArt);
|
||||
expect(["p1", "p2", "p3"]).toContain(result[0].id);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes at most perPool items per pool before backfilling", () => {
|
||||
const a = [item("a1"), item("a2"), item("a3"), item("a4")];
|
||||
const b = [item("b1"), item("b2"), item("b3"), item("b4")];
|
||||
// count 4 with perPool 2: exactly 2 from each pool, no backfill needed.
|
||||
for (let run = 0; run < 20; run++) {
|
||||
const result = buildHeroMix([a, b], hasArt, 4, 2);
|
||||
const fromA = result.filter(i => i.id.startsWith("a")).length;
|
||||
const fromB = result.filter(i => i.id.startsWith("b")).length;
|
||||
expect(fromA).toBe(2);
|
||||
expect(fromB).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("backfills from leftovers when samples fall short of count", () => {
|
||||
const a = [item("a1"), item("a2"), item("a3"), item("a4"), item("a5"), item("a6")];
|
||||
const result = buildHeroMix([a], hasArt, 6, 2);
|
||||
expect(result).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Hero banner mix builder, shared by the movies/TV/music landing stores.
|
||||
//
|
||||
// The hero used to show the first N items of fixed pools (continue watching,
|
||||
// recently played, latest), which made it identical on every visit. Instead we
|
||||
// sample a couple of items at random from each pool — pools are ordered most
|
||||
// personal first — and shuffle the rotation, so the banner is a fresh mix each
|
||||
// time while still leading with something personal.
|
||||
// TRACES: UR-034 | DR-038
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/** Fisher-Yates shuffle; returns a new array, input is untouched. */
|
||||
export function shuffle<T>(items: T[]): T[] {
|
||||
const result = [...items];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a varied hero rotation from ordered pools (most personal first).
|
||||
*
|
||||
* Takes up to `perPool` random items from each pool, de-duplicated by id and
|
||||
* filtered to items with usable artwork. The first pick from the first
|
||||
* non-empty pool leads the rotation (so a resumable/recent item greets the
|
||||
* user), the rest are shuffled. If that yields fewer than `count` items, the
|
||||
* remainder is backfilled from whatever is left across all pools.
|
||||
*/
|
||||
export function buildHeroMix(
|
||||
pools: MediaItem[][],
|
||||
hasArt: (item: MediaItem) => boolean,
|
||||
count = 6,
|
||||
perPool = 2
|
||||
): MediaItem[] {
|
||||
const seen = new Set<string>();
|
||||
const usable = pools.map(pool =>
|
||||
pool.filter(item => {
|
||||
if (!hasArt(item) || seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
const picked = new Set<string>();
|
||||
const picks: MediaItem[] = [];
|
||||
for (const pool of usable) {
|
||||
for (const item of shuffle(pool).slice(0, perPool)) {
|
||||
picked.add(item.id);
|
||||
picks.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (picks.length === 0) return [];
|
||||
|
||||
const [leader, ...rest] = picks;
|
||||
const leftovers = shuffle(usable.flat().filter(item => !picked.has(item.id)));
|
||||
return [leader, ...shuffle(rest), ...leftovers].slice(0, count);
|
||||
}
|
||||
Reference in New Issue
Block a user