forked from Keelrleek/SoulStream
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:
1 parent
6dcc62a2fd
commit
8ede6a8adf
14 files changed
+1722
-2
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" }));
|
||||
});
|
||||
Reference in new issue
Block a user