screentinker/server/test/preview-skip.test.js
ScreenTinker aa77332c0d Let the playlist preview skip, so reviewing item 8 does not cost seven durations
The preview shipped without the skip control #104 asked for, so checking a late item meant
watching every item before it in real time — the thing operators do most when ordering a
playlist with a client on the phone.

The preview is already the real player in device-free mode (an iframe of /player?preview=1),
so this drives that instance rather than growing a second playback implementation: the
dashboard posts next/prev to the one contentWindow, the player steps its own currentIndex and
re-renders through the same path a natural advance uses, and posts back index/total so the
modal can say "3 of 7".

Nothing here can reach a live screen. A real display is driven over its server socket and holds
no window handle this page could address; the message listener is installed only by the preview
boot path, previewNavigate refuses outside PREVIEW_MODE, and both ends pin the origin.

Stepping is schedule-aware in the direction of travel — falling forward past a dayparted item
would make "previous" walk forwards — and a multi-zone playlist reports itself as such, because
all zones play at once and a counter there would be a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:35:16 -05:00

172 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// #239: the playlist preview had no way to skip, so reviewing item 8 of a playlist meant sitting
// through items 17 in real time. The fix drives the player the preview ALREADY embeds (an iframe
// of /player?preview=1) over postMessage, so there is no second copy of the playback logic.
//
// Two things can go wrong, and neither shows up in a screenshot:
//
// 1. The index maths. A negative modulo or an off-by-one lands on an index that isn't in the
// playlist, and playlist[idx] === undefined mounts nothing — the operator sees a black frame
// and assumes the content is broken. "Previous" is the dangerous direction: JS's % returns a
// negative for a negative left operand.
// 2. The blast radius. A control that reached a REAL display would let an operator skip content
// on a wall in front of customers — far worse than the bug being fixed. So the channel is
// asserted here at the source level: only a player that booted in preview mode may be steered,
// and the dashboard addresses exactly one iframe rather than broadcasting.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const PLAYER = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
const DASHBOARD = fs.readFileSync(
path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'playlists.js'), 'utf8');
// Same lift as the other player tests: pull one pure function out of the single-file player and
// run it directly, so the maths is testable without a browser.
function lift(name) {
const start = PLAYER.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name}() should exist in the player`);
let depth = 0;
for (let j = PLAYER.indexOf('{', start); j < PLAYER.length; j++) {
if (PLAYER[j] === '{') depth++;
else if (PLAYER[j] === '}' && --depth === 0) {
return new Function(`${PLAYER.slice(start, j + 1)} return ${name};`)();
}
}
throw new Error('unbalanced braces reading ' + name);
}
const previewStepIndex = lift('previewStepIndex');
const NEXT = 1;
const PREV = -1;
test('next walks the playlist in order', () => {
assert.equal(previewStepIndex(0, NEXT, 7), 1);
assert.equal(previewStepIndex(5, NEXT, 7), 6);
});
test('next wraps past the last item back to the first', () => {
assert.equal(previewStepIndex(6, NEXT, 7), 0);
});
test('THE BUG previous must not reproduce: stepping back from item 1 wraps to the LAST item', () => {
// (0 - 1) % 7 is -1 in JavaScript, and playlist[-1] is undefined — a black frame.
assert.equal(previewStepIndex(0, PREV, 7), 6);
});
test('previous walks backwards', () => {
assert.equal(previewStepIndex(6, PREV, 7), 5);
assert.equal(previewStepIndex(1, PREV, 7), 0);
});
test('a one-item playlist lands on that item in either direction', () => {
// Re-rendering the single item is the honest answer: there is nowhere else to go, and returning
// -1 would idle a preview that has perfectly good content.
assert.equal(previewStepIndex(0, NEXT, 1), 0);
assert.equal(previewStepIndex(0, PREV, 1), 0);
});
test('an empty playlist has nowhere to go', () => {
assert.equal(previewStepIndex(0, NEXT, 0), -1);
assert.equal(previewStepIndex(-1, PREV, 0), -1);
});
test('a player that has not started yet still lands on a real item', () => {
// currentIndex is -1 until the first item mounts; a press during that window must not produce
// -2 or NaN.
assert.equal(previewStepIndex(-1, NEXT, 4), 0);
assert.equal(previewStepIndex(-1, PREV, 4), 2);
});
test('a corrupt or out-of-range index is normalised, never propagated', () => {
assert.equal(previewStepIndex(NaN, NEXT, 4), 1);
assert.equal(previewStepIndex(undefined, NEXT, 4), 1);
assert.equal(previewStepIndex(9, NEXT, 4), 2); // 9 % 4 === 1 -> next is 2
assert.equal(previewStepIndex(-9, NEXT, 4), 0); // -9 normalises to 3 -> wraps to 0
assert.equal(previewStepIndex(1.7, NEXT, 4), 2);
});
test('a non-numeric length is treated as no playlist rather than looping forever', () => {
assert.equal(previewStepIndex(0, NEXT, NaN), -1);
assert.equal(previewStepIndex(0, NEXT, undefined), -1);
assert.equal(previewStepIndex(0, NEXT, -3), -1);
});
test('dayparted items are skipped IN THE DIRECTION OF TRAVEL', () => {
// Falling forward past a filtered item would make "previous" walk forwards, which reads to the
// operator as the button being broken.
const off = new Set([1, 2]);
const allows = (i) => !off.has(i);
assert.equal(previewStepIndex(0, NEXT, 5, allows), 3);
assert.equal(previewStepIndex(3, PREV, 5, allows), 0);
assert.equal(previewStepIndex(0, PREV, 5, allows), 4);
});
test('every item outside its schedule window means nowhere to go, not item 0', () => {
// The caller idles on -1. Returning an index here would mount content the schedule says must not
// be on screen — the preview would then be lying about what the display will show.
assert.equal(previewStepIndex(2, NEXT, 5, () => false), -1);
assert.equal(previewStepIndex(2, PREV, 5, () => false), -1);
});
test('a step lands on an allowed item even when it has to wrap the whole list', () => {
const allows = (i) => i === 0;
assert.equal(previewStepIndex(0, NEXT, 6, allows), 0);
assert.equal(previewStepIndex(4, PREV, 6, allows), 0);
});
// ---- Blast radius: the control must never reach a live display ----
test('only a preview instance can be steered', () => {
// previewNavigate is the single entry point for a skip, and PREVIEW_MODE is set exactly once,
// in the ?preview=1 boot path (renderPreviewFromUrl) — a paired display never sets it.
const nav = PLAYER.slice(PLAYER.indexOf('function previewNavigate('));
assert.match(nav.slice(0, 200), /if \(!PREVIEW_MODE\) return;/,
'previewNavigate must refuse to act outside preview mode');
assert.equal((PLAYER.match(/PREVIEW_MODE = true;/g) || []).length, 1,
'preview mode should have exactly one assignment — the preview boot path');
});
test('the message listener exists only in preview mode and only for our own origin', () => {
// A live player never installs the listener at all, so a page that iframes a real display cannot
// even attempt a skip; the origin check stops a third-party framer of the preview itself.
const calls = PLAYER.match(/installPreviewControlChannel\(\)/g) || [];
assert.equal(calls.length, 2, 'expected one definition call site plus the preview boot call');
const boot = PLAYER.slice(PLAYER.indexOf('PREVIEW_MODE = true;'),
PLAYER.indexOf('PREVIEW_MODE = true;') + 200);
assert.match(boot, /installPreviewControlChannel\(\)/,
'the channel must be installed by the preview boot, not at load time');
const channel = PLAYER.slice(PLAYER.indexOf('function installPreviewControlChannel('));
assert.match(channel.slice(0, 800), /ev\.origin !== window\.location\.origin/,
'cross-origin messages must be rejected');
assert.match(channel.slice(0, 800), /d\.source !== 'screentinker-preview'/,
'unrelated postMessage traffic (extensions, embeds) must be ignored');
});
test('preview state is posted to our origin only — never "*"', () => {
// "*" would hand the workspace's playlist contents to any page that framed the player.
const post = PLAYER.slice(PLAYER.indexOf('function postPreviewState('));
assert.match(post.slice(0, 900), /window\.location\.origin\)/);
assert.doesNotMatch(post.slice(0, 900), /postMessage\([^)]*'\*'/);
});
test('the dashboard addresses the preview iframe, not a broadcast', () => {
// Broadcasting would still not reach a display (they hold a server socket, not a window handle),
// but addressing one contentWindow keeps the intent unambiguous and pins the target origin.
assert.match(DASHBOARD, /frame\.contentWindow\?\.postMessage\(\{ source: 'screentinker-preview', action \}, window\.location\.origin\)/);
assert.match(DASHBOARD, /ev\.source !== frame\.contentWindow/);
assert.match(DASHBOARD, /ev\.origin !== window\.location\.origin/);
});
test('the preview modal cleans up its window-level listeners on close', () => {
// They outlive the overlay otherwise, and a leaked keydown handler keeps posting arrow-key skips
// at a preview the operator already closed.
const modal = DASHBOARD.slice(DASHBOARD.indexOf('function showPlaylistPreview('),
DASHBOARD.indexOf('function layoutMockup('));
assert.match(modal, /window\.removeEventListener\('message', onPlayerMessage\)/);
assert.match(modal, /document\.removeEventListener\('keydown', onKey\)/);
});