forked from Keelrleek/SoulStream
Opening the preview faded the main track out at focus-acquire time, but the first preview clip only becomes audible after resolve/load/decode — so on the way IN the 340 ms main fade completed into silence before the preview arrived (a gap the user heard; the exit was already fine). The main fade is now deferred: focusBridge.requestSilence only arms the commitment and grants; coordinator.beginMainSilence performs the actual fade-pause, and the service fires it from afterChange on the opening session's first audible clip (or ITEM_FAILED). Main-cos-out then overlaps preview-sin-in — a true cross. resumeMain skips the fade-resume when the fade never fired (close before audible), and beginMainSilence re-checks the main is sounding before fading. The focus coordinator's acquisition logic and tests are untouched. Verified live: preview audible at 275 ms, main played until then, faded ~343 ms, paused at 618 ms and held frozen; close resumed from the exact position in 15 ms.
633 lines
26 KiB
JavaScript
633 lines
26 KiB
JavaScript
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);
|
|
});
|