Files
hockey_new/tests/runtime_timer_transport.cjs

297 lines
17 KiB
JavaScript

// Behavioral timer contracts using the actual browser functions, offline only.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../ui_builder/static/app.js'), 'utf8');
const plain = value => JSON.parse(JSON.stringify(value));
function definition(name) {
const start = source.search(new RegExp('^ (?:async )?function ' + name + '\\(', 'm'));
assert.ok(start >= 0, `Production function missing: ${name}`);
const end = source.indexOf('\n }', start);
assert.ok(end > start, `Production function boundary missing: ${name}`);
return source.slice(start, end + 4);
}
// Load actual transitive production dependencies while retaining explicit I/O stubs.
function productionClosure(context, roots) {
const functions = new Map(), pattern = /^( {0,2})(?:async )?function (\w+)\(/gm;
let match;
while ((match = pattern.exec(source))) {
const start = match.index, lineEnd = source.indexOf('\n', start);
const singleLine = /\}\s*;?\s*$/.test(source.slice(start, lineEnd));
const end = singleLine ? lineEnd : source.indexOf('\n' + match[1] + '}', start) + match[1].length + 2;
assert.ok(end > start, 'Unclosed production helper ' + match[2]);
functions.set(match[2], source.slice(start, end));
}
const needed = new Set(roots);
function visit(name) {
assert.ok(functions.has(name), 'Missing actual production helper ' + name);
for (const candidate of functions.keys()) {
if (needed.has(candidate) || typeof context[candidate] === 'function') continue;
if (new RegExp('\\b' + candidate + '\\b').test(functions.get(name))) {
needed.add(candidate); visit(candidate);
}
}
}
roots.forEach(visit);
return [...needed].map(name => functions.get(name)).join('\n');
}
function runtime(names, extra = {}) {
const state = {
config: { components: [], shortcut_sequences: [] }, data: {},
pressedShortcutKeys: new Set(), pressedShortcutModifiers: new Set(),
activeHockeyVmixTimerSteps: new Set(), vmixPenaltyTargetAssignments: new Map(),
vmixPenaltyMirrors: new Map(), vmixGameCountdownSeededTargets: new Set(),
runningShortcutSequences: new Set(), pendingShortcutSequenceRuns: new Map(),
vmixCommandQueue: new Promise(() => {}),
};
const context = {
state, console: { warn() {}, error() {} }, window: {},
Promise, setTimeout, clearTimeout, queueMicrotask,
padTimer: value => String(value).padStart(2, '0'), hockeyCanControl: () => true,
currentRuntimeVmixDeviceId: () => 'LIVE-DEVICE',
currentRuntimeHockeySessionToken: () => 'match-session',
trackRuntimeOverlayCommands() {}, applyServerRuntimeOverlayState() {},
...extra,
};
vm.createContext(context);
const presentation = ['getByPath', 'hockeyTimerSelectedGameId', 'hockeyScoreboardPresentationContext',
'hockeyObserveScoreboardPresentation', 'hockeySuppressScoreboardAuxiliaries',
// Load the production dependency closure added to the transport/shortcut.
'hockeyGameControlLanguage', 'hockeyVmixCommandIsUrgent', 'hockeyVmixUrgentGroup',
'hockeyVmixTraceNow', 'hockeyVmixTransportContext', 'hockeyVmixTrace', 'hockeyVmixPhysicalInput',
'hockeyVmixVisibilityIntent', 'hockeyRequireTitleMappingReady', 'hockeyTrackVmixDelivery',
'vmixOverlayLayer', 'hockeyShortcutOutTargets', 'hockeyShortcutExecution', 'hockeyRunShortcutSequenceOut',
'hockeyHandleAuxiliaryShortcut', 'hockeyAuxiliaryGateContext', 'hockeyScoreboardAuxiliariesEnabled',
'hockeyMatchFlags', 'hockeyAuxiliaryVisibilityCommands', 'hockeyScoreboardAuxiliaryCommand'];
vm.runInContext(productionClosure(context, [...new Set([...presentation, ...names])]), context);
return context;
}
const nativeNames = ['vmixCountdownValue', 'vmixGameCountdownControlCommands'];
const scenarios = {
async key_edges_web_first() {
const game = { running: false, startedOnce: false, currentMs: 1200000 };
const component = { action_id: 'game' };
const step = { id: 'timer', type: 'hockey_vmix_timers_start', enabled: true,
hockey_timer_command: 'toggle', game_timer_action_id: 'game', start_web_game: true,
sync_vmix_game: true, game_vmix_input: 'SCORE', game_vmix_selected_name: 'CLOCK.Text',
sync_vmix_penalties: false, start_web_penalties: false };
const sequence = { id: 'space', combo: 'Space', enabled: true, steps: [step] };
const sends = [], runs = [];
const c = runtime([...nativeNames, 'normalizeShortcutCombo', 'shortcutModifierFromEvent',
'shortcutKeyFromEvent', 'shortcutPhysicalKeyToken', 'shortcutFromKeyboardEvent',
'handleConfiguredShortcuts', 'handleConfiguredShortcutCombo', 'runShortcutSequenceStep'], {
shortcutModifierOrder: ['Ctrl', 'Alt', 'Shift', 'Meta'],
buildShortcutRuntimeContext: () => ({}), sequenceConditionMatches: () => true,
componentByActionId: () => component, isTimerComponent: () => true, ensureTimerState: () => game,
sequencePenaltyTargets: () => [], currentHockeyPenaltyEntries: () => [],
shortcutSequenceAllowed: () => true,
controlTimer: (_id, command, _value, options) => {
assert.equal(options.syncVmix, false, 'web transition must not recursively send vMix');
game.running = command !== 'pause'; game.startedOnce = true; return true;
},
sendRuntimeVmixTimerSequence: commands => {
sends.push({ commands: plain(commands), running: game.running, started: game.startedOnce });
return new Promise(() => {}); // A slow Agent must not block the next physical press.
},
});
c.state.config.shortcut_sequences = [sequence];
c.runShortcutSequence = (seq, meta) => {
runs.push(meta);
return c.runShortcutSequenceStep(seq, step);
};
const keyUpStart = source.indexOf('window.addEventListener("keyup", (event) => {');
assert.ok(keyUpStart >= 0);
const keyUpEnd = source.indexOf('\n }, true);', keyUpStart);
let keyUp;
c.window = { addEventListener: (_type, listener) => { keyUp = listener; } };
vm.runInContext(source.slice(keyUpStart, keyUpEnd + '\n }, true);'.length), c);
let swallowed = 0;
const key = (repeat = false) => ({ key: ' ', code: 'Space', repeat,
preventDefault() { swallowed++; }, stopPropagation() {}, stopImmediatePropagation() {} });
assert.equal(c.handleConfiguredShortcuts(key()), true);
assert.equal(runs.length, 1);
assert.equal(game.running, true);
assert.equal(sends[0].running, true, 'web state must change before any transport');
assert.deepEqual(sends[0].commands.map(row => row.Function), ['SetCountdown', 'StartCountdown']);
assert.equal(sends[0].commands[0].Value, '00:20:00');
assert.equal(c.handleConfiguredShortcuts(key()), true, 'duplicate physical keydown is swallowed');
assert.equal(c.handleConfiguredShortcuts(key(true)), true, 'browser repeat is swallowed');
assert.equal(runs.length, 1);
keyUp(key());
assert.equal(c.state.pressedShortcutKeys.size, 0, 'actual production keyup releases the latch');
assert.equal(c.handleConfiguredShortcuts(key()), true);
assert.equal(runs.length, 2);
assert.equal(game.running, false);
assert.equal(sends[1].running, false, 'pause changes web state before transport');
assert.deepEqual(sends[1].commands.map(row => row.Function), ['SuspendCountdown']);
keyUp(key());
assert.equal(c.handleConfiguredShortcuts(key(true)), false, 'orphan auto-repeat cannot trigger a shortcut');
assert.equal(runs.length, 2);
assert.ok(swallowed >= 4);
},
async ordered_transport() {
const calls = [];
let release;
const pending = new Promise(resolve => { release = resolve; });
const c = runtime(['compactVmixCommand', 'sendRuntimeVmixTimerSequence'], {
fetch: (url, options) => { calls.push({ url, options, body: JSON.parse(options.body) }); return pending; },
});
let value = '00:18:42';
const transport = c.sendRuntimeVmixTimerSequence(() => [
{ Function: 'SetCountdown', Input: 'SCORE', SelectedName: 'CLOCK.Text', Value: () => value },
{ Function: 'StartCountdown', Input: 'SCORE', SelectedName: 'CLOCK.Text' },
]);
assert.equal(calls.length, 1, 'timer must dispatch without waiting for the permanently blocked generic queue');
const sent = calls[0];
assert.equal(sent.url, '/api/hockey/vmix/sequence');
assert.equal(sent.options.keepalive, true);
assert.equal(sent.body.delivery_mode, 'timer-fast-ordered');
assert.equal(sent.body.device_id, 'LIVE-DEVICE');
assert.equal(sent.body.session_token, 'match-session');
assert.equal('sequence_id' in sent.body, false);
assert.equal('button_id' in sent.body, false);
assert.deepEqual(sent.body.commands.map(row => row.Function), ['SetCountdown', 'StartCountdown']);
assert.equal(sent.body.commands[0].Value, value);
release({ ok: true, json: async () => ({ ok: true, results: [{ ok: true }, { ok: true }] }) });
assert.equal((await transport).ok, true);
},
async blank_device_fails_closed() {
let fetchCount = 0;
const web = { running: false, startedOnce: false, currentMs: 1200000 };
const component = { action_id: 'game' };
const step = { id: 'timer', type: 'hockey_vmix_timers_start', enabled: true,
hockey_timer_command: 'toggle', game_timer_action_id: 'game', start_web_game: true,
sync_vmix_game: true, game_vmix_input: 'SCORE', game_vmix_selected_name: 'CLOCK.Text',
sync_vmix_penalties: false, start_web_penalties: false };
const c = runtime([...nativeNames, 'compactVmixCommand', 'sendRuntimeVmixTimerSequence', 'runShortcutSequenceStep'], {
currentRuntimeVmixDeviceId: () => '',
currentRuntimeHockeySessionToken: () => 'stale-session-with-old-device',
fetch: async () => { fetchCount++; return { ok: true, json: async () => ({ ok: true }) }; },
buildShortcutRuntimeContext: () => ({}), sequenceConditionMatches: () => true,
componentByActionId: () => component, isTimerComponent: () => true, ensureTimerState: () => web,
sequencePenaltyTargets: () => [], currentHockeyPenaltyEntries: () => [],
controlTimer: (_id, action) => { web.running = action !== 'pause'; web.startedOnce = true; return true; },
});
const unsent = { input: 'SCORE', eventKey: 'unsent-penalty' };
const unrelated = { input: 'OTHER', eventKey: 'unrelated-penalty' };
c.state.vmixPenaltyTargetAssignments.set('unsent', unsent);
c.state.vmixPenaltyTargetAssignments.set('unrelated', unrelated);
const result = await c.sendRuntimeVmixTimerSequence([{ Function: 'StartCountdown', Input: 'SCORE', SelectedName: 'CLOCK.Text' }]);
assert.equal(c.state.vmixPenaltyTargetAssignments.has('unsent'), false, 'blank output cannot confirm an unsent target');
assert.equal(c.state.vmixPenaltyTargetAssignments.get('unrelated'), unrelated, 'unrelated targets remain intact');
assert.equal(fetchCount, 0, 'blank Agent selection must never fall back through the session');
assert.equal(result.ok, false);
assert.equal(result.reason, 'device_selection_required');
await c.runShortcutSequenceStep({ id: 'space' }, step);
assert.equal(web.running, true, 'blank output target cannot roll back a local start');
assert.equal(web.currentMs, 1200000);
await c.runShortcutSequenceStep({ id: 'space' }, step);
assert.equal(web.running, false, 'the next local pause must remain usable');
assert.equal(fetchCount, 0);
},
async failed_assignment_cleanup_preserves_newer_delivery() {
let reject;
const c = runtime(['compactVmixCommand', 'sendRuntimeVmixTimerSequence'], {
fetch: () => new Promise((_resolve, fail) => { reject = fail; }),
});
const older = { input: 'PENALTY', eventKey: 'old' };
const newer = { input: 'PENALTY', eventKey: 'new' };
c.state.vmixPenaltyTargetAssignments.set('slot', older);
const pending = c.sendRuntimeVmixTimerSequence([{ Function: 'SetCountdown', Input: 'PENALTY', SelectedName: 'Clock.Text', Value: '00:02:00' }]);
c.state.vmixPenaltyTargetAssignments.set('slot', newer);
reject(new Error('older network request failed'));
assert.equal((await pending).ok, false);
assert.equal(c.state.vmixPenaltyTargetAssignments.get('slot'), newer);
},
async queued_values_sampled_at_dispatch() {
let release;
const blocker = new Promise(resolve => { release = resolve; });
const calls = [];
const c = runtime(['compactVmixCommand', 'sendRuntimeVmixSequence'], {
AbortController, window: { setTimeout },
fetch: async (url, options) => {
calls.push({ url, body: JSON.parse(options.body) });
return { ok: true, json: async () => ({ ok: true, confirmation: 'ack', results: [{ ok: true, ack_waited: true }] }) };
},
});
c.state.vmixCommandQueue = blocker;
c.state.vmixCommandQueueDepth = 0;
let commandReads = 0, valueReads = 0, liveValue = '00:20:00';
const target = { Input: 'SCORE', SelectedName: 'CLOCK.Text' };
const result = c.sendRuntimeVmixSequence(() => {
commandReads++;
return [{ Function: 'SetCountdown', ...target, Value: () => { valueReads++; return liveValue; } }];
});
await Promise.resolve();
assert.equal(commandReads, 0, 'command factories must wait for their actual queue turn');
assert.equal(valueReads, 0, 'countdown values must not be sampled before dispatch');
assert.equal(calls.length, 0);
liveValue = '00:19:55';
release();
assert.equal((await result).ok, true);
assert.equal(commandReads, 1);
assert.equal(valueReads, 1);
assert.deepEqual(calls[0].body.commands, [{ Function: 'SetCountdown', ...target, Value: liveValue }]);
assert.equal(c.state.vmixCommandQueueDepth, 0);
},
async native_first_start() {
const c = runtime(nativeNames);
let reads = 0;
const commands = c.vmixGameCountdownControlCommands('SCORE', 'CLOCK.Text', () => { reads++; return 1200000; }, 'start');
assert.deepEqual(plain(commands), [
{ Function: 'SetCountdown', Input: 'SCORE', SelectedName: 'CLOCK.Text', Value: '00:20:00' },
{ Function: 'StartCountdown', Input: 'SCORE', SelectedName: 'CLOCK.Text' },
]);
assert.equal(reads, 1);
},
async native_pause_resume() {
const c = runtime(nativeNames);
let reads = 0;
const provider = () => { reads++; return 999000; };
for (const action of ['pause', 'stop']) {
const commands = c.vmixGameCountdownControlCommands('SCORE', 'CLOCK.Text', provider, action);
assert.deepEqual(plain(commands), [{ Function: 'SuspendCountdown', Input: 'SCORE', SelectedName: 'CLOCK.Text' }]);
}
assert.deepEqual(plain(c.vmixGameCountdownControlCommands('SCORE', 'CLOCK.Text', provider, 'resume')),
[{ Function: 'StartCountdown', Input: 'SCORE', SelectedName: 'CLOCK.Text' }]);
assert.equal(reads, 0, 'native pause/resume must not even read or reseed browser time');
},
async scoreboard_before_overlay() {
const game = { running: false, currentMs: 1200000 }, component = { action_id: 'game' };
const timerStep = { id: 'timer', enabled: true, type: 'hockey_vmix_timers_start', sync_vmix_game: true,
game_vmix_mode: 'countdown', game_timer_action_id: 'game', game_vmix_input: 'SCORE', game_vmix_selected_name: 'CLOCK.Text' };
const sequence = { id: 'scoreboard', name: 'Scoreboard', enabled: true, is_scoreboard_sequence: true,
steps: [{ id: 'overlay', enabled: true, type: 'vmix_command' }] };
const order = [];
const c = runtime([...nativeNames, 'vmixGameCountdownTargetKey', 'markVmixGameCountdownSeeded',
'configuredHockeyVmixGameCountdownTargets', 'syncConfiguredScoreboardCountdownsToRuntime', 'runShortcutSequence'], {
shortcutSequenceIsOnAir: () => false, clone: plain,
window: { dispatchEvent() {} }, CustomEvent: class { constructor(type, args) { this.type = type; this.detail = args.detail; } },
componentByActionId: () => component, isTimerComponent: () => true, ensureTimerState: () => game,
runShortcutSequenceStep: async () => { order.push('overlay'); return { ok: true }; },
sendRuntimeVmixTimerSequence: async commands => { order.push(plain(commands)); return { ok: true }; },
reconcileShortcutSequenceOnAir() {}, activateConfiguredPenaltyCountdowns() {},
rebalanceVmixPenaltyTargets: async () => {}, hockeySyncScoreboardConditionalSteps: async () => {},
hockeyTitleMappingNotice: () => '', toast() {},
});
c.state.config.shortcut_sequences = [{ enabled: true, steps: [timerStep] }];
assert.equal(await c.runShortcutSequence(sequence), true);
assert.deepEqual(order[0].map(row => row.Function), ['SetCountdown', 'StopCountdown']);
assert.equal(order[0][0].Value, '00:20:00');
assert.equal(order[1], 'overlay', 'stopped clock seed must reach timer transport before OverlayIn');
order.length = 0; game.running = true;
assert.equal(await c.runShortcutSequence(sequence), true);
assert.deepEqual(order, ['overlay'], 'showing scoreboard cannot rewrite a running native timer');
},
};
module.exports = { definition, runtime, scenarios };
if (require.main === module) {
(async () => {
const scenario = process.argv[2];
assert.ok(scenarios[scenario], `Unknown scenario: ${scenario}`);
await scenarios[scenario]();
console.log(`PASS: ${scenario}`);
})().catch(error => { console.error(error); process.exitCode = 1; });
}