562 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|