jellytau/src/lib/components/library/TrackList.logic.test.ts
Duncan Tourolle 3a9c126dfe
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
Fix warnings and update tracability
2026-02-28 20:54:25 +01:00

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