import { test } from "node:test"; import assert from "node:assert/strict"; import { AVAILABILITY } from "../../src/lib/preview/contracts.js"; import { buildFixtureManifest } from "../../src/lib/preview/manifest.js"; import { createFixtureResolver } from "../../src/lib/preview/resolver.js"; import { createPreviewService } from "../../src/lib/preview/service.js"; import { PHASE } from "../../src/lib/preview/session.js"; const T0 = 1_700_000_000_000; const SID = "svc-1"; function manifest(n = 3) { return buildFixtureManifest({ rows: Array.from({ length: n }, (_, i) => ({ providerTrackId: String(i + 1), title: `T${i + 1}`, initialAvailability: AVAILABILITY.AVAILABLE, })), createdAtMs: T0, }); } /** Manual scheduler: settle windows fire only when the test says so. */ function makeScheduler() { let queue = new Map(); let nextId = 1; const delays = []; return { delays, // every ms passed to set(), in order — for adaptive-settle asserts set(fn, ms) { const id = nextId++; queue.set(id, fn); delays.push(ms); return id; }, clear(id) { queue.delete(id); }, async flush() { const fns = [...queue.values()]; queue.clear(); for (const fn of fns) await fn(); await drainMicrotasks(); }, pending: () => queue.size, }; } const drainMicrotasks = () => new Promise((r) => setTimeout(r, 0)); /** Transport double: records every command AND behaves like the real one — * play/pause dispatch their media events stamped with the load's context, * which is exactly the wiring the service's transportFactory provides. */ function makeTransport() { const log = []; let dispatch = null; let ctx = null; const t = { log, async load(lease, c, opts) { ctx = c; log.push(["load", lease.opaqueLeaseId, c.generation, !!opts?.startPaused, opts?.startAtMs ?? 0]); }, async play() { log.push(["play"]); if (ctx) dispatch({ type: "MEDIA_PLAYING", ...ctx }); }, async pause() { log.push(["pause"]); if (ctx) dispatch({ type: "MEDIA_PAUSED", ...ctx }); }, async seek(ms) { log.push(["seek", ms]); }, async setVolume(v) { log.push(["setVolume", v]); }, async stop() { ctx = null; log.push(["stop"]); }, async dispose() { log.push(["dispose"]); }, end() { if (ctx) dispatch({ type: "MEDIA_ENDED", ...ctx }); }, position(ms) { if (ctx) dispatch({ type: "MEDIA_POSITION", ...ctx, positionMs: ms }); }, }; return { transport: t, factory: (d) => { dispatch = d; return t; }, }; } function makeFocus({ grant = true, pausedByPreview = true } = {}) { const calls = { acquire: 0, release: 0, handoff: 0, beginMainSilence: 0 }; let live = null; return { calls, released: [], coordinator: { async acquirePreview() { calls.acquire += 1; if (!grant) return { granted: false, token: null }; live = { id: `t${calls.acquire}`, pausedByPreview }; return { granted: true, token: live }; }, beginMainSilence() { calls.beginMainSilence += 1; }, release(token) { calls.release += 1; const ok = token === live; live = null; return { restored: ok && pausedByPreview }; }, handoffToMain() { calls.handoff += 1; live = null; return { restored: false, reason: "handoff" }; }, }, }; } function makeService({ plan, grant = true, n = 3 } = {}) { const scheduler = makeScheduler(); const { transport, factory } = makeTransport(); const focus = makeFocus({ grant }); let clock = 0; const resolver = createFixtureResolver( plan ?? { 1: { opaqueLeaseId: "op-1" }, 2: { opaqueLeaseId: "op-2" }, 3: { opaqueLeaseId: "op-3" } } ); const service = createPreviewService({ resolver, transportFactory: factory, focus, scheduler, monotonicNow: () => clock, }); return { service, scheduler, transport, focus, resolver, advance: (ms) => (clock += ms), manifest: manifest(n) }; } // ------------------------------------------------------------------ open -- test("open: focus, settle, resolve, load audible, play", async () => { const { service, scheduler, transport, focus, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); assert.equal(focus.calls.acquire, 1); assert.equal(service.getState().phase, PHASE.RESOLVING_MEDIA); assert.equal(scheduler.pending(), 1); // settle armed await scheduler.flush(); const st = service.getState(); assert.equal(st.phase, PHASE.PLAYING); // lease landed, played, confirmed const load = transport.log.find((e) => e[0] === "load"); assert.deepEqual(load, ["load", "op-1", st.requestGeneration, false, 0]); // audible → not parked, from the top assert.ok(transport.log.some((e) => e[0] === "play")); }); test("open crossfade: main is NOT silenced until the first preview clip is audible", async () => { const { service, scheduler, focus, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); // Focus is granted, but the clip hasn't resolved/played — the main track // must still be sounding (the whole point of the deferred fade). assert.equal(focus.calls.beginMainSilence, 0, "no main fade through resolve/load"); await scheduler.flush(); // resolve → load → play → MEDIA_PLAYING assert.equal(service.getState().phase, PHASE.PLAYING); assert.equal(focus.calls.beginMainSilence, 1, "main fades exactly when the preview is audible"); }); test("open crossfade: a parked (denied) open never fades the main track", async () => { const { service, scheduler, focus, manifest } = makeService({ grant: false }); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); assert.equal(focus.calls.beginMainSilence, 0, "parked preview leaves the main playing"); }); test("open with focus denied: session opens PARKED, wait state surfaced", async () => { const { service, scheduler, transport, manifest } = makeService({ grant: false }); await service.open({ sessionId: SID, manifest }); assert.equal(service.getState().desiredPlaying, false); await scheduler.flush(); const load = transport.log.find((e) => e[0] === "load"); assert.equal(load[3], true, "must load parked without focus"); assert.ok(!transport.log.some((e) => e[0] === "play"), "no audio without focus"); }); // ------------------------------------------------------------- stepping --- test("rapid steps: only the LAST generation ever reaches the transport", async () => { const { service, scheduler, transport, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); service.step(1); // supersedes the open's pipeline before its settle fires service.step(1); const finalGen = service.getState().requestGeneration; await scheduler.flush(); await scheduler.flush(); const loads = transport.log.filter((e) => e[0] === "load"); assert.equal(loads.length, 1, "superseded generations never loaded"); assert.equal(loads[0][2], finalGen); assert.equal(loads[0][1], "op-3"); // stepped 1 → 2 → 3 }); test("failed resolve retries exactly once, then fails the item in place", async () => { const { service, scheduler, resolver, manifest } = makeService({ plan: { 1: (n) => (n === 1 ? { failure: "stale_lease", retryable: true } : { opaqueLeaseId: "op-fresh" }), 2: { failure: "network", retryable: true }, // fails on retry too 3: { opaqueLeaseId: "op-3" }, }, }); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); // background neighbor prefetch (item 2) settles assert.equal(service.getState().phase, PHASE.PLAYING); // retry rescued item 1 assert.equal(resolver.callCount("1"), 2); assert.equal(resolver.callCount("2"), 1, "prefetch tried item 2 once — prefetch never retries"); service.step(1); await scheduler.flush(); const st = service.getState(); assert.equal(st.phase, PHASE.ITEM_FAILED); assert.equal(st.errorClass, "network"); // 1 prefetch + 1 pipeline resolve + exactly 1 capped retry = 3, then stop assert.equal(resolver.callCount("2"), 3, "one capped retry in the pipeline, never a loop"); // the failed card stays; stepping onward still works service.step(1); await scheduler.flush(); assert.equal(service.getState().phase, PHASE.PLAYING); assert.equal(resolver.callCount("2"), 3, "failure is settled — no more calls"); }); // -------------------------------------------- adaptive settle / auto-play -- test("isolated steps fire the pipeline immediately; bursts pay the settle window", async () => { const { service, scheduler, advance, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); // isolated → 0 service.step(1); // same instant as the open's activation → burst → 150 advance(300); service.step(1); // beyond the burst window → isolated → 0 assert.deepEqual(scheduler.delays, [0, 150, 0]); }); test("auto-play: nearing the clip's end advances early so the next track crossfades in", async () => { const { service, scheduler, transport, advance, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); // playing item 1, neighbour prefetched const genBefore = service.getState().requestGeneration; advance(1000); // clear the burst window: the early step must fire at once transport.position(29_700); // inside EARLY_ADVANCE_MS of the 30 s clip assert.ok(scheduler.pending() >= 1, "early advance armed"); await scheduler.flush(); // STEP dispatched, settle armed at delay 0 await scheduler.flush(); // pipeline: load + play item 2 const st = service.getState(); assert.equal(st.activeManifestIndex, 1, "advanced before the clip ended"); assert.equal(st.phase, PHASE.PLAYING); assert.ok(st.requestGeneration > genBefore); assert.equal(st.desiredPlaying, true, "listen intent survives the auto-advance"); assert.equal(scheduler.delays.at(-1), 0, "the early step skipped the settle window"); }); test("auto-play: the LAST playable track never advances early", async () => { const { service, scheduler, transport, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); service.step(1); await scheduler.flush(); service.step(1); await scheduler.flush(); // riding on item 3 of 3 await drainMicrotasks(); assert.equal(service.getState().activeManifestIndex, 2); transport.position(29_800); assert.equal(scheduler.pending(), 0, "no early advance armed on the last track"); transport.end(); // natural end still lands in the replay state assert.equal(service.getState().phase, PHASE.ENDED); }); test("setVolume forwards the app volume to the transport", async () => { const { service, transport, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); service.setVolume(0.35); service.setVolume(0); // app mute → silent previews assert.deepEqual( transport.log.filter((e) => e[0] === "setVolume"), [["setVolume", 0.35], ["setVolume", 0]] ); }); // ------------------------------------------------------- resume memory ---- test("resume: stepping back to a track picks up where it left off", async () => { const { service, scheduler, transport, advance, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); // playing item 1 transport.position(12_000); // listened 12 s in advance(300); service.step(1); await scheduler.flush(); await drainMicrotasks(); // playing item 2 from the top assert.equal(transport.log.filter((e) => e[0] === "load").at(-1)[4], 0); advance(300); service.step(-1); await scheduler.flush(); const back = transport.log.filter((e) => e[0] === "load").at(-1); assert.equal(back[1], "op-1"); assert.equal(back[4], 12_000, "revisit resumes at the departure position"); assert.equal(service.getState().positionMs, 12_000, "scrubber painted at the resume point"); assert.equal(service.getState().phase, PHASE.PLAYING); }); test("resume: a track that finished (auto-advance) restarts from the top", async () => { const { service, scheduler, transport, advance, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); // playing item 1 advance(1000); transport.position(29_700); // the anticipatory auto-advance point await scheduler.flush(); // early STEP await scheduler.flush(); // playing item 2 assert.equal(service.getState().activeManifestIndex, 1); advance(300); service.step(-1); await scheduler.flush(); const back = transport.log.filter((e) => e[0] === "load").at(-1); assert.equal(back[1], "op-1"); assert.equal(back[4], 0, "a finished clip restarts from the beginning"); }); test("resume: flicking past a track does not erase its remembered position", async () => { const { service, scheduler, transport, advance, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); // playing item 1 transport.position(9_000); advance(300); service.step(1); // land on 2 await scheduler.flush(); await drainMicrotasks(); advance(300); service.step(1); // land on 3 — item 2 leaves at position 0 await scheduler.flush(); await drainMicrotasks(); advance(300); service.step(-1); // back to 2: no remembered position → from the top await scheduler.flush(); assert.equal(transport.log.filter((e) => e[0] === "load").at(-1)[4], 0); advance(300); service.step(-1); // back to 1: the 9 s position survived the pass-through await scheduler.flush(); const back = transport.log.filter((e) => e[0] === "load").at(-1); assert.equal(back[1], "op-1"); assert.equal(back[4], 9_000); }); // ------------------------------------------------------------- prefetch --- test("neighbor lease prefetch: stepping forward skips the resolve", async () => { const { service, scheduler, resolver, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); // pipeline + prefetch of item 2 kicked await drainMicrotasks(); assert.equal(resolver.callCount("2"), 1, "prefetched in the background"); service.step(1); await scheduler.flush(); assert.equal(resolver.callCount("2"), 1, "no second resolve on activation"); assert.equal(service.getState().phase, PHASE.PLAYING); }); test("backward prefetch: stepping back to the previous track skips the resolve", async () => { const { service, scheduler, resolver, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); // landed on 1; forward neighbour 2 prefetched service.step(1); await scheduler.flush(); await drainMicrotasks(); // landed on 2; BOTH neighbours (1, 3) prefetched const before = resolver.callCount("1"); assert.ok(before >= 2, "backward neighbour was prefetched after the step"); service.step(-1); await scheduler.flush(); assert.equal(service.getState().phase, PHASE.PLAYING); assert.equal(resolver.callCount("1"), before, "backward step used the prefetched lease"); }); test("pause clicked mid-step transition parks the pipeline instead of playing on", async () => { const { service, scheduler, transport, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); assert.equal(service.getState().phase, PHASE.PLAYING); service.step(1); // phase leaves PLAYING while the step resolves assert.equal(service.getState().phase, PHASE.RESOLVING_MEDIA); await service.togglePlay(); // user hits pause DURING the transition assert.equal(service.getState().desiredPlaying, false); assert.ok(transport.log.some((e) => e[0] === "pause"), "outgoing audio silenced"); const playsBefore = transport.log.filter((e) => e[0] === "play").length; await scheduler.flush(); const load = transport.log.filter((e) => e[0] === "load").at(-1); assert.equal(load[3], true, "the new track loads parked"); assert.equal( transport.log.filter((e) => e[0] === "play").length, playsBefore, "nothing auto-plays after parking" ); }); test("a prefetched lease past 80% of its safe window is not trusted", async () => { const { service, scheduler, resolver, advance, manifest } = makeService({ plan: { 1: { opaqueLeaseId: "op-1", safeForMs: 10_000 }, 2: { opaqueLeaseId: "op-2", safeForMs: 10_000 }, 3: { opaqueLeaseId: "op-3" }, }, }); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); advance(9_000); // beyond 80% of the 10s safe window service.step(1); await scheduler.flush(); assert.equal(resolver.callCount("2"), 2, "stale prefetch re-resolved"); }); // ------------------------------------------- crossfade (fade transports) -- /** Fade-capable transport double (the native transport's shape): fadeOut * exists, and load records the crossfadeMs it was handed. */ function makeFadeTransport() { const log = []; let dispatch = null; let ctx = null; const t = { log, async load(lease, c, opts) { ctx = c; log.push(["load", lease.opaqueLeaseId, c.generation, !!opts?.startPaused, opts?.crossfadeMs ?? 0]); }, async play() { log.push(["play"]); if (ctx) dispatch({ type: "MEDIA_PLAYING", ...ctx }); }, async pause() { log.push(["pause"]); }, async seek() {}, async fadeOut(ms) { log.push(["fadeOut", ms]); }, async stop() { ctx = null; log.push(["stop"]); }, async dispose() { log.push(["dispose"]); }, }; return { transport: t, factory: (d) => ((dispatch = d), t) }; } function makeFadeService({ plan, n = 3 } = {}) { const scheduler = makeScheduler(); const { transport, factory } = makeFadeTransport(); const focus = makeFocus(); const resolver = createFixtureResolver( plan ?? { 1: { opaqueLeaseId: "op-1" }, 2: { opaqueLeaseId: "op-2" }, 3: { opaqueLeaseId: "op-3" } } ); const service = createPreviewService({ resolver, transportFactory: factory, focus, scheduler, monotonicNow: () => 0, }); return { service, scheduler, transport, focus, manifest: manifest(n) }; } test("fade transport: close crossfades — main returns at once, teardown waits for the tail", async () => { const { service, scheduler, transport, focus, manifest } = makeFadeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); // playing item 1 const closing = service.close(); await drainMicrotasks(); assert.ok(transport.log.some((e) => e[0] === "fadeOut"), "the preview rings down"); assert.equal(focus.calls.release, 1, "main resumes while the fade is still audible"); assert.ok(!transport.log.some((e) => e[0] === "dispose"), "teardown holds for the fade tail"); await scheduler.flush(); // the tail timer await closing; assert.ok(transport.log.some((e) => e[0] === "dispose"), "then the transport is torn down"); }); test("fade transport: outgoing keeps sounding through a step — load's crossfade owns the handover", async () => { const { service, scheduler, transport, manifest } = makeFadeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); // playing op-1, op-2 prefetched await drainMicrotasks(); transport.log.length = 0; service.step(1); assert.ok( !transport.log.some((e) => e[0] === "fadeOut" || e[0] === "stop"), "nothing retires the outgoing at step time — it must still be sounding" ); await scheduler.flush(); const load = transport.log.find((e) => e[0] === "load"); assert.equal(load[1], "op-2"); assert.equal(load[4], 340, "audible step loads with the reel-slide-length crossfade"); assert.ok( !transport.log.some((e) => e[0] === "fadeOut"), "no standalone fade — the crossfade inside load ramps the outgoing" ); assert.equal(scheduler.pending(), 0, "overstay guard cleared once the load landed"); }); test("fade transport: failed resolve fades the outgoing out instead of leaving it playing", async () => { const { service, scheduler, transport, manifest } = makeFadeService({ plan: { 1: { opaqueLeaseId: "op-1" }, 2: { failure: "network", retryable: false }, 3: { opaqueLeaseId: "op-3" }, }, }); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); service.step(1); await scheduler.flush(); assert.equal(service.getState().phase, PHASE.ITEM_FAILED); assert.deepEqual( transport.log.filter((e) => e[0] === "fadeOut"), [["fadeOut", 340]], "failure retires the outgoing with the standalone fade" ); }); test("fade transport: an overstaying resolve trips the guard and fades the outgoing", async () => { const { service, scheduler, transport, manifest } = makeFadeService({ plan: { 1: { opaqueLeaseId: "op-1" }, 2: () => new Promise(() => {}), // resolve never returns 3: { opaqueLeaseId: "op-3" }, }, }); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); await drainMicrotasks(); transport.log.length = 0; service.step(1); await scheduler.flush(); // settle fires, pipeline blocks on the resolve, guard armed assert.equal(scheduler.pending(), 1, "overstay guard is armed while the resolve hangs"); await scheduler.flush(); // guard fires assert.deepEqual( transport.log.filter((e) => e[0] === "fadeOut"), [["fadeOut", 340]], "the outgoing is faded out rather than playing on under the new card" ); assert.ok(!transport.log.some((e) => e[0] === "load"), "nothing ever loaded"); }); // ------------------------------------------------------ close/handoff ----- test("close during resolve: terminal, transport torn down, focus released once", async () => { const { service, scheduler, transport, focus, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); // close BEFORE the settle window fires — mid-"resolving" phase const out = await service.close(); assert.equal(service.getState().phase, PHASE.CLOSING); assert.equal(scheduler.pending(), 0, "settle timer cancelled"); assert.ok(transport.log.some((e) => e[0] === "dispose")); assert.equal(focus.calls.release, 1); assert.ok(out.restored); // straggler flush does nothing await scheduler.flush(); assert.ok(!transport.log.some((e) => e[0] === "load")); }); test("play-full handoff: transport stopped, no restoration, main starts with the active item", async () => { const { service, scheduler, transport, focus, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); let started = null; await service.playFullTrack(async (item) => { started = item; }); assert.equal(focus.calls.handoff, 1); assert.equal(started.title, "T1"); assert.ok(transport.log.some((e) => e[0] === "stop")); // closing afterwards must NOT restore the pre-preview main track const out = await service.close(); assert.equal(out.restored, false); }); // -------------------------------------------------------------- controls -- test("togglePlay: pause from playing, resume from paused, replay from ended", async () => { const { service, scheduler, transport, manifest } = makeService(); await service.open({ sessionId: SID, manifest }); await scheduler.flush(); assert.equal(service.getState().phase, PHASE.PLAYING); await service.togglePlay(); assert.equal(service.getState().phase, PHASE.PAUSED); assert.equal(service.getState().desiredPlaying, false); assert.ok(transport.log.some((e) => e[0] === "pause")); await service.togglePlay(); assert.equal(service.getState().phase, PHASE.PLAYING); assert.equal(service.getState().desiredPlaying, true); // ride to the LAST playable item, end its clip, and replay from ENDED service.step(1); await scheduler.flush(); service.step(1); await scheduler.flush(); assert.equal(service.getState().phase, PHASE.PLAYING); transport.end(); assert.equal(service.getState().phase, PHASE.ENDED); await service.togglePlay(); // replay assert.equal(service.getState().phase, PHASE.RESOLVING_MEDIA); assert.equal(service.getState().desiredPlaying, true); });