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); }); }); }); }); });