Files
hockey_new/tests/build199_penalty_clock.cjs

235 lines
13 KiB
JavaScript

'use strict';
// Reuse the established deterministic browser/physical-I/O fixture; the clocks,
// projection, saved strength reconciliation, assignment and command generators
// under test are the real source functions loaded by that fixture.
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const assert = require('node:assert/strict');
const fixtureSource = fs.readFileSync(path.join(__dirname, 'build196_penalty_lifecycle.cjs'), 'utf8');
const entry = fixtureSource.indexOf('\nconst cases = {};');
assert.ok(entry > 0);
function fixtureFunction(name) {
const begin = fixtureSource.indexOf(`function ${name}(`);
const end = fixtureSource.indexOf('\n}', begin);
assert.ok(begin >= 0 && end > begin, name);
return fixtureSource.slice(begin, end + 2);
}
const make = new Function('require', 'process', '__dirname', fixtureSource.slice(0, entry)
+ '\n' + ['milliseconds', 'createNativeCountdownModel'].map(fixtureFunction).join('\n')
+ '\nreturn { makeRuntime, createNativeCountdownModel };')(require, process, __dirname);
const appSource = fs.readFileSync(process.argv[3] || path.join(__dirname, '../ui_builder/static/app.js'), 'utf8');
const clone = value => JSON.parse(JSON.stringify(value));
function productionFunction(name) {
const begin = appSource.search(new RegExp('^ (?:async )?function ' + name + '\\(', 'm'));
const end = appSource.indexOf('\n }', begin);
assert.ok(begin >= 0 && end > begin, `Missing actual production ${name}`);
return appSource.slice(begin, end + 4);
}
function penalty(id, side, minutes, remainingMs = minutes * 60000, running = true) {
return { 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, lastTimestamp: 1000, createdAt: 1 };
}
function runtime(first, { sharedLayer = true } = {}) {
const targets = {
home: { id: 'home', input: 'Penalty HOME', selected_name: 'Clock.Text', overlay: '2' },
away: { id: 'away', input: 'Penalty AWAY', selected_name: 'Clock.Text', overlay: sharedLayer ? '2' : '3' },
};
const h = make.makeRuntime({ penalties: [first], targets });
// These scenarios exercise an explicitly armed output. The server response
// double retains its persisted flag through timer PUTs, like the real route.
h.state.data.hockey.game_control.flags.scoreboard_auxiliaries_enabled = true;
const timerRequest = h.context.hockeyGameControlRequest;
h.context.hockeyGameControlRequest = async (...args) => {
const response = await timerRequest(...args);
response.flags.scoreboard_auxiliaries_enabled = true;
return response;
};
const gate = ['hockeyAuxiliaryGateContext', 'hockeyScoreboardAuxiliariesEnabled',
'hockeyObserveScoreForPenaltyGoal', 'hockeyGoalPenaltyContext', 'hockeyVmixTransportContext',
'hockeyObserveAuxiliaryGateControl']
.filter(name => appSource.includes(`function ${name}(`));
if (gate.length) vm.runInContext(gate.map(productionFunction).join('\n'), h.context);
const native = make.createNativeCountdownModel({
'Penalty HOME': { durationMs: 0, positionMs: 0, running: false, visible: false },
'Penalty AWAY': { durationMs: 0, positionMs: 0, running: false, visible: false },
'Unrelated clock': { durationMs: 75000, positionMs: 53000, running: true, visible: true, overlay: '8' },
});
h.setNative(native);
h.native = native;
h.context.activateConfiguredPenaltyCountdowns();
return h;
}
async function settle(h) { await h.clock.flush(); await h.tick(0); }
async function addPreparedPenalty(h, second) {
second.readyPending = true;
second.assignedPending = true;
h.board.penalties.push(second);
const snapshot = h.context.hockeyGameTimerSnapshot();
const home = snapshot.penalty_board.penalties.filter(x => x.side === 'home').length;
const away = snapshot.penalty_board.penalties.filter(x => x.side === 'away').length;
const prior = h.state.data.hockey.game_control;
// This is the server's saved-control response edge, not a replacement for any
// production projection or reconciliation function.
const control = { ...clone(prior), timers: clone(snapshot), strength: {
...clone(prior.strength), home_penalties: home, away_penalties: away,
home_skaters: Math.max(3, 5 - home), away_skaters: Math.max(3, 5 - away),
advantage_side: home < away ? 'home' : away < home ? 'away' : '',
state_label: home === away ? '4 на 4' : home + away > 1 ? '5 на 3' : 'PP',
} };
assert.equal(h.context.hockeyStoreGameControl('GAME-A', control), true);
await settle(h);
}
const cases = {};
for (const side of ['home', 'away']) {
const other = side === 'home' ? 'away' : 'home';
for (const running of [true, false]) {
const name = `existing_${side}_two_add_four_${running ? 'running' : 'paused'}`;
cases[name] = async () => {
const first = penalty('two', side, 2, 85000, running);
const h = runtime(first);
await h.context.rebalanceVmixPenaltyTargets({ scoreboardActive: true });
await settle(h);
const input = h.targets[other].input;
assert.equal(h.native.clocks.get(input).positionMs, 85000);
assert.equal(h.native.clocks.get(input).running, running);
assert.equal(h.native.clocks.get(input).visible, true);
const start = h.native.trace.length;
await addPreparedPenalty(h, penalty('four', other, 4));
const commands = h.native.trace.slice(start).map(x => x.command);
assert.equal(h.state.data.hockey.game_control.strength.state_label, '4 на 4');
assert.equal(h.native.clocks.get(input).positionMs, running ? 85000 : 240000);
assert.equal(h.native.clocks.get(input).running, true,
'The running penalty owns the next real transition when the first penalty is paused');
assert.equal(h.native.clocks.get(input).visible, true);
assert.ok(!commands.some(x => x.Function === `OverlayInput${h.targets[other].overlay}In` && x.Input === input),
'Updating 4on4 must not reissue IN for the same already-live physical plate');
if (running) assert.ok(!commands.some(x => ['SetCountdown', 'StopCountdown', 'StartCountdown'].includes(x.Function) && x.Input === input),
'The unchanged, running 2-minute transition clock must retain its position');
assert.equal(h.native.clocks.get('Unrelated clock').positionMs, 53000);
assert.equal(h.native.clocks.get('Unrelated clock').running, true);
assert.equal(h.errors.length, 0);
};
}
cases[`existing_${side}_four_add_two`] = async () => {
const h = runtime(penalty('four', side, 4));
await h.context.rebalanceVmixPenaltyTargets({ scoreboardActive: true });
await settle(h);
const input = h.targets[side].input, start = h.native.trace.length;
await addPreparedPenalty(h, penalty('two', other, 2));
const commands = h.native.trace.slice(start).map(x => x.command);
assert.equal(h.native.clocks.get(input).positionMs, 120000,
'A shorter opposing penalty supplies the new 4on4 transition countdown');
assert.equal(h.native.clocks.get(input).running, true);
assert.ok(commands.some(x => x.Function === 'SetCountdown' && x.Input === input && x.Value === '00:02:00'));
assert.equal(commands.filter(x => /^OverlayInput[1-8]In$/.test(x.Function) && x.Input === input).length, 1,
'The unequal-duration cycle switches once to the longer-penalty physical plate');
const reconciled = h.native.trace.length;
await h.context.rebalanceVmixPenaltyTargets({ scoreboardActive: true, forceOverlay: true });
await settle(h);
assert.ok(!h.native.trace.slice(reconciled).some(x => /^OverlayInput[1-8]In$/.test(x.command.Function) && x.command.Input === input),
'The newly selected physical plate must not replay IN during reconciliation');
assert.equal(h.errors.length, 0);
};
cases[`expired_two_continues_four_${side}`] = async () => {
const h = runtime(penalty('four', side, 4, 121000));
await h.context.rebalanceVmixPenaltyTargets({ scoreboardActive: true });
await addPreparedPenalty(h, penalty('two', other, 2, 1000));
const input = h.targets[side].input;
h.initialiseTicks();
await h.frames(1000);
await h.tick(40);
assert.equal(h.board.penalties.length, 1);
assert.equal(h.board.penalties[0].id, 'four');
assert.equal(h.board.penalties[0].remainingMs, 120000);
assert.equal(h.native.clocks.get(input).positionMs, 120000);
assert.equal(h.native.clocks.get(input).running, true);
assert.equal(h.native.clocks.get(input).visible, true);
assert.equal(h.main.running, true);
assert.equal(h.errors.length, 0);
};
}
cases.authoritative_missing_plate_is_restored = async () => {
const h = runtime(penalty('two', 'home', 2, 85000));
await h.context.rebalanceVmixPenaltyTargets({ scoreboardActive: true });
await settle(h);
const input = h.targets.away.input;
h.state.vmixOverlayRuntime.delete('2');
const start = h.native.trace.length;
await h.context.rebalanceVmixPenaltyTargets({ scoreboardActive: true, forceOverlay: true });
await settle(h);
assert.ok(h.native.trace.slice(start).some(x => x.command.Function === 'OverlayInput2In' && x.command.Input === input),
'Skip duplicate IN only while that same physical plate actually occupies the layer');
assert.equal(h.native.clocks.get(input).positionMs, 85000);
assert.equal(h.native.clocks.get(input).running, true);
};
function strictRuntime() {
const text = fs.readFileSync(path.join(__dirname, 'build198_presentation.cjs'), 'utf8');
const start = text.indexOf('\nconst cases = {};');
assert.ok(start > 0);
// That reusable fixture accepts a source directory; adapt only its source-file
// read so the same explicit app.js snapshot used by these tests is executed.
const readRequire = name => name === 'node:fs' ? { ...fs, readFileSync(file, ...args) {
return String(file).endsWith('/ui_builder/static/app.js') ? appSource : fs.readFileSync(file, ...args);
} } : require(name);
const shared = new Function('require', 'process', '__dirname', text.slice(0, start)
+ '\nreturn { harness, flush, defer };')(readRequire, process, __dirname);
const h = shared.harness({ penalties: true, conditional: false, team: false });
h.state.data.hockey.game_control.flags.scoreboard_auxiliaries_enabled = true;
return { h, ...shared };
}
function finalPhysicalClock(commands) {
const clock = { duration: 0, position: 0, running: false };
for (const command of commands) {
if (command.Input !== 'PenaltyPlate' || command.SelectedName !== 'Clock.Text') continue;
if (command.Function === 'SetCountdown') {
const parts = String(command.Value).split(':').map(Number);
assert.equal(parts.length, 3);
clock.duration = parts.reduce((seconds, part) => seconds * 60 + part, 0) * 1000;
} else if (command.Function === 'StopCountdown') {
clock.position = clock.duration;
clock.running = false;
} else if (command.Function === 'SuspendCountdown') clock.running = false;
else if (command.Function === 'StartCountdown') clock.running = true;
}
return clock;
}
for (const action of ['pause', 'resume']) {
cases[`strict_four_minute_shorten_then_${action}`] = async () => {
const { h, flush, defer } = strictRuntime();
h.event.preset = '4'; h.event.durationMs = 240000; h.event.remainingMs = 180000;
h.event.running = action === 'pause';
await h.c.rebalanceVmixPenaltyTargets(); await flush();
const heldQueue = defer();
h.state.vmixUrgentCommandQueue = heldQueue.promise;
const heldWire = h.holdFrame(commands => commands.some(command => command.Function === 'SetCountdown' && command.Value === '00:02:00'));
let completed = false;
const shortening = Promise.resolve(h.c.controlHockeyPenalty(h.component, h.event.id, 'set_time', '02:00', { awaitVmix: true }))
.then(value => { completed = true; return value; });
await flush();
assert.equal(completed, false, 'The queued shortening must still await actual dispatch/ACK');
const latestCommand = action === 'pause' ? 'pause' : 'start';
assert.equal(h.c.controlHockeyPenalty(h.component, h.event.id, latestCommand), true);
await flush();
assert.equal(h.event.running, action !== 'pause');
heldQueue.resolve(); await flush();
assert.equal(heldWire.started, true);
h.release(heldWire);
assert.equal(await shortening, true); await flush();
const physical = finalPhysicalClock(h.commands());
assert.equal(physical.position, 120000, 'The shortened double minor retains the latest two-minute value');
assert.equal(physical.running, h.event.running,
'A queued goal shortening must not undo the later operator pause/resume');
assert.equal(h.event.remainingMs, 120000);
};
}
(async () => {
const name = process.argv[2];
assert.equal(typeof cases[name], 'function', `Unknown scenario ${name}`);
await cases[name]();
console.log(`PASS: ${name}`);
})().catch(error => { console.error(error); process.exitCode = 1; });