jellytau/src/lib/components/library/TrackList.test.ts
Duncan Tourolle 09780103a7
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
2026-03-01 19:47:46 +01:00

562 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(),
},
}));
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";
describe.skip("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);
expect(getAllByText(/Song 3 with a Very Long Name/).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 } = render(TrackList, { props: { tracks: mockTracks } });
expect(getByText("Artist")).toBeTruthy();
expect(getByText("Artist 1")).toBeTruthy();
});
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 } = render(TrackList, { props: { tracks: mockTracks } });
expect(getByText("Album")).toBeTruthy();
expect(getByText("Album 1")).toBeTruthy();
});
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,
},
];
// Component renders both desktop and mobile views
const { getAllByText } = render(TrackList, {
props: { tracks: tracksWithoutDuration },
});
expect(getAllByText("-").length).toBeGreaterThan(0);
});
it("handles tracks without artist", () => {
const tracksWithoutArtist: MediaItem[] = [
{
...mockTracks[0],
artists: undefined,
},
];
const { getByText } = render(TrackList, {
props: { tracks: tracksWithoutArtist },
});
expect(getByText("-")).toBeTruthy();
});
it("renders multiple artists joined with comma", () => {
const { getByText } = render(TrackList, { props: { tracks: mockTracks } });
expect(getByText("Artist 3, Artist 4")).toBeTruthy();
});
});
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.skip("calls getAudioStreamUrl for each track", async () => {
// NOTE: This test is skipped because the code was refactored to use player_play_tracks
// which sends trackIds to the backend. The backend now handles all metadata/stream fetching.
// This test expected the old behavior where frontend called getAudioStreamUrl.
});
it.skip("includes artwork URLs in queue items", async () => {
// NOTE: This test is skipped because the code was refactored.
// Stream URLs and artwork URLs are no longer fetched by frontend.
// Backend handles all metadata and stream URL fetching via player_play_tracks.
});
it.skip("handles tracks without artwork gracefully", async () => {
// NOTE: This test is skipped because the code no longer includes artwork URLs
// in queue items sent to backend. Backend handles artwork fetching independently.
});
it("shows error alert when playback fails", async () => {
const alertSpy = vi.spyOn(window, "alert").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(alertSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track")
);
});
alertSpy.mockRestore();
});
it("handles auth errors gracefully", async () => {
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {});
(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(alertSpy).toHaveBeenCalledWith(
expect.stringContaining("Not authenticated")
);
});
alertSpy.mockRestore();
// Restore mock for other tests
(auth.getRepository as any).mockReturnValue(mockRepository as any);
});
it.skip("handles stream URL generation errors", async () => {
// NOTE: This test is skipped because stream URLs are no longer fetched by frontend.
// The code now uses player_play_tracks which sends trackIds to backend.
// Backend handles all stream URL generation, so this error path no longer exists.
});
});
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);
});
});
});
});
});