334 lines
9.8 KiB
TypeScript
334 lines
9.8 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|