Files
jellytau/src/lib/services/playerEvents.regression.test.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
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.
2026-08-21 17:41:44 +02:00

221 lines
8.1 KiB
TypeScript

/**
* Player Events Service — regression tests
*
* These tests intentionally use the REAL player store (and its real derived
* stores), unlike playerEvents.test.ts which mocks the store away. The bugs
* covered here live in the interaction between the event handler and the
* store's position/duration fields, so a mocked store cannot catch them.
*
* TRACES: UR-005 | DR-001
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get, writable } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import type { PlayerStatusEvent } from "$lib/api/bindings";
// Capture the handler that initPlayerEvents registers so we can drive events
// directly, exactly as the Tauri event bridge would.
let registeredHandler: ((event: { payload: PlayerStatusEvent }) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (_event: string, handler: any) => {
registeredHandler = handler;
return () => {};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
// The current queue item is what handleStateChanged seeds playing/paused state
// from. Back it with a writable so each test can install its own item.
const currentQueueItemStore = writable<MediaItem | null>(null);
vi.mock("$lib/stores/queue", () => ({
queue: { subscribe: vi.fn() },
currentQueueItem: { subscribe: (run: any) => currentQueueItemStore.subscribe(run) },
}));
// Keep the player in local mode so events aren't skipped as remote.
// playerEvents.ts reads `get(playbackMode)` (.mode/.isTransferring) and player.ts
// imports `isRemoteMode` from the same module — provide both.
vi.mock("$lib/stores/playbackMode", () => ({
playbackMode: {
setMode: vi.fn(),
initializeSessionMonitoring: vi.fn(),
subscribe: (run: any) => {
run({ mode: "local", isTransferring: false });
return () => {};
},
},
isRemoteMode: { subscribe: (run: any) => (run(false), () => {}) },
}));
// Remote-mode merged stores in player.ts read this; stub as no remote session.
vi.mock("$lib/stores/sessions", () => ({
selectedSession: { subscribe: (run: any) => (run(null), () => {}) },
}));
vi.mock("$lib/stores/sleepTimer", () => ({
sleepTimer: { set: vi.fn() },
}));
vi.mock("$lib/stores/nextEpisode", () => ({
nextEpisode: { showPopup: vi.fn(), updateCountdown: vi.fn() },
}));
vi.mock("$lib/services/preload", () => ({
preloadUpcomingTracks: vi.fn().mockResolvedValue(undefined),
}));
function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "track-1",
name: "Test Track",
kind: "track",
durationMs: null,
...overrides,
} as MediaItem;
}
async function fire(event: PlayerStatusEvent): Promise<void> {
if (!registeredHandler) throw new Error("handler not registered");
await registeredHandler({ payload: event });
// Let any async work inside the handler settle.
await Promise.resolve();
}
describe("Player Events — pause must not zero the slider duration", () => {
beforeEach(async () => {
// initPlayerEvents is a singleton; reset it so each test re-registers its
// own handler and starts from idle player state.
const { cleanupPlayerEvents } = await import("./playerEvents");
const { player } = await import("$lib/stores/player");
cleanupPlayerEvents();
player.setIdle();
vi.clearAllMocks();
registeredHandler = null;
currentQueueItemStore.set(null);
});
it("preserves the live duration across pause when runTimeTicks is missing", async () => {
// runTimeTicks is null — the previous code recomputed duration as 0 here,
// which collapsed the slider's max and snapped the thumb to the start.
const item = makeItem({ durationMs: null });
currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents");
const { player, playbackDuration, playbackPosition } = await import("$lib/stores/player");
await initPlayerEvents();
// 1. Track starts playing.
await fire({ type: "state_changed", state: "playing", media_id: item.id });
// 2. Backend reports the real duration once media is loaded / position ticks.
await fire({ type: "position_update", position: 42, duration: 180 });
expect(get(playbackPosition)).toBe(42);
expect(get(playbackDuration)).toBe(180);
// 3. User pauses. Position AND duration must survive — duration is what
// drives the slider's max, so a 0 here is what caused the regression.
await fire({ type: "state_changed", state: "paused", media_id: item.id });
expect(get(playbackPosition)).toBe(42);
expect(get(playbackDuration)).toBe(180);
// Sanity: the store is genuinely paused, not reset to idle/loading.
expect(get(player).state.kind).toBe("paused");
});
it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
// 70 seconds = 70_000 ms.
const item = makeItem({ durationMs: 70_000 });
currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents");
const { playbackDuration } = await import("$lib/stores/player");
await initPlayerEvents();
// No position_update yet, so there is no live duration to prefer.
await fire({ type: "state_changed", state: "paused", media_id: item.id });
expect(get(playbackDuration)).toBe(70);
});
});
/**
* A recoverable error is a network hiccup, not the end of playback. The handler
* used to stop the player unconditionally, so a blip on wifi killed the track —
* on Linux especially, where MPV's EndFile(ERROR) is the only signal a stream
* died and there is no in-process controller for the backend to consult.
*
* TRACES: UR-004, UR-040 | DR-130
*/
describe("Player Events — recoverable errors get one chance before stopping", () => {
beforeEach(async () => {
const { cleanupPlayerEvents } = await import("./playerEvents");
const { player } = await import("$lib/stores/player");
cleanupPlayerEvents();
player.setIdle();
vi.clearAllMocks();
registeredHandler = null;
currentQueueItemStore.set(null);
});
it("does not stop the player when Rust re-opened the stream", async () => {
const { invoke } = await import("@tauri-apps/api/core");
vi.mocked(invoke).mockImplementation(async (cmd: string) =>
cmd === "player_recover_stream" ? true : null,
);
const { initPlayerEvents } = await import("./playerEvents");
const { player } = await import("$lib/stores/player");
await initPlayerEvents();
await fire({ type: "state_changed", state: "playing", media_id: "track-1" });
await fire({ type: "error", message: "Playback stream failed", recoverable: true });
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
expect(calls).toContain("player_recover_stream");
expect(calls).not.toContain("player_stop");
expect(get(player).state.kind).not.toBe("error");
});
it("stops the player when recovery declines", async () => {
const { invoke } = await import("@tauri-apps/api/core");
vi.mocked(invoke).mockImplementation(async (cmd: string) =>
cmd === "player_recover_stream" ? false : null,
);
const { initPlayerEvents } = await import("./playerEvents");
await initPlayerEvents();
await fire({ type: "error", message: "Playback stream failed", recoverable: true });
await Promise.resolve();
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
expect(calls).toContain("player_recover_stream");
expect(calls).toContain("player_stop");
});
it("does not attempt recovery for an unrecoverable error", async () => {
// Android decides in its JNI callback and reports the errors it already
// declined as unrecoverable, so this must not ask a second time.
const { invoke } = await import("@tauri-apps/api/core");
vi.mocked(invoke).mockResolvedValue(null);
const { initPlayerEvents } = await import("./playerEvents");
await initPlayerEvents();
await fire({ type: "error", message: "Decoder failed", recoverable: false });
await Promise.resolve();
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
expect(calls).not.toContain("player_recover_stream");
expect(calls).toContain("player_stop");
});
});