import { describe, it, expect, vi, beforeEach } from "vitest"; import { invoke } from "@tauri-apps/api/core"; import type { MediaItem } from "$lib/api/types"; import { auth } from "$lib/stores/auth"; // Mock modules vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); vi.mock("@tauri-apps/plugin-os", () => ({ platform: vi.fn(() => "linux"), })); vi.mock("$lib/api/client", () => ({ default: class {}, })); vi.mock("$lib/stores/auth", () => ({ auth: { getRepository: vi.fn(), }, })); describe("TrackList Logic Tests", () => { const mockRepository = { getAudioStreamUrl: vi.fn(), getImageUrl: vi.fn(), }; 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, 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, primaryImageTag: "tag2", indexNumber: 2, }, ]; beforeEach(() => { vi.clearAllMocks(); (auth.getRepository as any).mockReturnValue(mockRepository); mockRepository.getAudioStreamUrl.mockResolvedValue("http://stream.url/track"); mockRepository.getImageUrl.mockReturnValue("http://image.url/artwork"); }); describe("Queue Building Logic", () => { it("should build correct queue structure from tracks", async () => { (invoke as any).mockResolvedValue(undefined); // Simulate the queue building logic from defaultHandleTrackClick const repo = auth.getRepository(); const queueItems = await Promise.all( mockTracks.map(async (t) => ({ id: t.id, title: t.name, artist: t.artists?.join(", "), album: t.albumName, duration: t.runTimeTicks ? t.runTimeTicks / 10000000 : undefined, artworkUrl: t.primaryImageTag ? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.primaryImageTag, }) : undefined, mediaType: "Audio", streamUrl: await repo.getAudioStreamUrl(t.id), jellyfinItemId: t.id, })) ); expect(queueItems).toHaveLength(2); expect(queueItems[0]).toMatchObject({ id: "track-1", title: "Song 1", artist: "Artist 1", album: "Album 1", duration: 180, mediaType: "Audio", }); expect(queueItems[0].streamUrl).toBe("http://stream.url/track"); expect(queueItems[0].artworkUrl).toBe("http://image.url/artwork"); }); it("should call getAudioStreamUrl for each track", async () => { const repo = auth.getRepository(); await Promise.all( mockTracks.map(async (t) => { const streamUrl = await repo.getAudioStreamUrl(t.id); return { id: t.id, streamUrl, }; }) ); expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledTimes(2); expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledWith("track-1"); expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledWith("track-2"); }); it("should handle tracks without artwork", async () => { const trackWithoutArt: MediaItem = { ...mockTracks[0], primaryImageTag: undefined, }; const repo = auth.getRepository(); const artworkUrl = trackWithoutArt.primaryImageTag ? repo.getImageUrl(trackWithoutArt.albumId || trackWithoutArt.id, "Primary", { maxWidth: 300, tag: trackWithoutArt.primaryImageTag, }) : undefined; expect(artworkUrl).toBeUndefined(); expect(mockRepository.getImageUrl).not.toHaveBeenCalled(); }); it("should handle tracks without artist", async () => { const trackWithoutArtist: MediaItem = { ...mockTracks[0], artists: undefined, }; const artistString = trackWithoutArtist.artists?.join(", "); expect(artistString).toBeUndefined(); }); it("should join multiple artists with comma", () => { const track: MediaItem = { ...mockTracks[0], artists: ["Artist 1", "Artist 2", "Artist 3"], }; const artistString = track.artists?.join(", "); expect(artistString).toBe("Artist 1, Artist 2, Artist 3"); }); }); describe("Duration Formatting", () => { function formatDuration(ticks?: number): string { if (!ticks) return "-"; const seconds = Math.floor(ticks / 10000000); const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; } it("should format duration correctly", () => { expect(formatDuration(1800000000)).toBe("3:00"); // 3 minutes expect(formatDuration(2400000000)).toBe("4:00"); // 4 minutes expect(formatDuration(3000000000)).toBe("5:00"); // 5 minutes }); it("should handle seconds padding", () => { expect(formatDuration(650000000)).toBe("1:05"); // 1:05 expect(formatDuration(6150000000)).toBe("10:15"); // 10:15 }); it("should return dash for undefined duration", () => { expect(formatDuration(undefined)).toBe("-"); expect(formatDuration(0)).toBe("-"); }); }); describe("Player Invocation", () => { it("should invoke player_play_queue with correct parameters", async () => { const invokeMock = (invoke as any).mockResolvedValue(undefined); const queueItems = [ { id: "track-1", title: "Song 1", artist: "Artist 1", album: "Album 1", duration: 180, artworkUrl: "http://image.url/artwork", mediaType: "Audio", streamUrl: "http://stream.url/track", jellyfinItemId: "track-1", }, ]; await invoke("player_play_queue", { request: { items: queueItems, startIndex: 0, shuffle: false, }, }); expect(invokeMock).toHaveBeenCalledWith("player_play_queue", { request: { items: queueItems, startIndex: 0, shuffle: false, }, }); }); it("should use correct startIndex for different positions", async () => { const invokeMock = (invoke as any).mockResolvedValue(undefined); const queueItems = mockTracks.map((t) => ({ id: t.id, title: t.name })); // Test clicking second track (index 1) await invoke("player_play_queue", { request: { items: queueItems, startIndex: 1, shuffle: false, }, }); const callArgs = invokeMock.mock.calls[0][1] as any; expect(callArgs.request.startIndex).toBe(1); }); }); describe("Error Handling", () => { it("should handle missing auth repository", () => { (auth.getRepository as any).mockReturnValue(null); const repo = auth.getRepository(); expect(repo).toBeNull(); // In the actual component, this would throw "Not authenticated" expect(() => { if (!repo) { throw new Error("Not authenticated"); } }).toThrow("Not authenticated"); // Restore for other tests (auth.getRepository as any).mockReturnValue(mockRepository); }); it("should handle stream URL generation failure", async () => { mockRepository.getAudioStreamUrl.mockResolvedValue(null); const streamUrl = await mockRepository.getAudioStreamUrl("track-1"); expect(streamUrl).toBeNull(); // In the actual component, this would throw an error expect(() => { if (!streamUrl) { throw new Error("Failed to get stream URL for track"); } }).toThrow("Failed to get stream URL"); }); it("should handle player invoke errors", async () => { const error = new Error("Network error"); (invoke as any).mockRejectedValue(error); await expect( invoke("player_play_queue", { request: { items: [], startIndex: 0, shuffle: false, }, }) ).rejects.toThrow("Network error"); }); }); describe("Callback vs Default Handler", () => { it("should use custom callback when provided", async () => { const customCallback = vi.fn(); const track = mockTracks[0]; const index = 0; // Simulate the unified handler logic const onTrackClick = customCallback; if (onTrackClick) { await onTrackClick(track, index); } expect(customCallback).toHaveBeenCalledWith(track, index); expect(customCallback).toHaveBeenCalledTimes(1); }); it("should not invoke player when custom callback is provided", async () => { const customCallback = vi.fn(); const invokeMock = (invoke as any).mockResolvedValue(undefined); // Simulate unified handler with custom callback const onTrackClick = customCallback; if (onTrackClick) { await onTrackClick(mockTracks[0], 0); } else { // This branch wouldn't execute await invoke("player_play_queue", { request: { items: [], startIndex: 0, shuffle: false }, }); } expect(customCallback).toHaveBeenCalled(); expect(invokeMock).not.toHaveBeenCalled(); }); it("should invoke player when no custom callback", async () => { const invokeMock = (invoke as any).mockResolvedValue(undefined); // Simulate unified handler without custom callback - default handler runs await invoke("player_play_queue", { request: { items: [], startIndex: 0, shuffle: false }, }); expect(invokeMock).toHaveBeenCalled(); }); }); });