Files
jellytau/src/lib/components/library/TrackList.test.ts
T
dtourolle 2a1f1689b4
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
Layout and search fix
2026-07-11 19:55:55 +02:00

556 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock modules BEFORE any imports that might use them
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-os", () => ({
platform: vi.fn(() => "linux"),
}));
vi.mock("$lib/api/client", () => {
return {
default: class {
static getDeviceName = () => "test-device";
},
};
});
vi.mock("$lib/stores/auth", () => ({
auth: {
getRepository: vi.fn(),
},
}));
vi.mock("$lib/stores/queue", () => ({
queue: {
setQueue: vi.fn(),
addToQueue: vi.fn(),
},
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
}));
vi.mock("./DownloadButton.svelte", () => ({
default: vi.fn(() => ({ $$: {}, $set: vi.fn(), $on: vi.fn(), $destroy: vi.fn() })),
}));
vi.mock("$lib/stores/library", () => ({
library: {
loadLibraries: vi.fn(),
loadItems: vi.fn(),
loadItem: vi.fn(),
setCurrentLibrary: vi.fn(),
},
libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) },
}));
// Now import the modules after mocks are set up
import { render, fireEvent, waitFor } from "@testing-library/svelte";
import { invoke } from "@tauri-apps/api/core";
import TrackList from "./TrackList.svelte";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast";
describe("TrackList", () => {
const mockRepository = {
getAudioStreamUrl: vi.fn(),
getImageUrl: vi.fn(),
getHandle: vi.fn(() => "mock-repository-handle"),
};
const mockTracks: MediaItem[] = [
{
id: "track-1",
name: "Song 1",
type: "Audio",
serverId: "server-1",
artists: ["Artist 1"],
albumName: "Album 1",
albumId: "album-1",
runTimeTicks: 1800000000, // 3 minutes
primaryImageTag: "tag1",
indexNumber: 1,
},
{
id: "track-2",
name: "Song 2",
type: "Audio",
serverId: "server-1",
artists: ["Artist 2"],
albumName: "Album 2",
albumId: "album-2",
runTimeTicks: 2400000000, // 4 minutes
primaryImageTag: "tag2",
indexNumber: 2,
},
{
id: "track-3",
name: "Song 3 with a Very Long Name That Should Be Truncated",
type: "Audio",
serverId: "server-1",
artists: ["Artist 3", "Artist 4"],
albumName: "Album 3",
albumId: "album-3",
runTimeTicks: 3000000000, // 5 minutes
indexNumber: 3,
},
];
beforeEach(() => {
vi.clearAllMocks();
(auth.getRepository as any).mockReturnValue(mockRepository as any);
mockRepository.getAudioStreamUrl.mockResolvedValue("http://stream.url/track");
mockRepository.getImageUrl.mockReturnValue("http://image.url/artwork");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Rendering Tests", () => {
it("renders track list with tracks", () => {
// Component renders both desktop and mobile views, so use getAllByText
const { getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
expect(getAllByText("Song 2").length).toBeGreaterThan(0);
// Long names are abbreviated in the middle via truncateMiddle(name, 48).
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(0);
});
it("shows loading skeleton when loading=true", () => {
const { container } = render(TrackList, {
props: { tracks: [], loading: true },
});
const skeletons = container.querySelectorAll(".animate-pulse");
expect(skeletons.length).toBeGreaterThan(0);
});
it("shows empty state when no tracks", () => {
const { getByText } = render(TrackList, { props: { tracks: [] } });
expect(getByText("No tracks found")).toBeTruthy();
});
it("shows artist column by default", () => {
const { getByText, getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
// Header only exists in the desktop table.
expect(getByText("Artist")).toBeTruthy();
// Artist name renders in both desktop and mobile views.
expect(getAllByText("Artist 1").length).toBeGreaterThan(0);
});
it("hides artist column when showArtist=false", () => {
const { queryByText } = render(TrackList, {
props: { tracks: mockTracks, showArtist: false },
});
// Header should not be present
expect(queryByText("Artist")).toBeFalsy();
});
it("shows album column by default", () => {
const { getByText, getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
// Header only exists in the desktop table.
expect(getByText("Album")).toBeTruthy();
// Album name renders in both desktop and mobile views.
expect(getAllByText("Album 1").length).toBeGreaterThan(0);
});
it("hides album column when showAlbum=false", () => {
const { queryByText } = render(TrackList, {
props: { tracks: mockTracks, showAlbum: false },
});
// Header should not be present
expect(queryByText("Album")).toBeFalsy();
});
it("shows duration in correct format", () => {
// Component renders both desktop and mobile views
const { getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
expect(getAllByText("3:00").length).toBeGreaterThan(0); // track-1: 3 minutes
expect(getAllByText("4:00").length).toBeGreaterThan(0); // track-2: 4 minutes
expect(getAllByText("5:00").length).toBeGreaterThan(0); // track-3: 5 minutes
});
it("handles tracks without duration", () => {
const tracksWithoutDuration: MediaItem[] = [
{
...mockTracks[0],
runTimeTicks: undefined,
},
];
// formatDuration(undefined) renders an empty string, so the row still
// renders without crashing and the track title is present.
const { getAllByText } = render(TrackList, {
props: { tracks: tracksWithoutDuration },
});
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
});
it("handles tracks without artist", () => {
const tracksWithoutArtist: MediaItem[] = [
{
...mockTracks[0],
artists: undefined,
artistItems: undefined,
},
];
// The artist fallback renders "-" in both desktop and mobile views.
const { getAllByText } = render(TrackList, {
props: { tracks: tracksWithoutArtist },
});
expect(getAllByText("-").length).toBeGreaterThan(0);
});
it("renders multiple artists joined with comma", () => {
// Tracks fall back to artists.join(", ") when artistItems is absent;
// the joined string renders in both desktop and mobile views.
const { getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
expect(getAllByText("Artist 3, Artist 4").length).toBeGreaterThan(0);
});
});
describe("Default Click Handler Tests", () => {
it("calls player_play_queue when track is clicked", async () => {
const invokeMock = (invoke as any).mockResolvedValue(undefined);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
// Find and click the first track button
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
expect(firstTrackButton).toBeTruthy();
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith(
"player_play_tracks",
expect.objectContaining({
repositoryHandle: "mock-repository-handle",
request: expect.objectContaining({
trackIds: expect.arrayContaining(["track-1"]),
startIndex: 0,
shuffle: false,
}),
})
);
});
});
it("builds queue with all tracks in order", async () => {
const invokeMock = (invoke as any).mockResolvedValue(undefined);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const secondTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 2")
);
await fireEvent.click(secondTrackButton!);
await waitFor(() => {
const callArgs = invokeMock.mock.calls[0][1] as any;
expect(callArgs.request.trackIds).toEqual(["track-1", "track-2", "track-3"]);
});
});
it("sets correct startIndex for clicked track", async () => {
const invokeMock = (invoke as any).mockResolvedValue(undefined);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const thirdTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 3")
);
await fireEvent.click(thirdTrackButton!);
await waitFor(() => {
const callArgs = invokeMock.mock.calls[0][1] as any;
expect(callArgs.request.startIndex).toBe(2);
});
});
it("shows error toast when playback fails", async () => {
const toastSpy = vi.spyOn(toast, "error").mockImplementation(() => "");
(invoke as any).mockRejectedValue(new Error("Network error"));
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"),
expect.anything()
);
});
toastSpy.mockRestore();
});
it("handles auth errors gracefully", async () => {
const toastSpy = vi.spyOn(toast, "error").mockImplementation(() => "");
// No repository → requireHandle() throws "No repository available",
// which the default handler surfaces via toast.error.
(auth.getRepository as any).mockReturnValue(null as any);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"),
expect.anything()
);
});
toastSpy.mockRestore();
// Restore mock for other tests
(auth.getRepository as any).mockReturnValue(mockRepository as any);
});
});
describe("Custom Callback Tests", () => {
it("calls custom callback when provided", async () => {
const onTrackClick = vi.fn();
const { container } = render(TrackList, {
props: { tracks: mockTracks, onTrackClick },
});
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(onTrackClick).toHaveBeenCalledWith(mockTracks[0], 0);
});
});
it("does not call player_play_queue when custom callback provided", async () => {
const onTrackClick = vi.fn();
const invokeMock = (invoke as any);
const { container } = render(TrackList, {
props: { tracks: mockTracks, onTrackClick },
});
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(onTrackClick).toHaveBeenCalled();
});
expect(invokeMock).not.toHaveBeenCalled();
});
it("receives correct track and index in callback", async () => {
const onTrackClick = vi.fn();
const { container } = render(TrackList, {
props: { tracks: mockTracks, onTrackClick },
});
const buttons = container.querySelectorAll("button");
const secondTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 2")
);
await fireEvent.click(secondTrackButton!);
await waitFor(() => {
expect(onTrackClick).toHaveBeenCalledWith(mockTracks[1], 1);
});
});
it("handles async custom callbacks", async () => {
const onTrackClick = vi.fn().mockResolvedValue(undefined);
const { container } = render(TrackList, {
props: { tracks: mockTracks, onTrackClick },
});
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(onTrackClick).toHaveBeenCalled();
});
});
it("calls custom callback even when it might throw", async () => {
// Test that custom callbacks are called - error handling is caller's responsibility
const onTrackClick = vi.fn().mockResolvedValue(undefined);
const { container } = render(TrackList, {
props: { tracks: mockTracks, onTrackClick },
});
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(onTrackClick).toHaveBeenCalledWith(mockTracks[0], 0);
});
});
});
describe("Edge Cases", () => {
it("handles empty tracks array", () => {
const { getByText } = render(TrackList, { props: { tracks: [] } });
expect(getByText("No tracks found")).toBeTruthy();
});
it("handles single track", async () => {
const singleTrack = [mockTracks[0]];
(invoke as any).mockResolvedValue(undefined);
const { container } = render(TrackList, { props: { tracks: singleTrack } });
const buttons = container.querySelectorAll("button");
const trackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
await fireEvent.click(trackButton!);
await waitFor(() => {
const callArgs = (invoke as any).mock.calls[0][1] as any;
expect(callArgs.request.trackIds).toEqual(["track-1"]);
expect(callArgs.request.startIndex).toBe(0);
});
});
it("handles click on first track (index 0)", async () => {
(invoke as any).mockResolvedValue(undefined);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
const callArgs = (invoke as any).mock.calls[0][1] as any;
expect(callArgs.request.startIndex).toBe(0);
});
});
it("handles click on last track", async () => {
(invoke as any).mockResolvedValue(undefined);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const lastTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 3")
);
await fireEvent.click(lastTrackButton!);
await waitFor(() => {
const callArgs = (invoke as any).mock.calls[0][1] as any;
expect(callArgs.request.startIndex).toBe(2);
});
});
});
describe("Loading State", () => {
it("shows loading spinner when track is clicked", async () => {
// Make invoke slow to capture loading state
(invoke as any).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
fireEvent.click(firstTrackButton!);
// Check for loading spinner
await waitFor(() => {
const spinner = container.querySelector(".animate-spin");
expect(spinner).toBeTruthy();
});
});
it("disables track buttons during loading", async () => {
(invoke as any).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
fireEvent.click(firstTrackButton!);
// Track selection buttons should be disabled during loading
await waitFor(() => {
// Find track buttons (ones containing song names)
const trackButtons = Array.from(container.querySelectorAll("button")).filter(
(btn) => btn.textContent?.includes("Song")
);
expect(trackButtons.length).toBeGreaterThan(0);
trackButtons.forEach((btn) => {
expect(btn.disabled).toBe(true);
});
});
});
});
});