Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
576 lines
18 KiB
TypeScript
576 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(),
|
|
},
|
|
currentQueueItem: {
|
|
subscribe: vi.fn((fn: any) => {
|
|
fn(null);
|
|
return () => {};
|
|
}),
|
|
},
|
|
}));
|
|
|
|
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";
|
|
import { toast } from "$lib/stores/toast";
|
|
|
|
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",
|
|
durationMs: 180000, // 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",
|
|
durationMs: 240000, // 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",
|
|
durationMs: 300000, // 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);
|
|
// Long names are abbreviated in the middle via truncateMiddle(name, 48).
|
|
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).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, getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
|
|
|
|
// Header only exists in the desktop table.
|
|
expect(getByText("Artist")).toBeTruthy();
|
|
// Artist name renders in both desktop and mobile views.
|
|
expect(getAllByText("Artist 1").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
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, getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
|
|
|
|
// Header only exists in the desktop table.
|
|
expect(getByText("Album")).toBeTruthy();
|
|
// Album name renders in both desktop and mobile views.
|
|
expect(getAllByText("Album 1").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
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],
|
|
durationMs: undefined,
|
|
},
|
|
];
|
|
|
|
// formatDuration(undefined) renders an empty string, so the row still
|
|
// renders without crashing and the track title is present.
|
|
const { getAllByText } = render(TrackList, {
|
|
props: { tracks: tracksWithoutDuration },
|
|
});
|
|
|
|
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("handles tracks without artist", () => {
|
|
const tracksWithoutArtist: MediaItem[] = [
|
|
{
|
|
...mockTracks[0],
|
|
artists: undefined,
|
|
artistItems: undefined,
|
|
},
|
|
];
|
|
|
|
// The artist fallback renders "-" in both desktop and mobile views.
|
|
const { getAllByText } = render(TrackList, {
|
|
props: { tracks: tracksWithoutArtist },
|
|
});
|
|
|
|
expect(getAllByText("-").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders multiple artists joined with comma", () => {
|
|
// Tracks fall back to artists.join(", ") when artistItems is absent;
|
|
// the joined string renders in both desktop and mobile views.
|
|
const { getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
|
|
|
|
expect(getAllByText("Artist 3, Artist 4").length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
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("shows error toast when playback fails", async () => {
|
|
const toastSpy = vi.spyOn(toast, "error").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(toastSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to play track"),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
toastSpy.mockRestore();
|
|
});
|
|
|
|
it("handles auth errors gracefully", async () => {
|
|
const toastSpy = vi.spyOn(toast, "error").mockImplementation(() => "");
|
|
// No repository → requireHandle() throws "No repository available",
|
|
// which the default handler surfaces via toast.error.
|
|
(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(toastSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to play track"),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
toastSpy.mockRestore();
|
|
|
|
// Restore mock for other tests
|
|
(auth.getRepository as any).mockReturnValue(mockRepository as any);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|