First working POC
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
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() })),
|
||||
}));
|
||||
|
||||
// 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("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.
|
||||
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user