Phase 1: preview contracts and pure session core, 61 tests

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.
This commit is contained in:
KeelrLeek committed 2026-07-22 18:29:03 -05:00
1 parent 6dcc62a2fd
commit 8ede6a8adf
14 files changed
+1722 -2

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createFocusCoordinator, createRevisionGateway } from "../../src/lib/preview/focus.js";
import { createFixtureResolver } from "../../src/lib/preview/resolver.js";
function harness({ audible = true, ackGeneration, ackApplied = true, ackDelayMs = 0, ackHangs = false } = {}) {
const gateway = createRevisionGateway();
const resumed = [];
const status = {
status: audible ? "playing" : "idle",
trackKey: "track-A",
positionMs: 102_000,
audible,
playbackGeneration: 7,
};
const coord = createFocusCoordinator({
mainStatus: () => ({ ...status }),
requestSilence: (gen) =>
ackHangs
? new Promise(() => {})
: new Promise((res) =>
setTimeout(() => res({ generation: ackGeneration ?? gen, applied: ackApplied }), ackDelayMs)
),
resumeMain: (snapshot, opts) => resumed.push({ snapshot, opts }),
gateway,
ackTimeoutMs: 50,
});
return { coord, gateway, resumed, status };
}
// ------------------------------------------------------------ acquisition --
test("main idle: focus granted without any silence negotiation, nothing to restore", async () => {
const { coord, resumed } = harness({ audible: false });
const { granted, token } = await coord.acquirePreview();
assert.ok(granted);
assert.equal(token.pausedByPreview, false);
const rel = coord.release(token);
assert.equal(rel.restored, false);
assert.equal(rel.reason, "main_was_not_audible");
assert.equal(resumed.length, 0);
});
test("main audible: granted only through a generation-bound acknowledgement", async () => {
const { coord } = harness({ audible: true });
const { granted, token } = await coord.acquirePreview();
assert.ok(granted);
assert.equal(token.pausedByPreview, true);
assert.equal(token.snapshot.trackKey, "track-A");
assert.equal(token.snapshot.positionMs, 102_000);
});
test("stale/wrong-generation ack leaves preview parked", async () => {
const { coord } = harness({ audible: true, ackGeneration: 3 }); // main is at 7
const { granted, token } = await coord.acquirePreview();
assert.equal(granted, false);
assert.equal(token, null);
});
test("ack that never arrives (timeout) leaves preview parked — renderer status is not proof", async () => {
const { coord } = harness({ audible: true, ackHangs: true });
const { granted } = await coord.acquirePreview();
assert.equal(granted, false);
});
test("ack applied=false is not silence", async () => {
const { coord } = harness({ audible: true, ackApplied: false });
const { granted } = await coord.acquirePreview();
assert.equal(granted, false);
});
test("user command DURING negotiation invalidates the grant", async () => {
const { coord, gateway } = harness({ audible: true, ackDelayMs: 20 });
const p = coord.acquirePreview();
gateway.userSeekOrScratch(); // user scrubbed while we negotiated
const { granted } = await p;
assert.equal(granted, false);
});
// -------------------------------------------------------------- release ----
test("clean release restores main at the snapshot, tagged focus-internal", async () => {
const { coord, gateway, resumed } = harness({ audible: true });
const { token } = await coord.acquirePreview();
const rel = coord.release(token);
assert.ok(rel.restored);
assert.equal(resumed.length, 1);
assert.equal(resumed[0].snapshot.trackKey, "track-A");
assert.equal(resumed[0].snapshot.positionMs, 102_000);
assert.equal(resumed[0].opts.focusInternal, true);
// and the focus-internal resume did not invalidate anything
assert.equal(gateway.changedSince(token.revisions), false);
});
for (const [name, bump] of [
["user pause/resume", (g) => g.userPauseResume()],
["user starts another track", (g) => g.userPlay()],
["queue jump / next / previous", (g) => g.queueJumpOrNextPrev()],
["queue mutation", (g) => g.queueMutation()],
["seek/scratch", (g) => g.userSeekOrScratch()],
["stop", (g) => g.userStop()],
["auto-advance", (g) => g.autoAdvance()],
["native source change", (g) => g.nativeSourceChange()],
]) {
test(`no restoration after ${name}`, async () => {
const { coord, gateway, resumed } = harness({ audible: true });
const { token } = await coord.acquirePreview();
bump(gateway);
const rel = coord.release(token);
assert.equal(rel.restored, false);
assert.equal(rel.reason, "revisions_changed");
assert.equal(resumed.length, 0);
});
}
test("focus-internal commands do not invalidate their own token", async () => {
const { coord, gateway } = harness({ audible: true });
const { token } = await coord.acquirePreview();
gateway.userPauseResume({ focusInternal: true }); // the coordinator's own pause
gateway.userPlay({ focusInternal: true });
assert.equal(gateway.changedSince(token.revisions), false);
assert.ok(coord.release(token).restored);
});
test("play-full handoff never restores the old track", async () => {
const { coord, resumed } = harness({ audible: true });
await coord.acquirePreview();
const h = coord.handoffToMain();
assert.equal(h.restored, false);
assert.equal(h.reason, "handoff");
assert.equal(resumed.length, 0);
assert.equal(coord.owner(), "main");
});
test("a stale token cannot release anything", async () => {
const { coord } = harness({ audible: true });
const { token } = await coord.acquirePreview();
coord.release(token);
const again = coord.release(token);
assert.equal(again.restored, false);
assert.equal(again.reason, "stale_token");
});
// ------------------------------------------------------------- resolver ----
test("fixture resolver: scripted fail-once-then-succeed with bypassCache", async () => {
const src = { provider: "deezer", providerTrackId: "42", matchConfidence: "native" };
const resolver = createFixtureResolver({
42: (n) => (n === 1 ? { failure: "stale_lease", retryable: true } : { opaqueLeaseId: "op-fresh" }),
});
const first = await resolver.resolve(src, { generation: 5 });
assert.equal(first.ok, false);
assert.equal(first.failure, "stale_lease");
assert.ok(first.retryable);
const second = await resolver.resolve(src, { generation: 5, bypassCache: true });
assert.ok(second.ok);
assert.equal(second.lease.transport, "native");
assert.equal(second.lease.opaqueLeaseId, "op-fresh");
assert.equal(resolver.callCount("42"), 2);
});
test("fixture resolver: unknown id classifies as not_found, not an exception", async () => {
const resolver = createFixtureResolver({});
const r = await resolver.resolve({ provider: "deezer", providerTrackId: "999", matchConfidence: "native" }, { generation: 1 });
assert.equal(r.ok, false);
assert.equal(r.failure, "not_found");
assert.equal(r.retryable, false);
});
+124
View File
@@ -0,0 +1,124 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
computeSafeForMs,
makeNativeLease,
makeWebviewLease,
isValidLease,
LEASE_FALLBACK_SAFE_MS,
LEASE_SAFETY_MARGIN_MS,
LEASE_MAX_SAFE_MS,
} from "../../src/lib/preview/lease.js";
import { redactUrl, parseSignedExpiryMs, isAllowedMediaUrl } from "../../src/lib/preview/redact.js";
const SRC = Object.freeze({ provider: "deezer", providerTrackId: "1", matchConfidence: "native" });
const NOW = 1_784_760_000_000;
const MIN = 60_000;
// ---------------------------------------------------------- safeForMs -----
test("valid exp + Date: provider-clock lifetime minus margin", () => {
const safe = computeSafeForMs({ expEpochMs: NOW + 14 * MIN, dateHeaderMs: NOW, localNowMs: NOW });
assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS);
});
test("local clock AHEAD of provider: the shorter local view wins", () => {
const safe = computeSafeForMs({
expEpochMs: NOW + 14 * MIN,
dateHeaderMs: NOW,
localNowMs: NOW + 10 * MIN, // local thinks only 4 min remain
});
assert.equal(safe, 4 * MIN - LEASE_SAFETY_MARGIN_MS);
});
test("local clock BEHIND provider: local view cannot extend the lease", () => {
const safe = computeSafeForMs({
expEpochMs: NOW + 14 * MIN,
dateHeaderMs: NOW,
localNowMs: NOW - 60 * MIN, // local view claims 74 min
});
assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS);
});
test("missing exp → short capped fallback", () => {
assert.equal(computeSafeForMs({ expEpochMs: null, dateHeaderMs: NOW, localNowMs: NOW }), LEASE_FALLBACK_SAFE_MS);
});
test("malformed exp → fallback", () => {
assert.equal(computeSafeForMs({ expEpochMs: NaN, dateHeaderMs: NOW, localNowMs: NOW }), LEASE_FALLBACK_SAFE_MS);
});
test("contradictory: exp before Date → fallback", () => {
assert.equal(
computeSafeForMs({ expEpochMs: NOW - MIN, dateHeaderMs: NOW, localNowMs: NOW }),
LEASE_FALLBACK_SAFE_MS
);
});
test("contradictory: absurd multi-hour lifetime → fallback", () => {
assert.equal(
computeSafeForMs({ expEpochMs: NOW + 6 * 60 * MIN, dateHeaderMs: NOW, localNowMs: NOW }),
LEASE_FALLBACK_SAFE_MS
);
});
test("missing Date but sane local view → local view minus margin, capped", () => {
const safe = computeSafeForMs({ expEpochMs: NOW + 14 * MIN, dateHeaderMs: null, localNowMs: NOW });
assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS);
const capped = computeSafeForMs({ expEpochMs: NOW + 90 * MIN, dateHeaderMs: null, localNowMs: NOW });
assert.equal(capped, LEASE_MAX_SAFE_MS - LEASE_SAFETY_MARGIN_MS);
});
test("already expired with no Date → fallback (refresh immediately-ish, never negative)", () => {
assert.equal(
computeSafeForMs({ expEpochMs: NOW - MIN, dateHeaderMs: null, localNowMs: NOW }),
LEASE_FALLBACK_SAFE_MS
);
});
// ------------------------------------------------------ union validity ----
test("discriminated union: native carries exactly an opaque id", () => {
const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 1000 });
assert.ok(isValidLease(lease));
assert.equal(lease.url, undefined);
assert.ok(!isValidLease({ ...lease, url: "https://x" }));
});
test("discriminated union: webview carries exactly a url", () => {
const lease = makeWebviewLease({ source: SRC, url: "https://cdnt-preview.dzcdn.net/a.mp3", safeForMs: 1000 });
assert.ok(isValidLease(lease));
assert.equal(lease.opaqueLeaseId, undefined);
assert.ok(!isValidLease({ ...lease, opaqueLeaseId: "x" }));
});
test("constructors refuse the wrong locator", () => {
assert.throws(() => makeNativeLease({ source: SRC, safeForMs: 1 }));
assert.throws(() => makeWebviewLease({ source: SRC, safeForMs: 1 }));
});
// ------------------------------------------------------------ redaction ---
test("redactUrl yields host + hash + exp, never the url", () => {
const url = "https://cdnt-preview.dzcdn.net/api/x.mp3?hdnea=exp=1784761537~acl=/x*~hmac=deadbeef";
const r = redactUrl(url);
assert.equal(r.host, "cdnt-preview.dzcdn.net");
assert.equal(r.expMs, 1784761537000);
assert.match(r.hash, /^[0-9a-f]{8}$/);
assert.ok(!JSON.stringify(r).includes("hmac"));
});
test("parseSignedExpiryMs handles bare exp params and garbage", () => {
assert.equal(parseSignedExpiryMs("https://h/x?exp=1784761537"), 1784761537000);
assert.equal(parseSignedExpiryMs("https://h/x"), null);
assert.equal(parseSignedExpiryMs(null), null);
});
test("host policy: https + exact allowlist, redirects re-checked by the same gate", () => {
assert.ok(isAllowedMediaUrl("https://cdnt-preview.dzcdn.net/a.mp3"));
assert.ok(!isAllowedMediaUrl("http://cdnt-preview.dzcdn.net/a.mp3")); // scheme
assert.ok(!isAllowedMediaUrl("https://evil.dzcdn.net/a.mp3")); // host
assert.ok(!isAllowedMediaUrl("https://cdnt-preview.dzcdn.net.evil.com/a.mp3")); // suffix trick
assert.ok(!isAllowedMediaUrl("not a url"));
});
+122
View File
@@ -0,0 +1,122 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { AVAILABILITY, isValidManifest } from "../../src/lib/preview/contracts.js";
import { buildArtistManifest, buildAlbumManifest, buildFixtureManifest } from "../../src/lib/preview/manifest.js";
const T0 = 1_700_000_000_000;
const catalogTrack = (id, over = {}) => ({
id,
title: `Track ${id}`,
artist: "Röyksopp",
album: "Melody A.M.",
album_id: 9,
cover: `cover-${id}`,
duration_secs: 219,
preview: `https://cdnt-preview.dzcdn.net/x-${id}.mp3?hdnea=exp=1784761537~hmac=aa`,
track_no: 1,
disk_number: 1,
unreleased: false,
...over,
});
test("artist manifest: rank order kept, exact provider-id dupes collapse", () => {
const m = buildArtistManifest({
artist: { id: 480, name: "Röyksopp", picture: "pic" },
popularTracks: [catalogTrack(1), catalogTrack(2), catalogTrack(1), catalogTrack(3)],
createdAtMs: T0,
});
assert.equal(m.items.length, 3); // dupe of id 1 collapsed
assert.deepEqual(m.items.map((i) => i.sources[0].providerTrackId), ["1", "2", "3"]);
assert.deepEqual(m.items.map((i) => i.sourceOrdinal), [0, 1, 2]);
assert.equal(m.context.kind, "artist");
assert.equal(m.context.sourceTrackCount, 4); // source count is truthful
assert.ok(isValidManifest(m));
});
test("artist manifest: same title, different provider ids do NOT collapse", () => {
const m = buildArtistManifest({
artist: { id: 480, name: "Röyksopp" },
popularTracks: [catalogTrack(1, { title: "Same" }), catalogTrack(2, { title: "Same" })],
createdAtMs: T0,
});
assert.equal(m.items.length, 2);
});
test("availability from catalog rows: preview/empty/unreleased", () => {
const m = buildArtistManifest({
artist: { id: 1, name: "A" },
popularTracks: [
catalogTrack(1),
catalogTrack(2, { preview: "" }),
catalogTrack(3, { preview: undefined }),
catalogTrack(4, { unreleased: true }),
],
createdAtMs: T0,
});
assert.deepEqual(
m.items.map((i) => i.initialAvailability),
[AVAILABILITY.AVAILABLE, AVAILABILITY.NOT_PROVIDED, AVAILABILITY.NOT_PROVIDED, AVAILABILITY.UNRELEASED]
);
// unreleased rows lose their mutating actions
assert.equal(m.items[3].actions.playFull, false);
assert.equal(m.items[3].actions.save, false);
});
test("no signed URL survives into the manifest", () => {
const m = buildArtistManifest({
artist: { id: 1, name: "A" },
popularTracks: [catalogTrack(1)],
createdAtMs: T0,
});
const json = JSON.stringify(m);
assert.ok(!json.includes("hdnea"), "signed URL leaked into manifest");
assert.ok(!json.includes("cdnt-preview"), "media host leaked into manifest");
});
test("album manifest: disc/track sort with stable fallback + context inheritance", () => {
const m = buildAlbumManifest({
album: { id: 77, title: "The Wall", artist: "Pink Floyd", cover: "wall.jpg" },
tracks: [
catalogTrack(21, { disk_number: 2, track_no: 1, album: "", album_id: null, cover: null }),
catalogTrack(11, { disk_number: 1, track_no: 1 }),
catalogTrack(12, { disk_number: 1, track_no: 2 }),
catalogTrack(13, { disk_number: null, track_no: null }), // sorts by response order within disc 1
],
createdAtMs: T0,
});
assert.deepEqual(m.items.map((i) => i.sources[0].providerTrackId), ["13", "11", "12", "21"]);
const disc2 = m.items[3];
assert.equal(disc2.album, "The Wall"); // inherited
assert.equal(disc2.albumId, "77");
assert.equal(disc2.artwork, "wall.jpg");
assert.equal(m.context.sourceTrackCount, 4);
});
test("fixture manifest: playlist duplicates keep distinct sessionKeys, shared recordingKey", () => {
const m = buildFixtureManifest({
kind: "playlist",
rows: [
{ providerTrackId: "9", title: "Song" },
{ providerTrackId: "8", title: "Other" },
{ providerTrackId: "9", title: "Song" }, // duplicate occurrence
],
createdAtMs: T0,
});
assert.equal(m.items.length, 3);
assert.notEqual(m.items[0].sessionKey, m.items[2].sessionKey);
assert.equal(m.items[0].recordingKey, m.items[2].recordingKey);
assert.ok(isValidManifest(m));
});
test("fixture manifest: apple identity is a first-class source ref", () => {
const m = buildFixtureManifest({
rows: [{ provider: "apple", providerTrackId: "ap-123", isrc: "USX9P1234567", matchConfidence: "isrc", title: "X" }],
createdAtMs: T0,
});
const src = m.items[0].sources[0];
assert.equal(src.provider, "apple");
assert.equal(src.providerTrackId, "ap-123");
assert.equal(src.matchConfidence, "isrc");
});
+212
View File
@@ -0,0 +1,212 @@
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);
});
+85
View File
@@ -0,0 +1,85 @@
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 } from "../../src/lib/preview/session.js";
import { createFixtureTransport } from "../../src/lib/preview/transport.js";
import { makeNativeLease } from "../../src/lib/preview/lease.js";
const SID = "sess-t";
const T0 = 1_700_000_000_000;
const SRC = { provider: "deezer", providerTrackId: "1", matchConfidence: "native" };
function openSession() {
const manifest = buildFixtureManifest({
rows: [
{ providerTrackId: "1", title: "One", initialAvailability: AVAILABILITY.AVAILABLE },
{ providerTrackId: "2", title: "Two", initialAvailability: AVAILABILITY.AVAILABLE },
],
createdAtMs: T0,
});
return reduce(createInitialState(SID), { type: "MANIFEST_READY", sessionId: SID, manifest });
}
test("end-to-end against the fake transport: load → play → ticks → auto-advance", () => {
let state = openSession();
const transport = createFixtureTransport((action) => {
state = reduce(state, action);
});
const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 300_000, clipDurationMs: 30_000 });
state = reduce(state, {
type: "LEASE_READY", sessionId: SID, generation: state.requestGeneration,
sessionKey: state.activeItemKey, clipDurationMs: 30_000,
});
transport.load(lease, { sessionId: SID, generation: state.requestGeneration, sessionKey: state.activeItemKey });
transport.play();
assert.equal(state.phase, PHASE.PLAYING);
transport.tick(12_000);
assert.equal(state.positionMs, 12_000);
transport.tick(18_000); // reaches clip end → MEDIA_ENDED → auto-advance
assert.equal(state.activeManifestIndex, 1);
assert.equal(state.phase, PHASE.RESOLVING_MEDIA);
assert.equal(state.desiredPlaying, true);
});
test("events after a step carry the old generation and are inert", () => {
let state = openSession();
const transport = createFixtureTransport((action) => {
state = reduce(state, action);
});
const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 300_000 });
const genAtLoad = state.requestGeneration;
transport.load(lease, { sessionId: SID, generation: genAtLoad, sessionKey: state.activeItemKey });
transport.play();
assert.equal(state.phase, PHASE.PLAYING);
// user steps: the reducer bumps the generation…
state = reduce(state, { type: "STEP", sessionId: SID, dir: 1 });
const after = state;
// …but the un-stopped transport keeps ticking with the OLD generation
transport.tick(5_000);
assert.equal(state.positionMs, after.positionMs, "stale position must not land");
assert.equal(state.phase, PHASE.RESOLVING_MEDIA);
});
test("invalid lease surfaces ITEM_FAILED(invalid_media) instead of playing", () => {
let state = openSession();
const transport = createFixtureTransport((action) => {
state = reduce(state, action);
});
const bogus = { transport: "native", safeForMs: 100 }; // no opaqueLeaseId
transport.load(bogus, { sessionId: SID, generation: state.requestGeneration, sessionKey: state.activeItemKey });
assert.equal(state.phase, PHASE.ITEM_FAILED);
assert.equal(state.errorClass, "invalid_media");
});
test("dispose is terminal and idempotent", () => {
const transport = createFixtureTransport(() => {});
transport.dispose();
transport.dispose();
assert.throws(() => transport.load({}, { sessionId: SID, generation: 1, sessionKey: "k" }));
});