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,277 @@
|
||||
// Pure preview-session reducer — design §12 runtime state, §9 navigation
|
||||
// invariants. No timers, no I/O, no Date.now(): every action carries what it
|
||||
// needs, so the whole machine replays deterministically under node:test.
|
||||
//
|
||||
// THE one rule everything else hangs off: any action produced by async work
|
||||
// (resolver, transport) carries {sessionId, generation}, and the reducer
|
||||
// drops it unless BOTH match the current state. A late lease from generation
|
||||
// 3 can never start audio under the cover generation 4 is showing.
|
||||
|
||||
import { AVAILABILITY } from "./contracts.js";
|
||||
|
||||
export const PHASE = Object.freeze({
|
||||
RESOLVING_MANIFEST: "resolving_manifest",
|
||||
READY: "ready",
|
||||
RESOLVING_MEDIA: "resolving_media",
|
||||
BUFFERING: "buffering",
|
||||
PLAYING: "playing",
|
||||
PAUSED: "paused",
|
||||
ENDED: "ended",
|
||||
ITEM_FAILED: "item_failed",
|
||||
EMPTY: "empty",
|
||||
CLOSING: "closing",
|
||||
});
|
||||
|
||||
export function createInitialState(sessionId) {
|
||||
if (!sessionId) throw new Error("sessionId required");
|
||||
return Object.freeze({
|
||||
sessionId,
|
||||
phase: PHASE.RESOLVING_MANIFEST,
|
||||
manifest: null,
|
||||
itemRuntimeByKey: Object.freeze({}),
|
||||
activeItemKey: null,
|
||||
activeManifestIndex: null,
|
||||
requestGeneration: 0,
|
||||
desiredPlaying: false,
|
||||
muted: false,
|
||||
positionMs: 0,
|
||||
clipDurationMs: undefined,
|
||||
focusToken: null,
|
||||
errorClass: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ derivations --
|
||||
|
||||
/** Effective availability: runtime overrides the manifest's initial value. */
|
||||
export function effectiveAvailability(state, item) {
|
||||
return state.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
|
||||
}
|
||||
|
||||
/** Manifest indices whose items are currently playable, in manifest order.
|
||||
* Derived every time — never stored, so it can't drift (design §9). */
|
||||
export function playableManifestIndices(state) {
|
||||
if (!state.manifest) return [];
|
||||
const out = [];
|
||||
state.manifest.items.forEach((item, i) => {
|
||||
if (effectiveAvailability(state, item) === AVAILABILITY.AVAILABLE) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Position of the active item inside the playable list, or null when the
|
||||
* active item is not currently eligible (e.g. it failed and is being shown
|
||||
* in its error state). The active item is identified by sessionKey + index —
|
||||
* never by this cursor (design §9: availability changes recompute the cursor
|
||||
* AROUND the stable item; they never move the item). */
|
||||
export function playableCursor(state) {
|
||||
if (state.activeManifestIndex == null) return null;
|
||||
const idx = playableManifestIndices(state).indexOf(state.activeManifestIndex);
|
||||
return idx === -1 ? null : idx;
|
||||
}
|
||||
|
||||
function itemAt(state, manifestIndex) {
|
||||
return state.manifest?.items[manifestIndex] ?? null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- reducer ----
|
||||
|
||||
function guard(state, action, { needGeneration }) {
|
||||
if (action.sessionId !== state.sessionId) return false;
|
||||
if (state.phase === PHASE.CLOSING) return false; // terminal: nothing lands
|
||||
if (needGeneration && action.generation !== state.requestGeneration) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function withRuntime(state, sessionKey, patch) {
|
||||
const prev = state.itemRuntimeByKey[sessionKey] ?? {
|
||||
availability: undefined,
|
||||
mediaPhase: "idle",
|
||||
errorClass: undefined,
|
||||
};
|
||||
return Object.freeze({
|
||||
...state.itemRuntimeByKey,
|
||||
[sessionKey]: Object.freeze({ ...prev, ...patch }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Activate the item at `manifestIndex`, bumping the generation — the
|
||||
* common core of first-pick, step, click and auto-advance. */
|
||||
function activate(state, manifestIndex, { keepDesired }) {
|
||||
const item = itemAt(state, manifestIndex);
|
||||
if (!item) return state;
|
||||
return Object.freeze({
|
||||
...state,
|
||||
phase: PHASE.RESOLVING_MEDIA,
|
||||
activeItemKey: item.sessionKey,
|
||||
activeManifestIndex: manifestIndex,
|
||||
requestGeneration: state.requestGeneration + 1,
|
||||
desiredPlaying: keepDesired ? state.desiredPlaying : state.desiredPlaying,
|
||||
positionMs: 0,
|
||||
clipDurationMs: undefined,
|
||||
errorClass: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function reduce(state, action) {
|
||||
switch (action.type) {
|
||||
// ---- session lifecycle ------------------------------------------
|
||||
case "MANIFEST_READY": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
const next = Object.freeze({ ...state, manifest: action.manifest });
|
||||
const playable = playableManifestIndices(next);
|
||||
if (playable.length === 0) {
|
||||
return Object.freeze({ ...next, phase: PHASE.EMPTY });
|
||||
}
|
||||
// Opening takes audio (decision 2026-07-22): the first playable
|
||||
// item becomes active with listen intent already set; the focus
|
||||
// coordinator upstream decides when sound may actually start.
|
||||
return activate(
|
||||
Object.freeze({ ...next, desiredPlaying: true }),
|
||||
action.startAtManifestIndex != null && playable.includes(action.startAtManifestIndex)
|
||||
? action.startAtManifestIndex
|
||||
: playable[0],
|
||||
{ keepDesired: true }
|
||||
);
|
||||
}
|
||||
case "CLOSE": {
|
||||
if (action.sessionId !== state.sessionId) return state;
|
||||
return Object.freeze({ ...state, phase: PHASE.CLOSING, desiredPlaying: false, focusToken: null });
|
||||
}
|
||||
|
||||
// ---- navigation (user intent, current-generation by definition) --
|
||||
case "STEP": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
const playable = playableManifestIndices(state);
|
||||
if (playable.length === 0) return state;
|
||||
const cur = playableCursor(state);
|
||||
let target;
|
||||
if (cur != null) {
|
||||
target = cur + action.dir;
|
||||
} else {
|
||||
// Active item isn't eligible (failed): move to the next
|
||||
// eligible MANIFEST ordinal in the step direction (design §9).
|
||||
const from = state.activeManifestIndex ?? -1;
|
||||
target =
|
||||
action.dir > 0
|
||||
? playable.findIndex((i) => i > from)
|
||||
: (() => {
|
||||
let last = -1;
|
||||
playable.forEach((i, k) => {
|
||||
if (i < from) last = k;
|
||||
});
|
||||
return last;
|
||||
})();
|
||||
}
|
||||
if (target < 0 || target >= playable.length) return state; // no wrap
|
||||
return activate(state, playable[target], { keepDesired: true });
|
||||
}
|
||||
case "ACTIVATE_KEY": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
const idx = state.manifest?.items.findIndex((it) => it.sessionKey === action.sessionKey) ?? -1;
|
||||
if (idx === -1) return state;
|
||||
const item = state.manifest.items[idx];
|
||||
if (effectiveAvailability(state, item) !== AVAILABILITY.AVAILABLE) return state;
|
||||
if (idx === state.activeManifestIndex) return state;
|
||||
return activate(state, idx, { keepDesired: true });
|
||||
}
|
||||
case "REPLAY": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
if (state.activeManifestIndex == null) return state;
|
||||
return Object.freeze({
|
||||
...activate(state, state.activeManifestIndex, { keepDesired: true }),
|
||||
desiredPlaying: true,
|
||||
});
|
||||
}
|
||||
case "SET_DESIRED_PLAYING": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
return Object.freeze({ ...state, desiredPlaying: !!action.value });
|
||||
}
|
||||
case "SET_MUTED": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
return Object.freeze({ ...state, muted: !!action.value });
|
||||
}
|
||||
case "FOCUS_SET": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
return Object.freeze({ ...state, focusToken: action.token ?? null });
|
||||
}
|
||||
|
||||
// ---- async completions (generation-gated) ------------------------
|
||||
case "RESOLVE_STARTED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
if (action.sessionKey !== state.activeItemKey) return state;
|
||||
return Object.freeze({
|
||||
...state,
|
||||
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "resolving" }),
|
||||
});
|
||||
}
|
||||
case "LEASE_READY": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
if (action.sessionKey !== state.activeItemKey) return state;
|
||||
return Object.freeze({
|
||||
...state,
|
||||
phase: state.desiredPlaying ? PHASE.BUFFERING : PHASE.READY,
|
||||
clipDurationMs: action.clipDurationMs ?? state.clipDurationMs,
|
||||
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "ready" }),
|
||||
});
|
||||
}
|
||||
case "MEDIA_PLAYING": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
return Object.freeze({ ...state, phase: PHASE.PLAYING });
|
||||
}
|
||||
case "MEDIA_PAUSED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
return Object.freeze({ ...state, phase: PHASE.PAUSED });
|
||||
}
|
||||
case "MEDIA_POSITION": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
return Object.freeze({ ...state, positionMs: action.positionMs });
|
||||
}
|
||||
case "MEDIA_ENDED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
// Auto-advance (decision 2026-07-22): roll into the next playable
|
||||
// item under the SAME generation rules — activate() bumps, so any
|
||||
// straggler event from the finished clip is stale by construction.
|
||||
const playable = playableManifestIndices(state);
|
||||
const cur = playableCursor(state);
|
||||
if (cur != null && cur + 1 < playable.length) {
|
||||
return activate(state, playable[cur + 1], { keepDesired: true });
|
||||
}
|
||||
return Object.freeze({ ...state, phase: PHASE.ENDED, desiredPlaying: false });
|
||||
}
|
||||
case "ITEM_FAILED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
const runtime = withRuntime(state, action.sessionKey, {
|
||||
availability: action.availability ?? AVAILABILITY.TEMP_FAILED,
|
||||
mediaPhase: "failed",
|
||||
errorClass: action.errorClass,
|
||||
});
|
||||
const isActive = action.sessionKey === state.activeItemKey;
|
||||
return Object.freeze({
|
||||
...state,
|
||||
itemRuntimeByKey: runtime,
|
||||
// A failed ACTIVE item stays visible in its error state
|
||||
// (design §9) — no auto-skip, the user retries or steps.
|
||||
phase: isActive ? PHASE.ITEM_FAILED : state.phase,
|
||||
errorClass: isActive ? action.errorClass : state.errorClass,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- runtime availability (metadata, NOT generation-gated) -------
|
||||
case "AVAILABILITY_CHANGED": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
// Only itemRuntimeByKey changes: a newly available EARLIER item
|
||||
// must never move the current card (design §9) — and it can't,
|
||||
// because the active item is keyed, not cursor-positioned.
|
||||
return Object.freeze({
|
||||
...state,
|
||||
itemRuntimeByKey: withRuntime(state, action.sessionKey, {
|
||||
availability: action.availability,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user