Files
hockey_new/tests/build200_penalty_display.cjs

259 lines
16 KiB
JavaScript

'use strict';
// Real browser runtime helpers and real transport; only HTTP, time and vMix are doubles.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const fixture = fs.readFileSync(path.join(__dirname, 'build198_presentation.cjs'), 'utf8');
const end = fixture.indexOf('\nconst cases = {};');
assert.ok(end > 0);
// The server acknowledges every generic request without shortcut/button IDs,
// including a multi-command native preparation (agent_bridge.send_sequence).
const ioFixture = fixture.slice(0, end).replace(
'(!body.delivery_mode && !body.sequence_id && !body.button_id && body.commands.length === 1)',
'(!body.delivery_mode && !body.sequence_id && !body.button_id)');
assert.notEqual(ioFixture, fixture.slice(0, end));
const { harness, flush } = new Function('require', 'process', '__dirname', ioFixture
+ '\nreturn { harness, flush };')(require, process, __dirname);
const clone = value => JSON.parse(JSON.stringify(value));
const physicalFixture = fs.readFileSync(path.join(__dirname, 'build196_penalty_lifecycle.cjs'), 'utf8');
function physicalFunction(name) {
const begin = physicalFixture.indexOf(`function ${name}(`), finish = physicalFixture.indexOf('\n}', begin);
assert.ok(begin >= 0 && finish > begin);
return physicalFixture.slice(begin, finish + 2);
}
const nativeModel = new Function('assert', ['milliseconds', 'createNativeCountdownModel'].map(physicalFunction).join('\n')
+ '\nreturn createNativeCountdownModel;')(assert);
function event(id, side, minutes, remainingMs = minutes * 60000, running = true) {
return { id, external_id: id, side, teamPenalty: true, player: null, infraction: { id: 'minor' },
preset: String(minutes), durationMs: minutes * 60000, remainingMs, running,
startedOnce: running || remainingMs < minutes * 60000, finished: false,
readyEmitted: true, assignedEmitted: true, createdAt: 1, lastTimestamp: 100000 };
}
const opposite = side => side === 'home' ? 'away' : 'home';
const input = side => side === 'home' ? 'Penalty HOME' : 'Penalty AWAY';
function runtime(penalties, { captions = true } = {}) {
const h = harness({ penalties: true });
h.state.hockeyPenaltyBoards.board.penalties = penalties;
h.timerStep.home_penalty_targets = [{ id: 'home', input: input('home'), selected_name: 'Clock.Text', overlay: '2' }];
h.timerStep.away_penalty_targets = [{ id: 'away', input: input('away'), selected_name: 'Clock.Text', overlay: '2' }];
if (captions) for (const side of ['away', 'home']) h.sequence.steps.push({ id: 'caption-' + side,
type: 'vmix_command', enabled: true, function: 'OverlayInput2In', input: input(side),
selected_name: 'Text.Text', value: '{{ hockey.game_control.strength.state_label }}',
condition: 'has_' + opposite(side) + '_penalties' });
h.updateStrength = () => {
const counts = { home: 0, away: 0 };
for (const p of h.state.hockeyPenaltyBoards.board.penalties) if (!p.finished && p.remainingMs > 0) counts[p.side]++;
h.state.data.hockey.game_control.strength = { home_penalties: counts.home, away_penalties: counts.away,
home_skaters: 5 - counts.home, away_skaters: 5 - counts.away,
advantage_side: counts.home < counts.away ? 'home' : counts.away < counts.home ? 'away' : '',
state_label: counts.home === counts.away ? '4on4' : 'powerplay' };
};
h.updateStrength();
h.native = nativeModel({
[input('home')]: { durationMs: 0, positionMs: 0, running: false, visible: false },
[input('away')]: { durationMs: 0, positionMs: 0, running: false, visible: false },
ForeignClock: { durationMs: 75000, positionMs: 53000, running: true, visible: true, overlay: '8' },
});
const fetch = h.c.fetch;
h.c.fetch = async (url, options) => {
const response = await fetch(url, options);
if (url === '/api/hockey/vmix/sequence') {
const payload = await response.json();
if (response.ok && payload.ok) h.native.apply(JSON.parse(options.body).commands);
}
return response;
};
h.plan = () => h.c.penaltyDisplayEntriesByTargetSide(h.timerStep);
h.rebalance = async options => { const count = await h.c.rebalanceVmixPenaltyTargets({ scoreboardActive: true, ...options }); await flush(); return count; };
return h;
}
function assertOwner(h, side, id) {
const plan = h.plan();
assert.equal(plan.plateSide, side, 'The physical plate must belong to the longer opposing tail, independent of insertion order');
assert.equal(plan[side][0]?.event.id, id);
assert.equal(plan[opposite(side)].length, 0, 'Only one physical plate may be planned');
}
const cases = {};
for (const shortSide of ['home', 'away']) {
const longSide = opposite(shortSide);
for (const reverse of [false, true]) cases[`pair_${shortSide}_short_${reverse ? 'long_first' : 'short_first'}`] = async () => {
const rows = [event('two', shortSide, 2), event('four', longSide, 4)];
const h = runtime(reverse ? rows.reverse() : rows);
assertOwner(h, longSide, 'two');
await h.rebalance();
assert.deepEqual(h.ins().map(x => x.Input), [input(longSide)]);
assert.equal(h.native.clocks.get(input(longSide)).positionMs, 120000);
};
cases[`phase_expiry_${shortSide}_short`] = async () => {
const h = runtime([event('two', shortSide, 2), event('four', longSide, 4)]);
await h.rebalance();
h.state.hockeyPenaltyBoards.board.penalties = [event('four', longSide, 4, 120000)];
h.updateStrength(); const start = h.frames.length;
await h.rebalance();
assertOwner(h, longSide, 'four');
assert.equal(h.native.clocks.get(input(longSide)).positionMs, 120000);
assert.equal(h.native.clocks.get(input(longSide)).visible, true);
const commands = h.frames.slice(start).flatMap(x => x.body.commands);
assert.ok(commands.some(x => x.Function === 'SetCountdown' && x.Input === input(longSide) && x.Value === '00:02:00'));
assert.ok(!commands.some(x => /^OverlayInput[1-8](In|Out)$/.test(x.Function)), 'The same live physical plate changes phase without OUT/IN');
assert.equal(h.plan().advantageSide, shortSide, 'Physical ownership must not change real hockey advantage');
};
}
cases.reverse_insertion_remembers_owner = async () => {
const h = runtime([event('four', 'away', 4)]);
await h.rebalance();
h.state.hockeyPenaltyBoards.board.penalties.push(event('two', 'home', 2)); h.updateStrength();
await h.rebalance(); assertOwner(h, 'away', 'two');
assert.equal(h.state.vmixOverlayRuntime.get('2').input, input('away'));
};
cases.repeat_main_has_no_wrong_side_flash = async () => {
const h = runtime([event('two', 'home', 2), event('four', 'away', 4)]);
await h.rebalance();
await h.c.runShortcutSequence(h.sequence.id, { throw_on_error: true }); await flush();
const start = h.frames.length;
await h.c.runShortcutSequence(h.sequence.id, { throw_on_error: true }); await flush();
const ins = h.frames.slice(start).flatMap(x => x.body.commands).filter(x => /^OverlayInput[1-8]In$/.test(x.Function));
assert.ok(ins.some(x => x.Input === 'Scoreboard'));
assert.ok(!ins.some(x => x.Input === input('home')), 'A repeated upper-score IN must never flash the unselected left native plate');
assert.equal(h.state.vmixOverlayRuntime.get('2').input, input('away'));
};
cases.conditional_and_fallback_same_owner = async () => {
const h = runtime([event('two', 'home', 2), event('four', 'away', 4)]);
await h.rebalance();
await h.c.hockeySyncScoreboardConditionalSteps({ force: true });
assert.equal(h.state.vmixOverlayRuntime.get('2').input, input('away'), 'Conditional captions must agree with native routing');
const targets = h.c.hockeyPenaltyOverlayFallbackTargets();
assert.equal(targets.get('2').input, input('away'));
assert.ok(!h.ins().some(x => x.Input === input('home')));
};
cases.native_caption_data_ack_before_in = async () => {
const h = runtime([event('two', 'home', 2), event('four', 'away', 4)]);
h.state.hockeyPenaltyAdvantageCycle = { hadAdvantage: true, lastAdvantageSide: 'away' };
const hold = h.holdMapping(input('away'));
const pending = h.rebalance(); await flush();
assert.equal(hold.started, true, 'A new native plate needs its non-clock caption DATA prepared');
assert.equal(h.ins().length, 0, 'Native IN must wait for caption Mapping ACK');
h.release(hold); await pending;
assert.equal(h.ins().at(-1).Input, input('away'));
const excluded = JSON.parse(new URL(hold.row.url, 'http://hockey.invalid').searchParams.get('exclude_fields'));
assert.ok(excluded.includes('Clock.Text'), 'Caption preparation must preserve the native countdown');
};
cases.native_cold_in_dedup_after_caption_mapping = async () => {
const h = runtime([event('two', 'home', 2), event('four', 'away', 4)]);
const hold = h.holdMapping(input('away'));
const pending = h.rebalance(); await flush();
assert.equal(hold.started, true);
assert.equal(h.native.clocks.get(input('away')).positionMs, 120000);
await h.c.sendRuntimeVmixSequence([{ Function: 'OverlayInput2In', Input: input('away') }], { requireAck: true, skipTitleMapping: true, urgent: true });
h.native.advance(1000);
h.release(hold); const count = await pending;
assert.equal(h.ins().filter(command => command.Input === input('away')).length, 1, 'A native IN acknowledged during caption Mapping must not replay');
assert.equal(h.native.clocks.get(input('away')).positionMs, 119000);
assert.equal(count, 3, 'Only the acknowledged native Set/Stop/Start commands count as this request delivery');
};
for (const shortSide of ['home', 'away']) cases[`clock_continuity_pause_resume_${shortSide}_short`] = async () => {
const longSide = opposite(shortSide), two = event('two', shortSide, 2, 85000);
const h = runtime([two]);
await h.rebalance();
const target = input(longSide), start = h.frames.length;
h.state.hockeyPenaltyBoards.board.penalties.push(event('four', longSide, 4)); h.updateStrength();
await h.rebalance();
assertOwner(h, longSide, 'two');
assert.equal(h.native.clocks.get(target).positionMs, 85000);
assert.equal(h.frames.length, start, 'An unchanged transition preserves its native clock without new IN or seed');
assert.equal(h.c.controlHockeyPenalty(h.component, two.id, 'pause', '', { syncVmix: false }), true);
await h.rebalance();
assert.equal(h.native.clocks.get(target).positionMs, 240000, 'A running opposing event is the next actual transition while the first is stopped');
assert.equal(h.native.clocks.get(target).running, true);
for (const row of h.state.hockeyPenaltyBoards.board.penalties) assert.equal(h.c.controlHockeyPenalty(h.component, row.id, 'pause', '', { syncVmix: false }), true);
await h.rebalance();
assert.equal(h.native.clocks.get(target).positionMs, 85000);
assert.equal(h.native.clocks.get(target).running, false);
for (const row of h.state.hockeyPenaltyBoards.board.penalties) assert.equal(h.c.controlHockeyPenalty(h.component, row.id, 'start', '', { syncVmix: false }), true);
await h.rebalance();
assert.equal(h.native.clocks.get(target).positionMs, 85000);
assert.equal(h.native.clocks.get(target).running, true);
assert.equal(h.ins().filter(command => command.Input === target).length, 1);
assert.equal(h.native.clocks.get('ForeignClock').positionMs, 53000);
};
cases.tail_snapshot_and_hydration = async () => {
const h = runtime([event('two', 'home', 2), event('four', 'away', 4)]);
await h.rebalance();
h.state.hockeyPenaltyBoards.board.penalties = [event('four', 'away', 4, 120000)]; h.updateStrength();
await h.rebalance();
h.installSnippet('if (false) { hockeyHistorySnapshot(); hockeyPenaltySnapshot(); normalizeStoredHockeyEvent(); } globalThis.takeSnapshot = () => hockeyGameTimerSnapshot(); globalThis.restoreTimers = (...args) => hockeyApplySavedTimers(...args);');
const snapshot = h.c.takeSnapshot();
assert.equal(snapshot.penalty_board.advantage_cycle.display_owner_side, 'away');
const next = runtime([], { captions: false });
next.installSnippet('if (false) { normalizeStoredHockeyEvent(); } globalThis.restoreTimers = (...args) => hockeyApplySavedTimers(...args);');
next.c.restoreTimers('gameA', clone(snapshot)); next.updateStrength();
assertOwner(next, 'away', 'four');
};
cases.pending_goal_snapshot_keeps_latest_pause = async () => {
const row = event('two', 'home', 2, 85000);
const h = runtime([row]); await h.rebalance();
row.goalResolution = { key: 'goal-1', status: 'pending', finished: false, remainingMs: 120000, message: '', at: '2026-11-05T00:00:00Z' };
row.running = false;
const key = h.c.penaltyMirrorKey(h.component, row);
h.state.hockeyGoalPenaltyPending = new Map([[key, { key: 'goal-1', entry: { component: h.component, event: row },
context: h.c.hockeyGoalPenaltyContext(), running: false, status: 'pending', finished: false, remainingMs: 120000 }]]);
h.installSnippet('if (false) { hockeyHistorySnapshot(); hockeyPenaltySnapshot(); normalizeStoredHockeyEvent(); } globalThis.takeSnapshot = () => hockeyGameTimerSnapshot(); globalThis.restoreTimers = (...args) => hockeyApplySavedTimers(...args);');
const snapshot = h.c.takeSnapshot();
snapshot.penalty_board.penalties[0].remainingMs = 111000;
snapshot.penalty_board.penalties[0].running = true;
h.c.restoreTimers('gameA', snapshot);
const restored = h.state.hockeyPenaltyBoards.board.penalties[0];
assert.equal(restored.remainingMs, 85000, 'An older snapshot cannot rewind the unresolved row');
assert.equal(restored.running, false, 'Pause while goal ACK is pending survives rehydration');
assert.equal(restored.goalResolution.key, 'goal-1');
};
cases.pending_goal_blocks_automatic_native_replay = async () => {
const row = event('two', 'home', 2);
const h = runtime([row]); await h.rebalance();
await h.c.hockeySyncScoreboardConditionalSteps({ force: true });
row.goalResolution = { key: 'goal-1', status: 'unknown', finished: true, remainingMs: 0 };
const start = h.frames.length;
await h.rebalance({ force: true, forceOverlay: true });
await h.c.hockeySyncScoreboardConditionalSteps({ force: true });
assert.equal(h.frames.length, start, 'An unresolved goal must not trigger native seed, IN or automatic conditional OUT');
assert.equal(h.native.clocks.get(input('away')).visible, true, 'Only the dedicated goal delivery may change its physical state');
h.installSnippet('globalThis.closeGate = () => hockeySetScoreboardAuxiliariesEnabled(false, { persist: false });');
await h.c.closeGate();
assert.ok(h.commands().slice(start).some(command => command.Function === 'OverlayInput2Out'), 'The operator can still close the auxiliary gate while goal resolution is unresolved');
};
cases.pending_goal_blocks_legacy_in_preserves_data = async () => {
const row = event('two', 'home', 2);
const h = runtime([row]); await h.rebalance();
row.goalResolution = { key: 'goal-1', status: 'pending', finished: true, remainingMs: 0 };
const start = h.commands().length;
const result = await h.c.sendRuntimeVmixSequence([
{ Function: 'SetText', Input: input('away'), SelectedName: 'Text.Text', Value: 'DATA remains available' },
{ Function: 'PreviewInput', Input: input('away') },
{ Function: 'OverlayInput2In', Input: input('away') },
{ Function: 'OverlayInput3In', Input: 'ForeignPenalty' },
], { skipTitleMapping: true });
const sent = h.commands().slice(start);
assert.ok(!sent.some(command => command.Function === 'OverlayInput2In'), 'A legacy interaction cannot replay a goal-pending native plate');
assert.ok(sent.some(command => command.Function === 'SetText'));
assert.ok(sent.some(command => command.Function === 'PreviewInput'));
assert.ok(sent.some(command => command.Function === 'OverlayInput3In' && command.Input === 'ForeignPenalty'));
assert.equal(result.auxiliary_blocked, 1);
};
cases.pure_equal_and_foreign_captions_unchanged = async () => {
const h = runtime([event('a', 'home', 2), event('b', 'away', 2)]);
assert.equal(h.plan().plateSide, '');
h.sequence.steps.push({ id: 'foreign', enabled: true, type: 'vmix_command', function: 'OverlayInput3In', input: 'ForeignPenalty', condition: 'has_penalties' });
await h.c.hockeySyncScoreboardConditionalSteps();
assert.ok(h.ins().some(x => x.Input === 'ForeignPenalty'), 'Non-native configured title animation is outside this routing fix');
};
cases.gate_off_blocks_every_penalty_in = async () => {
const h = runtime([event('two', 'home', 2), event('four', 'away', 4)]);
h.state.data.hockey.game_control.flags.scoreboard_auxiliaries_enabled = false;
await h.rebalance(); await h.c.hockeySyncScoreboardConditionalSteps({ force: true });
assert.equal(h.ins().length, 0);
assertOwner(h, 'away', 'two');
};
const name = process.argv[2];
assert.equal(typeof cases[name], 'function', 'Unknown scenario ' + name);
cases[name]().then(() => console.log('PASS: ' + name), error => { console.error(error.stack || error); process.exitCode = 1; });