many changes
Traceability Validation / Check Requirement Traces (push) Failing after 1m18s
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled

This commit is contained in:
2026-02-14 00:09:47 +01:00
parent 6d1c618a3a
commit e3797f32ca
74 changed files with 6718 additions and 771 deletions
@@ -0,0 +1,431 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, waitFor } from "@testing-library/svelte";
/**
* Integration tests for async image loading pattern used in components
*
* Pattern:
* - Component has $state<string> imageUrl = ""
* - Component has async loadImageUrl() function
* - Component uses $effect to call loadImageUrl when dependencies change
* - For lists: uses Map<string, string> to cache URLs per item
*/
// Mock repository with getImageUrl
const createMockRepository = () => ({
getImageUrl: vi.fn(),
});
describe("Async Image Loading Pattern", () => {
let mockRepository: any;
beforeEach(() => {
mockRepository = createMockRepository();
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllTimers();
});
describe("Single Image Loading", () => {
it("should load image URL asynchronously on component mount", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulating component with async image loading
const imageUrl = await mockRepository.getImageUrl("item123", "Primary");
expect(imageUrl).toBe("https://server.com/image.jpg");
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item123", "Primary");
});
it("should show placeholder while loading", async () => {
mockRepository.getImageUrl.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve("https://server.com/image.jpg"), 100))
);
vi.useFakeTimers();
const promise = mockRepository.getImageUrl("item123", "Primary");
// Initially no URL
expect(promise).toBeInstanceOf(Promise);
vi.advanceTimersByTime(100);
vi.useRealTimers();
const result = await promise;
expect(result).toBe("https://server.com/image.jpg");
});
it("should reload image when item changes", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image1.jpg");
const url1 = await mockRepository.getImageUrl("item1", "Primary");
expect(url1).toBe("https://server.com/image1.jpg");
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
const url2 = await mockRepository.getImageUrl("item2", "Primary");
expect(url2).toBe("https://server.com/image2.jpg");
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
it("should not reload image if item ID hasn't changed", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// First load
await mockRepository.getImageUrl("item123", "Primary");
// Would normally use $effect to track changes
// If item ID is same, should not reload (handled by component caching)
// This test documents the expected behavior
});
it("should handle load errors gracefully", async () => {
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
// Component should catch error and show placeholder
try {
await mockRepository.getImageUrl("item123", "Primary");
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
});
});
describe("List Image Caching (Map-based)", () => {
it("should cache URLs using Map<string, string>", () => {
// Simulating component state: imageUrls = $state<Map<string, string>>(new Map())
const imageUrls = new Map<string, string>();
// Load first item
imageUrls.set("item1", "https://server.com/image1.jpg");
expect(imageUrls.has("item1")).toBe(true);
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
// Load second item
imageUrls.set("item2", "https://server.com/image2.jpg");
expect(imageUrls.size).toBe(2);
// Check cache hit
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
});
it("should load images only once per item", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
const imageUrls = new Map<string, string>();
// Simulate loading multiple items
const items = [
{ id: "item1", name: "Album 1" },
{ id: "item2", name: "Album 2" },
{ id: "item1", name: "Album 1 (again)" }, // Same ID
];
for (const item of items) {
if (!imageUrls.has(item.id)) {
const url = await mockRepository.getImageUrl(item.id, "Primary");
imageUrls.set(item.id, url);
}
}
// Should only call once per unique ID
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
it("should update single item without affecting others", async () => {
const imageUrls = new Map<string, string>();
imageUrls.set("item1", "https://server.com/image1.jpg");
imageUrls.set("item2", "https://server.com/image2.jpg");
imageUrls.set("item3", "https://server.com/image3.jpg");
// Update item2
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2_updated.jpg");
const newUrl = await mockRepository.getImageUrl("item2", "Primary");
imageUrls.set("item2", newUrl);
// Others should remain unchanged
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
expect(imageUrls.get("item2")).toBe("https://server.com/image2_updated.jpg");
expect(imageUrls.get("item3")).toBe("https://server.com/image3.jpg");
});
it("should clear cache when data changes", () => {
const imageUrls = new Map<string, string>();
imageUrls.set("item1", "https://server.com/image1.jpg");
imageUrls.set("item2", "https://server.com/image2.jpg");
// Clear cache
imageUrls.clear();
expect(imageUrls.size).toBe(0);
expect(imageUrls.has("item1")).toBe(false);
});
it("should support Map operations efficiently", () => {
const imageUrls = new Map<string, string>();
// Add items
for (let i = 0; i < 100; i++) {
imageUrls.set(`item${i}`, `https://server.com/image${i}.jpg`);
}
expect(imageUrls.size).toBe(100);
// Check specific item
expect(imageUrls.has("item50")).toBe(true);
expect(imageUrls.get("item50")).toBe("https://server.com/image50.jpg");
// Iterate
let count = 0;
imageUrls.forEach(() => {
count++;
});
expect(count).toBe(100);
});
});
describe("Component Lifecycle ($effect integration)", () => {
it("should trigger load on prop change", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate $effect tracking prop changes
let effectCount = 0;
const trackingEffect = vi.fn(() => {
effectCount++;
return mockRepository.getImageUrl("item123", "Primary");
});
trackingEffect();
expect(effectCount).toBe(1);
trackingEffect();
expect(effectCount).toBe(2);
});
it("should skip load if conditions not met", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate conditional loading (e.g., if (!imageUrl && primaryImageTag))
let imageUrl = "";
const primaryImageTag = "";
if (!imageUrl && primaryImageTag) {
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
}
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
});
it("should handle dependent state updates", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate component state changes triggering effects
const state = {
item: { id: "item1", primaryImageTag: "tag1" },
imageUrl: "",
};
const loadImage = async () => {
if (state.item.primaryImageTag) {
state.imageUrl = await mockRepository.getImageUrl(state.item.id, "Primary");
}
};
await loadImage();
expect(state.imageUrl).toBe("https://server.com/image.jpg");
// Change item
state.item = { id: "item2", primaryImageTag: "tag2" };
state.imageUrl = "";
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
await loadImage();
expect(state.imageUrl).toBe("https://server.com/image2.jpg");
});
});
describe("Error Handling in Async Loading", () => {
it("should set empty string on error", async () => {
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
let imageUrl = "";
try {
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
} catch {
imageUrl = ""; // Set to empty on error
}
expect(imageUrl).toBe("");
});
it("should allow retry after error", async () => {
mockRepository.getImageUrl
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce("https://server.com/image.jpg");
let imageUrl = "";
// First attempt fails
try {
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
} catch {
imageUrl = "";
}
// Retry succeeds
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
expect(imageUrl).toBe("https://server.com/image.jpg");
});
it("should handle concurrent load requests", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate loading multiple images concurrently
const imageUrls = new Map<string, string>();
const items = [
{ id: "item1" },
{ id: "item2" },
{ id: "item3" },
];
const promises = items.map(item =>
mockRepository.getImageUrl(item.id, "Primary")
.then(url => imageUrls.set(item.id, url))
.catch(() => imageUrls.set(item.id, ""))
);
await Promise.all(promises);
expect(imageUrls.size).toBe(3);
expect(imageUrls.has("item1")).toBe(true);
expect(imageUrls.has("item2")).toBe(true);
expect(imageUrls.has("item3")).toBe(true);
});
});
describe("Performance Characteristics", () => {
it("should not reload unnecessarily", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate $effect with dependency tracking
let dependencyValue = "same";
let previousDependency = "same";
const loadImage = async () => {
if (dependencyValue !== previousDependency) {
previousDependency = dependencyValue;
return await mockRepository.getImageUrl("item123", "Primary");
}
};
await loadImage();
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
// No change in dependency
await loadImage();
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
// Change dependency
dependencyValue = "changed";
await loadImage();
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
it("should handle large lists efficiently", async () => {
const imageUrls = new Map<string, string>();
let loadCount = 0;
mockRepository.getImageUrl.mockImplementation(() => {
loadCount++;
return Promise.resolve("https://server.com/image.jpg");
});
// Simulate loading 1000 items but caching URLs
const items = Array.from({ length: 1000 }, (_, i) => ({ id: `item${i % 10}` }));
for (const item of items) {
if (!imageUrls.has(item.id)) {
const url = await mockRepository.getImageUrl(item.id, "Primary");
imageUrls.set(item.id, url);
}
}
// Should only load 10 unique images
expect(loadCount).toBe(10);
expect(imageUrls.size).toBe(10);
});
it("should not block rendering during async loading", () => {
mockRepository.getImageUrl.mockImplementation(
() => new Promise((resolve) =>
setTimeout(() => resolve("https://server.com/image.jpg"), 1000)
)
);
// Async operation should not block component rendering
const renderTiming = {
startRender: Date.now(),
loadStart: null as number | null,
loadComplete: null as number | null,
};
// Render happens immediately
renderTiming.startRender = Date.now();
// Load happens asynchronously
mockRepository.getImageUrl("item123", "Primary").then(() => {
renderTiming.loadComplete = Date.now();
});
// Render should complete before load finishes
expect(Date.now() - renderTiming.startRender).toBeLessThan(1000);
});
});
describe("Backend Integration", () => {
it("should call backend with correct parameters", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
await mockRepository.getImageUrl("item123", "Primary", {
maxWidth: 300,
});
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
"item123",
"Primary",
{
maxWidth: 300,
}
);
});
it("should handle backend URL correctly", async () => {
const backendUrl = "https://server.com/Items/item123/Images/Primary?maxWidth=300&api_key=token";
mockRepository.getImageUrl.mockResolvedValue(backendUrl);
const url = await mockRepository.getImageUrl("item123", "Primary", { maxWidth: 300 });
expect(url).toBe(backendUrl);
// Frontend never constructs URLs directly
expect(url).toContain("api_key=");
});
it("should not require URL construction in frontend", async () => {
// Frontend receives pre-constructed URL from backend
const preConstructedUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValue(preConstructedUrl);
const url = await mockRepository.getImageUrl("item123", "Primary");
// Frontend just uses the URL
expect(url).toContain("https://");
expect(url).toContain("item123");
});
});
});
+21 -5
View File
@@ -10,6 +10,9 @@
let { people, title = "Cast & Crew" }: Props = $props();
// Map of person IDs to their image URLs, loaded asynchronously
let personImageUrls = $state<Map<string, string>>(new Map());
// Group people by type
const groupedPeople = $derived.by(() => {
const groups: Record<string, Person[]> = {
@@ -58,18 +61,31 @@
}
}
function getPersonImageUrl(person: Person): string {
// Load image URL for a single person
async function loadPersonImageUrl(person: Person): Promise<void> {
if (!person.primaryImageTag || personImageUrls.has(person.id)) return;
try {
const repo = auth.getRepository();
return repo.getImageUrl(person.id, "Primary", {
const url = await repo.getImageUrl(person.id, "Primary", {
maxWidth: 200,
tag: person.primaryImageTag,
});
personImageUrls.set(person.id, url);
} catch {
return "";
personImageUrls.set(person.id, "");
}
}
// Load image URLs for all people
$effect(() => {
people.forEach((person) => {
if (person.primaryImageTag && !personImageUrls.has(person.id)) {
loadPersonImageUrl(person);
}
});
});
function handlePersonClick(person: Person) {
goto(`/library/${person.id}`);
}
@@ -94,9 +110,9 @@
>
<!-- Person image -->
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
{#if person.primaryImageTag}
{#if person.primaryImageTag && personImageUrls.get(person.id)}
<img
src={getPersonImageUrl(person)}
src={personImageUrls.get(person.id)}
alt={person.name}
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
loading="lazy"
@@ -12,6 +12,9 @@
let { episode, series, allEpisodes, onBack }: Props = $props();
let backdropUrl = $state<string>("");
let episodeThumbnailUrls = $state<Map<string, string>>(new Map());
// Check if an episode matches the focused episode (by ID or season/episode number)
function isCurrentEpisode(ep: MediaItem): boolean {
if (ep.id === episode.id) return true;
@@ -70,52 +73,74 @@
return allEpisodes.slice(start, end);
});
function getBackdropUrl(): string {
// Load backdrop URL asynchronously
async function loadBackdropUrl(): Promise<void> {
try {
const repo = auth.getRepository();
// Try episode backdrop first
if (episode.backdropImageTags?.[0]) {
return repo.getImageUrl(episode.id, "Backdrop", {
backdropUrl = await repo.getImageUrl(episode.id, "Backdrop", {
maxWidth: 1920,
tag: episode.backdropImageTags[0],
});
return;
}
// Try episode primary (thumbnail)
if (episode.primaryImageTag) {
return repo.getImageUrl(episode.id, "Primary", {
backdropUrl = await repo.getImageUrl(episode.id, "Primary", {
maxWidth: 1920,
tag: episode.primaryImageTag,
});
return;
}
// Fall back to series backdrop
if (series.backdropImageTags?.[0]) {
return repo.getImageUrl(series.id, "Backdrop", {
backdropUrl = await repo.getImageUrl(series.id, "Backdrop", {
maxWidth: 1920,
tag: series.backdropImageTags[0],
});
return;
}
return "";
backdropUrl = "";
} catch {
return "";
backdropUrl = "";
}
}
function getEpisodeThumbnail(ep: MediaItem): string {
// Load episode thumbnail URL for a single episode
async function loadEpisodeThumbnailUrl(ep: MediaItem): Promise<void> {
if (!ep.primaryImageTag || episodeThumbnailUrls.has(ep.id)) return;
try {
const repo = auth.getRepository();
return repo.getImageUrl(ep.id, "Primary", {
const url = await repo.getImageUrl(ep.id, "Primary", {
maxWidth: 400,
tag: ep.primaryImageTag,
});
episodeThumbnailUrls.set(ep.id, url);
} catch {
return "";
episodeThumbnailUrls.set(ep.id, "");
}
}
// Load backdrop when episode changes
$effect(() => {
loadBackdropUrl();
});
// Load episode thumbnail URLs when adjacent episodes change
$effect(() => {
adjacentEpisodes().forEach((ep) => {
if (ep.primaryImageTag && !episodeThumbnailUrls.has(ep.id)) {
loadEpisodeThumbnailUrl(ep);
}
});
});
function formatDuration(ticks?: number): string {
if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000);
@@ -143,7 +168,6 @@
goto(`/library/${series.id}?episode=${ep.id}`);
}
const backdropUrl = $derived(getBackdropUrl());
const episodeLabel = $derived(
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
);
@@ -264,7 +288,7 @@
{#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)}
{@const thumbUrl = getEpisodeThumbnail(ep)}
{@const thumbUrl = episodeThumbnailUrls.get(ep.id) ?? ""}
<button
onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
+15 -21
View File
@@ -3,6 +3,7 @@
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
import VideoDownloadButton from "./VideoDownloadButton.svelte";
interface Props {
@@ -14,6 +15,7 @@
let { episode, focused = false, onclick }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
let imageUrl = $state<string>("");
onMount(() => {
if (focused && buttonRef) {
@@ -35,39 +37,31 @@
);
const downloadProgress = $derived(downloadInfo?.progress || 0);
function getImageUrl(): string {
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
return repo.getImageUrl(episode.id, "Primary", {
imageUrl = await repo.getImageUrl(episode.id, "Primary", {
maxWidth: 320,
tag: episode.primaryImageTag,
});
} catch {
return "";
imageUrl = "";
}
}
function getProgress(): number {
// Load image when episode changes
$effect(() => {
loadImageUrl();
});
const progress = $derived(() => {
if (!episode.userData || !episode.runTimeTicks) {
return 0;
}
return (episode.userData.playbackPositionTicks / episode.runTimeTicks) * 100;
}
});
function formatDuration(ticks?: number): string {
if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
const imageUrl = $derived(getImageUrl());
const progress = $derived(getProgress());
const duration = $derived(formatDuration(episode.runTimeTicks));
const episodeNumber = $derived(episode.indexNumber || 0);
</script>
@@ -107,11 +101,11 @@
</div>
<!-- Progress bar -->
{#if progress > 0}
{#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress}%"
style="width: {progress()}%"
></div>
</div>
{/if}
@@ -41,6 +41,7 @@
let selectedGenre = $state<Genre | null>(null);
let genreItems = $state<MediaItem[]>([]);
let loadingItems = $state(false);
let genreItemImageUrls = $state<Map<string, string>>(new Map());
const { markLoaded } = useServerReachabilityReload(async () => {
await loadGenres();
@@ -79,6 +80,7 @@
try {
loadingItems = true;
selectedGenre = genre;
genreItemImageUrls = new Map(); // Clear image URLs when loading new genre
const repo = auth.getRepository();
const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: config.itemTypes,
@@ -96,6 +98,31 @@
}
}
// Load image URL for a single item
async function loadGenreItemImage(item: MediaItem): Promise<void> {
if (!item.primaryImageTag || genreItemImageUrls.has(item.id)) return;
try {
const repo = auth.getRepository();
const url = await repo.getImageUrl(item.id, "Primary", {
maxWidth: 300,
tag: item.primaryImageTag,
});
genreItemImageUrls.set(item.id, url);
} catch {
genreItemImageUrls.set(item.id, "");
}
}
// Load image URLs for all genre items
$effect(() => {
genreItems.forEach((item) => {
if (item.primaryImageTag && !genreItemImageUrls.has(item.id)) {
loadGenreItemImage(item);
}
});
});
function applyFilter() {
let result = [...genres];
@@ -217,12 +244,9 @@
{#each genreItems as item (item.id)}
<button onclick={() => handleItemClick(item)} class="group text-left">
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
{#if item.primaryImageTag}
{#if item.primaryImageTag && genreItemImageUrls.get(item.id)}
<img
src={auth.getRepository().getImageUrl(item.id, "Primary", {
maxWidth: 300,
tag: item.primaryImageTag,
})}
src={genreItemImageUrls.get(item.id)}
alt={item.name}
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
@@ -30,7 +30,6 @@
sortOptions: Array<{ key: string; label: string }>; // Jellyfin field names
defaultSort: string; // Jellyfin field name (e.g., "SortName")
displayComponent: "grid" | "tracklist"; // Which component to use
searchFields: string[]; // Which fields to search in: ["name", "artists"], etc.
}
interface Props {
@@ -42,8 +41,10 @@
let items = $state<MediaItem[]>([]);
let loading = $state(true);
let searchQuery = $state("");
let debouncedSearchQuery = $state("");
let sortBy = $state<string>(config.defaultSort);
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
const { markLoaded } = useServerReachabilityReload(async () => {
await loadItems();
@@ -65,8 +66,8 @@
const repo = auth.getRepository();
// Use backend search if search query is provided, otherwise use getItems with sort
if (searchQuery.trim()) {
const result = await repo.search(searchQuery, {
if (debouncedSearchQuery.trim()) {
const result = await repo.search(debouncedSearchQuery, {
includeItemTypes: [config.itemType],
limit: 10000,
});
@@ -90,9 +91,18 @@
function handleSearch(query: string) {
searchQuery = query;
loadItems();
}
// Debounce search input (300ms delay)
$effect(() => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
debouncedSearchQuery = searchQuery;
loadItems();
}, 300);
});
function handleSort(newSort: string) {
sortBy = newSort;
loadItems();
@@ -0,0 +1,661 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import GenericMediaListPage from "./GenericMediaListPage.svelte";
// Mock SvelteKit navigation
vi.mock("$app/navigation", () => ({
goto: vi.fn(),
}));
// Mock stores
vi.mock("$lib/stores/library", () => ({
currentLibrary: {
subscribe: vi.fn((fn) => {
fn({ id: "lib123", name: "Music" });
return vi.fn();
}),
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getRepository: vi.fn(() => ({
getItems: vi.fn(),
search: vi.fn(),
})),
},
}));
vi.mock("$lib/composables/useServerReachabilityReload", () => ({
useServerReachabilityReload: vi.fn(() => ({
markLoaded: vi.fn(),
})),
}));
describe("GenericMediaListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllTimers();
});
describe("Component Initialization", () => {
it("should render with title and search bar", () => {
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
const { container } = render(GenericMediaListPage, {
props: { config },
});
const heading = screen.getByText("Tracks");
expect(heading).toBeTruthy();
const searchInput = container.querySelector('input[type="text"]');
expect(searchInput).toBeTruthy();
});
it("should load items on mount", async () => {
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
render(GenericMediaListPage, {
props: { config },
});
await waitFor(() => {
// loadItems should have been called
});
});
it("should display sort options", () => {
const config = {
itemType: "MusicAlbum",
title: "Albums",
backPath: "/library/music",
searchPlaceholder: "Search albums...",
sortOptions: [
{ key: "SortName", label: "Title" },
{ key: "Artist", label: "Artist" },
{ key: "ProductionYear", label: "Year" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
render(GenericMediaListPage, {
props: { config },
});
// Check that all sort options are rendered
const titleOption = screen.queryByText("Title");
expect(titleOption).toBeTruthy();
});
});
describe("Search Functionality", () => {
it("should debounce search input for 300ms", async () => {
vi.useFakeTimers();
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
const { container } = render(GenericMediaListPage, {
props: { config },
});
const searchInput = container.querySelector("input") as HTMLInputElement;
// Type into search
fireEvent.input(searchInput, { target: { value: "t" } });
expect(searchInput.value).toBe("t");
// Search should not trigger immediately
vi.advanceTimersByTime(100);
// Add more characters
fireEvent.input(searchInput, { target: { value: "test" } });
// Still shouldn't trigger (only 100ms passed total)
vi.advanceTimersByTime(100);
// Now advance to 300ms total - search should trigger
vi.advanceTimersByTime(100);
await waitFor(() => {
// Search should have been debounced
});
vi.useRealTimers();
});
it("should use backend search when search query is provided", async () => {
const mockSearchFn = vi.fn().mockResolvedValue({
items: [{ id: "item1", name: "Test Track" }],
totalRecordCount: 1,
});
const mockRepository = {
getItems: vi.fn(),
search: mockSearchFn,
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
vi.useFakeTimers();
const { container } = render(GenericMediaListPage, {
props: { config },
});
const searchInput = container.querySelector("input") as HTMLInputElement;
fireEvent.input(searchInput, { target: { value: "test" } });
// Advance timer to trigger debounced search
vi.advanceTimersByTime(300);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("test", expect.objectContaining({
includeItemTypes: ["Audio"],
limit: 10000,
}));
});
vi.useRealTimers();
});
it("should use getItems without search for empty query", async () => {
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: mockGetItemsFn,
search: vi.fn(),
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
render(GenericMediaListPage, {
props: { config },
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
includeItemTypes: ["Audio"],
sortBy: "SortName",
sortOrder: "Ascending",
}));
});
});
it("should clear previous search when input becomes empty", async () => {
vi.useFakeTimers();
const mockSearchFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: mockGetItemsFn,
search: mockSearchFn,
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
const { container } = render(GenericMediaListPage, {
props: { config },
});
const searchInput = container.querySelector("input") as HTMLInputElement;
// Type search query
fireEvent.input(searchInput, { target: { value: "test" } });
vi.advanceTimersByTime(300);
// Clear search
fireEvent.input(searchInput, { target: { value: "" } });
vi.advanceTimersByTime(300);
await waitFor(() => {
// Should call getItems when search is cleared
expect(mockGetItemsFn).toHaveBeenCalled();
});
vi.useRealTimers();
});
});
describe("Sorting Functionality", () => {
it("should pass sortBy parameter to backend", async () => {
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: mockGetItemsFn,
search: vi.fn(),
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "MusicAlbum",
title: "Albums",
backPath: "/library/music",
searchPlaceholder: "Search albums...",
sortOptions: [
{ key: "SortName", label: "Title" },
{ key: "Artist", label: "Artist" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
render(GenericMediaListPage, {
props: { config },
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
sortBy: "SortName",
sortOrder: "Ascending",
}));
});
});
it("should pass Jellyfin field names to backend (not custom compareFn)", async () => {
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: mockGetItemsFn,
search: vi.fn(),
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [
{ key: "SortName", label: "Title" },
{ key: "Artist", label: "Artist" },
{ key: "Album", label: "Album" },
{ key: "DatePlayed", label: "Recent" },
],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
render(GenericMediaListPage, {
props: { config },
});
await waitFor(() => {
const lastCall = (mockGetItemsFn as any).mock.calls[0];
const options = lastCall[1];
// Should pass Jellyfin field names directly
expect(typeof options.sortBy).toBe("string");
expect(["SortName", "Artist", "Album", "DatePlayed"]).toContain(options.sortBy);
});
});
});
describe("ItemType Filtering", () => {
it("should include correct itemType in getItems request", async () => {
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: mockGetItemsFn,
search: vi.fn(),
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
render(GenericMediaListPage, {
props: { config },
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
includeItemTypes: ["Audio"],
}));
});
});
it("should include correct itemType in search request", async () => {
vi.useFakeTimers();
const mockSearchFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: vi.fn(),
search: mockSearchFn,
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "MusicAlbum",
title: "Albums",
backPath: "/library/music",
searchPlaceholder: "Search albums...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
const { container } = render(GenericMediaListPage, {
props: { config },
});
const searchInput = container.querySelector("input") as HTMLInputElement;
fireEvent.input(searchInput, { target: { value: "album" } });
vi.advanceTimersByTime(300);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("album", expect.objectContaining({
includeItemTypes: ["MusicAlbum"],
}));
});
vi.useRealTimers();
});
});
describe("Loading State", () => {
it("should show loading indicator during data fetch", async () => {
const mockGetItemsFn = vi.fn(
() => new Promise((resolve) => setTimeout(
() => resolve({ items: [], totalRecordCount: 0 }),
100
))
);
const mockRepository = {
getItems: mockGetItemsFn,
search: vi.fn(),
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
vi.useFakeTimers();
const { container } = render(GenericMediaListPage, {
props: { config },
});
// Component should be rendering (will show loading state internally)
expect(container).toBeTruthy();
vi.advanceTimersByTime(100);
vi.useRealTimers();
});
});
describe("Error Handling", () => {
it("should handle backend errors gracefully", async () => {
const mockGetItemsFn = vi.fn().mockRejectedValue(new Error("Network error"));
const mockRepository = {
getItems: mockGetItemsFn,
search: vi.fn(),
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
render(GenericMediaListPage, {
props: { config },
});
await waitFor(() => {
// Should handle error without throwing
expect(mockGetItemsFn).toHaveBeenCalled();
});
});
it("should handle missing library gracefully", async () => {
const { goto } = await import("$app/navigation");
const mockGetItemsFn = vi.fn();
const mockRepository = {
getItems: mockGetItemsFn,
search: vi.fn(),
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
);
// Mock currentLibrary to return null
vi.resetModules();
vi.mocked((await import("$lib/stores/library")).currentLibrary.subscribe).mockImplementation(
(fn: any) => {
fn(null);
return vi.fn();
}
);
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
render(GenericMediaListPage, {
props: { config },
});
// Should navigate to back path when library is missing
await waitFor(() => {
// goto would be called with config.backPath
});
});
});
describe("Display Component Props", () => {
it("should support grid display component", () => {
const config = {
itemType: "MusicAlbum",
title: "Albums",
backPath: "/library/music",
searchPlaceholder: "Search albums...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
const { container } = render(GenericMediaListPage, {
props: { config },
});
expect(container).toBeTruthy();
});
it("should support tracklist display component", () => {
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
const { container } = render(GenericMediaListPage, {
props: { config },
});
expect(container).toBeTruthy();
});
});
describe("Config Simplification", () => {
it("should not require searchFields in config", () => {
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
// Note: searchFields is NOT present
};
// Should render without searchFields
expect(() => {
render(GenericMediaListPage, {
props: { config },
});
}).not.toThrow();
});
it("should not require compareFn in sort options", () => {
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks...",
sortOptions: [
{ key: "SortName", label: "Title" },
// Note: no compareFn property
],
defaultSort: "SortName",
displayComponent: "tracklist" as const,
};
// Should render without compareFn in sort options
expect(() => {
render(GenericMediaListPage, {
props: { config },
});
}).not.toThrow();
});
});
});
@@ -13,23 +13,37 @@
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
// Map of item IDs to their image URLs, loaded asynchronously
let imageUrls = $state<Map<string, string>>(new Map());
function getDownloadInfo(itemId: string) {
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
}
function getImageUrl(item: MediaItem | Library): string {
// Load image URL for a single item
async function loadImageUrl(item: MediaItem | Library): Promise<void> {
try {
const repo = auth.getRepository();
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
return repo.getImageUrl(item.id, "Primary", {
const url = await repo.getImageUrl(item.id, "Primary", {
maxWidth: 80,
tag,
});
imageUrls.set(item.id, url);
} catch {
return "";
imageUrls.set(item.id, "");
}
}
// Load image URLs whenever items change
$effect(() => {
items.forEach((item) => {
if (!imageUrls.has(item.id)) {
loadImageUrl(item);
}
});
});
function getSubtitle(item: MediaItem | Library): string {
if (!("type" in item)) return "";
@@ -66,7 +80,7 @@
<div class="space-y-1">
{#each items as item, index (item.id)}
{@const imageUrl = getImageUrl(item)}
{@const imageUrl = imageUrls.get(item.id) ?? ""}
{@const subtitle = getSubtitle(item)}
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
{@const progress = getProgress(item)}
+21 -20
View File
@@ -2,7 +2,6 @@
import type { MediaItem, Library } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads";
import { getImageUrlSync } from "$lib/services/imageCache";
interface Props {
item: MediaItem | Library;
@@ -14,6 +13,9 @@
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
// Image URL state - loaded asynchronously
let imageUrl = $state<string>("");
// Check if this item is downloaded
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
@@ -40,32 +42,35 @@
return "aspect-video";
});
function getImageUrl(): string {
// Load image URL asynchronously from backend
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
const serverUrl = repo.serverUrl;
const id = item.id;
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
// Use the caching service - returns server URL immediately and triggers background caching
return getImageUrlSync(serverUrl, id, "Primary", {
imageUrl = await repo.getImageUrl(item.id, "Primary", {
maxWidth,
tag,
});
} catch {
return "";
imageUrl = "";
}
}
function getProgress(): number {
// Load image URL whenever item or size changes
$effect(() => {
loadImageUrl();
});
const progress = $derived(() => {
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
return 0;
}
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100;
}
});
function getSubtitle(): string {
const subtitle = $derived(() => {
if (!("type" in item)) return "";
switch (item.type) {
@@ -82,11 +87,7 @@
default:
return "";
}
}
const imageUrl = $derived(getImageUrl());
const progress = $derived(getProgress());
const subtitle = $derived(getSubtitle());
});
</script>
<button
@@ -122,11 +123,11 @@
</div>
<!-- Progress bar -->
{#if progress > 0}
{#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress}%"
style="width: {progress()}%"
></div>
</div>
{/if}
@@ -188,8 +189,8 @@
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
{item.name}
</p>
{#if subtitle}
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
{#if subtitle()}
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
{/if}
</div>
</button>
@@ -0,0 +1,359 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/svelte";
import MediaCard from "./MediaCard.svelte";
vi.mock("$lib/stores/auth", () => ({
auth: {
getRepository: vi.fn(() => ({
getImageUrl: vi.fn(),
})),
},
}));
describe("MediaCard - Async Image Loading", () => {
let mockRepository: any;
beforeEach(() => {
vi.clearAllMocks();
mockRepository = {
getImageUrl: vi.fn(),
};
vi.mocked((global as any).__stores_auth?.auth?.getRepository).mockReturnValue(mockRepository);
});
afterEach(() => {
vi.clearAllTimers();
});
describe("Image Loading", () => {
it("should load image URL asynchronously", async () => {
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
primaryImageTag: "abc123",
};
const { container } = render(MediaCard, {
props: { item: mediaItem },
});
// Component should render immediately with placeholder
expect(container).toBeTruthy();
// Wait for image URL to load
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
"item123",
"Primary",
expect.objectContaining({
maxWidth: 300,
})
);
});
});
it("should show placeholder while image is loading", async () => {
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockImageUrl), 100))
);
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
primaryImageTag: "abc123",
};
const { container } = render(MediaCard, {
props: { item: mediaItem },
});
// Placeholder should be visible initially
const placeholder = container.querySelector(".placeholder");
if (placeholder) {
expect(placeholder).toBeTruthy();
}
// Wait for image to load
vi.useFakeTimers();
vi.advanceTimersByTime(100);
vi.useRealTimers();
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalled();
});
});
it("should update image URL when item changes", async () => {
const mockImageUrl1 = "https://server.com/Items/item1/Images/Primary?api_key=token";
const mockImageUrl2 = "https://server.com/Items/item2/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValueOnce(mockImageUrl1);
const mediaItem1 = {
id: "item1",
name: "Album 1",
type: "MusicAlbum",
primaryImageTag: "tag1",
};
const { rerender } = render(MediaCard, {
props: { item: mediaItem1 },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item1", "Primary", expect.any(Object));
});
// Change item
mockRepository.getImageUrl.mockResolvedValueOnce(mockImageUrl2);
const mediaItem2 = {
id: "item2",
name: "Album 2",
type: "MusicAlbum",
primaryImageTag: "tag2",
};
await rerender({ item: mediaItem2 });
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item2", "Primary", expect.any(Object));
});
});
it("should not reload image if item ID hasn't changed", async () => {
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
primaryImageTag: "abc123",
};
const { rerender } = render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
// Rerender with same item
await rerender({ item: mediaItem });
// Should not call getImageUrl again
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
it("should handle missing primary image tag gracefully", async () => {
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
// primaryImageTag is undefined
};
const { container } = render(MediaCard, {
props: { item: mediaItem },
});
// Should render without calling getImageUrl
await waitFor(() => {
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
});
// Should show placeholder
expect(container).toBeTruthy();
});
it("should handle image load errors gracefully", async () => {
mockRepository.getImageUrl.mockRejectedValue(new Error("Failed to load image"));
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
primaryImageTag: "abc123",
};
const { container } = render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalled();
});
// Should still render without crashing
expect(container).toBeTruthy();
});
});
describe("Image Options", () => {
it("should pass correct options to getImageUrl", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
primaryImageTag: "abc123",
};
render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
"item123",
"Primary",
{
maxWidth: 300,
}
);
});
});
it("should include tag in image options when available", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
primaryImageTag: "tag123",
};
render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
"item123",
"Primary",
{
maxWidth: 300,
}
);
});
});
});
describe("Caching", () => {
it("should cache image URLs to avoid duplicate requests", async () => {
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
primaryImageTag: "abc123",
};
// Render same item multiple times
const { rerender } = render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
// Rerender with same item
await rerender({ item: mediaItem });
// Should still only have called once (cached)
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
it("should have separate cache entries for different items", async () => {
const mockImageUrl1 = "https://server.com/Items/item1/Images/Primary?api_key=token";
const mockImageUrl2 = "https://server.com/Items/item2/Images/Primary?api_key=token";
let callCount = 0;
mockRepository.getImageUrl.mockImplementation(() => {
callCount++;
return Promise.resolve(callCount === 1 ? mockImageUrl1 : mockImageUrl2);
});
const item1 = {
id: "item1",
name: "Album 1",
type: "MusicAlbum",
primaryImageTag: "tag1",
};
const item2 = {
id: "item2",
name: "Album 2",
type: "MusicAlbum",
primaryImageTag: "tag2",
};
const { rerender } = render(MediaCard, {
props: { item: item1 },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
await rerender({ item: item2 });
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
// Change back to item 1 - should use cached value
await rerender({ item: item1 });
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
});
describe("Reactive Updates", () => {
it("should respond to property changes via $effect", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum",
primaryImageTag: "abc123",
};
const { rerender } = render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalled();
});
const previousCallCount = mockRepository.getImageUrl.mock.calls.length;
// Update a property that shouldn't trigger reload
await rerender({
item: {
...mediaItem,
name: "Updated Album Name",
},
});
// Should not call getImageUrl again (same primaryImageTag)
expect(mockRepository.getImageUrl.mock.calls.length).toBe(previousCallCount);
});
});
});
@@ -14,6 +14,7 @@
let movies = $state<MediaItem[]>([]);
let series = $state<MediaItem[]>([]);
let loading = $state(true);
let imageUrl = $state<string>("");
onMount(async () => {
await loadFilmography();
@@ -38,23 +39,27 @@
}
}
function getImageUrl(): string {
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
return repo.getImageUrl(person.id, "Primary", {
imageUrl = await repo.getImageUrl(person.id, "Primary", {
maxWidth: 400,
tag: person.primaryImageTag,
});
} catch {
return "";
imageUrl = "";
}
}
// Load image when person changes
$effect(() => {
loadImageUrl();
});
function handleItemClick(item: MediaItem) {
goto(`/library/${item.id}`);
}
const imageUrl = $derived(getImageUrl());
</script>
<div class="space-y-8">
@@ -13,19 +13,26 @@
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
function getImageUrl(): string {
let imageUrl = $state<string>("");
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
return repo.getImageUrl(season.id, "Primary", {
imageUrl = await repo.getImageUrl(season.id, "Primary", {
maxWidth: 200,
tag: season.primaryImageTag,
});
} catch {
return "";
imageUrl = "";
}
}
const imageUrl = $derived(getImageUrl());
// Load image when season changes
$effect(() => {
loadImageUrl();
});
const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
const seasonName = $derived(