forked from Keelrleek/SoulStream
src/lib/preview/ — framework-free ESM modules, per design §12–14: - contracts.js: versioned manifest/item/source-ref factories; signed URLs structurally excluded from manifests; Apple identity first-class. - lease.js: discriminated MediaLease union; computeSafeForMs derives lifetimes provider-clock-first with fallback/contradiction handling (margins are ADRs backed by Phase 0 soak evidence). - redact.js: URL redaction (host+hash+exp only) and the HTTPS + exact host allowlist gate applied to initial URLs and every redirect. - manifest.js: artist (rank order, exact-id dedupe), album (disc/track sort, context inheritance) and fixture builders — pure, no fetching. - session.js: reducer with session/generation guards on every async action; keyed active item with derived playable indices/cursor; auto-advance under fresh generations; close is terminal. - resolver.js: PreviewResolver contract + scripted fixture resolver with classified failures (stale_lease/not_found/network/…). - transport.js: transport seam + deterministic fake (manual ticks). - focus.js: revision gateway (all main-player entry points; focus- internal tag) and the coordinator — acquisition only via a generation-bound silence ack, restore only with revisions unchanged. tests/preview/*.test.mjs run via `npm test` (node --test): 61 passing, covering the full Phase 1 list in IMPLEMENTATION-PLAN §6 including the exit gate: stale lease/play events are inert, no restoration after any simulated main command, and no component renders anywhere.
213 lines
8.6 KiB
JavaScript
213 lines
8.6 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 {
|
|
createInitialState,
|
|
reduce,
|
|
PHASE,
|
|
playableManifestIndices,
|
|
playableCursor,
|
|
} from "../../src/lib/preview/session.js";
|
|
|
|
const T0 = 1_700_000_000_000;
|
|
const SID = "sess-1";
|
|
|
|
/** rows: a=available, x=not provided — e.g. "aaxa" */
|
|
function manifestOf(pattern) {
|
|
return buildFixtureManifest({
|
|
rows: [...pattern].map((c, i) => ({
|
|
providerTrackId: String(i + 1),
|
|
title: `T${i + 1}`,
|
|
initialAvailability: c === "a" ? AVAILABILITY.AVAILABLE : AVAILABILITY.NOT_PROVIDED,
|
|
})),
|
|
createdAtMs: T0,
|
|
});
|
|
}
|
|
|
|
function opened(pattern) {
|
|
const s0 = createInitialState(SID);
|
|
return reduce(s0, { type: "MANIFEST_READY", sessionId: SID, manifest: manifestOf(pattern) });
|
|
}
|
|
|
|
const key = (state, i) => state.manifest.items[i].sessionKey;
|
|
|
|
// ------------------------------------------------------------ lifecycle ---
|
|
|
|
test("manifest ready: first playable becomes active with listen intent, generation bumps", () => {
|
|
const s = opened("xaa");
|
|
assert.equal(s.phase, PHASE.RESOLVING_MEDIA);
|
|
assert.equal(s.activeManifestIndex, 1); // index 0 is not playable
|
|
assert.equal(s.requestGeneration, 1);
|
|
assert.equal(s.desiredPlaying, true); // take-audio-on-open decision
|
|
});
|
|
|
|
test("empty manifest → empty phase, no active item", () => {
|
|
const s = opened("xx");
|
|
assert.equal(s.phase, PHASE.EMPTY);
|
|
assert.equal(s.activeItemKey, null);
|
|
});
|
|
|
|
test("close is terminal: nothing lands afterwards", () => {
|
|
let s = opened("aa");
|
|
s = reduce(s, { type: "CLOSE", sessionId: SID });
|
|
assert.equal(s.phase, PHASE.CLOSING);
|
|
const after = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: s.requestGeneration });
|
|
assert.equal(after.phase, PHASE.CLOSING);
|
|
const stepped = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
|
assert.equal(stepped.activeManifestIndex, s.activeManifestIndex);
|
|
});
|
|
|
|
test("session replacement: actions from the old session id are ignored", () => {
|
|
const old = opened("aa");
|
|
const fresh = reduce(createInitialState("sess-2"), {
|
|
type: "MANIFEST_READY",
|
|
sessionId: "sess-2",
|
|
manifest: manifestOf("aa"),
|
|
});
|
|
const poked = reduce(fresh, { type: "STEP", sessionId: SID, dir: 1 }); // old session id
|
|
assert.equal(poked, fresh);
|
|
assert.notEqual(old.sessionId, fresh.sessionId);
|
|
});
|
|
|
|
// ------------------------------------------------------------ navigation --
|
|
|
|
test("step skips unavailable ordinals and never wraps", () => {
|
|
let s = opened("axa"); // playable indices [0, 2]
|
|
assert.equal(s.activeManifestIndex, 0);
|
|
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
|
assert.equal(s.activeManifestIndex, 2); // skipped 1
|
|
const end = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
|
assert.equal(end.activeManifestIndex, 2); // no wrap
|
|
const back = reduce(s, { type: "STEP", sessionId: SID, dir: -1 });
|
|
assert.equal(back.activeManifestIndex, 0);
|
|
const start = reduce(back, { type: "STEP", sessionId: SID, dir: -1 });
|
|
assert.equal(start.activeManifestIndex, 0); // no wrap at the front either
|
|
});
|
|
|
|
test("every activation bumps the generation", () => {
|
|
let s = opened("aaa");
|
|
const g1 = s.requestGeneration;
|
|
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
|
assert.equal(s.requestGeneration, g1 + 1);
|
|
s = reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 0) });
|
|
assert.equal(s.requestGeneration, g1 + 2);
|
|
});
|
|
|
|
test("activating an unavailable or already-active card is refused", () => {
|
|
const s = opened("ax");
|
|
assert.equal(reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 1) }), s);
|
|
assert.equal(reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 0) }), s);
|
|
});
|
|
|
|
// -------------------------------------------------- ordinal/cursor maths --
|
|
|
|
test("playable indices and cursor reflect manifest order and availability", () => {
|
|
const s = opened("axaxa");
|
|
assert.deepEqual(playableManifestIndices(s), [0, 2, 4]);
|
|
assert.equal(playableCursor(s), 0);
|
|
const at4 = reduce(reduce(s, { type: "STEP", sessionId: SID, dir: 1 }), { type: "STEP", sessionId: SID, dir: 1 });
|
|
assert.equal(at4.activeManifestIndex, 4);
|
|
assert.equal(playableCursor(at4), 2);
|
|
});
|
|
|
|
test("a newly available EARLIER item recomputes the cursor but never moves the active card", () => {
|
|
let s = opened("xaa");
|
|
assert.equal(s.activeManifestIndex, 1);
|
|
assert.equal(playableCursor(s), 0);
|
|
s = reduce(s, {
|
|
type: "AVAILABILITY_CHANGED",
|
|
sessionId: SID,
|
|
sessionKey: key(s, 0),
|
|
availability: AVAILABILITY.AVAILABLE,
|
|
});
|
|
assert.equal(s.activeManifestIndex, 1); // card did not move
|
|
assert.equal(s.activeItemKey, key(s, 1));
|
|
assert.equal(playableCursor(s), 1); // cursor recomputed around it
|
|
assert.deepEqual(playableManifestIndices(s), [0, 1, 2]);
|
|
});
|
|
|
|
test("a failed ACTIVE item stays visible in its error state; stepping leaves it", () => {
|
|
let s = opened("aaa");
|
|
const activeKey = s.activeItemKey;
|
|
s = reduce(s, {
|
|
type: "ITEM_FAILED",
|
|
sessionId: SID,
|
|
generation: s.requestGeneration,
|
|
sessionKey: activeKey,
|
|
errorClass: "stale_lease",
|
|
});
|
|
assert.equal(s.phase, PHASE.ITEM_FAILED);
|
|
assert.equal(s.activeItemKey, activeKey); // still the visible card
|
|
assert.equal(playableCursor(s), null); // not currently eligible
|
|
// stepping forward finds the next eligible MANIFEST ordinal
|
|
const stepped = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
|
assert.equal(stepped.activeManifestIndex, 1);
|
|
});
|
|
|
|
// ------------------------------------------------------ stale rejection ---
|
|
|
|
test("a lease from an older generation cannot touch the newer item", () => {
|
|
let s = opened("aaa");
|
|
const staleGen = s.requestGeneration;
|
|
const staleKey = s.activeItemKey;
|
|
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); // gen bumped
|
|
const poked = reduce(s, {
|
|
type: "LEASE_READY",
|
|
sessionId: SID,
|
|
generation: staleGen,
|
|
sessionKey: staleKey,
|
|
clipDurationMs: 30_000,
|
|
});
|
|
assert.equal(poked, s); // dropped entirely
|
|
});
|
|
|
|
test("a stale MEDIA_PLAYING cannot flip the phase", () => {
|
|
let s = opened("aa");
|
|
const staleGen = s.requestGeneration;
|
|
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
|
const poked = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: staleGen });
|
|
assert.equal(poked.phase, s.phase);
|
|
});
|
|
|
|
test("current-generation media flow: lease → buffering → playing → position", () => {
|
|
let s = opened("aa");
|
|
const g = s.requestGeneration;
|
|
s = reduce(s, { type: "LEASE_READY", sessionId: SID, generation: g, sessionKey: s.activeItemKey, clipDurationMs: 30_000 });
|
|
assert.equal(s.phase, PHASE.BUFFERING); // desiredPlaying was true
|
|
assert.equal(s.clipDurationMs, 30_000);
|
|
s = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: g });
|
|
assert.equal(s.phase, PHASE.PLAYING);
|
|
s = reduce(s, { type: "MEDIA_POSITION", sessionId: SID, generation: g, positionMs: 12_000 });
|
|
assert.equal(s.positionMs, 12_000);
|
|
});
|
|
|
|
// ---------------------------------------------------------- auto-advance --
|
|
|
|
test("clip end auto-advances to the next playable item under a fresh generation", () => {
|
|
let s = opened("axa");
|
|
const g = s.requestGeneration;
|
|
s = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: g });
|
|
s = reduce(s, { type: "MEDIA_ENDED", sessionId: SID, generation: g });
|
|
assert.equal(s.activeManifestIndex, 2); // skipped the unavailable ordinal
|
|
assert.equal(s.requestGeneration, g + 1); // stale events from the old clip die
|
|
assert.equal(s.desiredPlaying, true); // keeps playing
|
|
assert.equal(s.phase, PHASE.RESOLVING_MEDIA);
|
|
});
|
|
|
|
test("clip end on the LAST playable item stops there (Replay state)", () => {
|
|
let s = opened("aa");
|
|
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
|
const g = s.requestGeneration;
|
|
s = reduce(s, { type: "MEDIA_ENDED", sessionId: SID, generation: g });
|
|
assert.equal(s.phase, PHASE.ENDED);
|
|
assert.equal(s.desiredPlaying, false);
|
|
assert.equal(s.activeManifestIndex, 1); // stays on the last card
|
|
// Replay restarts the same item with a new generation
|
|
const r = reduce(s, { type: "REPLAY", sessionId: SID });
|
|
assert.equal(r.activeManifestIndex, 1);
|
|
assert.equal(r.requestGeneration, g + 1);
|
|
assert.equal(r.desiredPlaying, true);
|
|
});
|