297 lines
17 KiB
JavaScript
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; });
|
|
}
|