Files
hockey_new/tests/build200_goal_ack.cjs

234 lines
18 KiB
JavaScript

'use strict';
// Reuse the established I/O fixture. State, goal rules, mutation and physical
// sender remain actual functions from the selected production app.js.
const fs = require('node:fs'), vm = require('node:vm'), path = require('node:path'), assert = require('node:assert/strict');
const legacy = fs.readFileSync(path.join(__dirname, 'build199_goal_penalty.cjs'), 'utf8');
const end = legacy.indexOf('\nconst cases = {};'); assert.ok(end > 0);
const ioFixture = legacy.slice(0, end).replace("['hockeyStoreGameControl', 'hockeyScoreCommand'",
"['controlHockeyPenalty', 'updateHockeyPenaltyBoards', 'hockeyStoreGameControl', 'hockeyScoreCommand'");
assert.notEqual(ioFixture, legacy.slice(0, end));
const { harness, penalty, flush, clone, assertInfrastructure } = new Function('require', 'process', '__dirname',
ioFixture + '\nreturn { harness, penalty, flush, clone, assertInfrastructure };')(require, process, __dirname);
const cases = {};
function nativeModel(h) {
const fixture = fs.readFileSync(path.join(__dirname, 'build196_penalty_lifecycle.cjs'), 'utf8');
const actual = name => { const begin = fixture.indexOf('function ' + name + '('), finish = fixture.indexOf('\n}', begin);
assert.ok(begin >= 0 && finish > begin); return fixture.slice(begin, finish + 2); };
const create = new Function('assert', ['milliseconds', 'createNativeCountdownModel'].map(actual).join('\n')
+ '\nreturn createNativeCountdownModel;')(assert);
const model = create({ 'HOME-plate': { durationMs: 0, positionMs: 0, running: false, visible: false },
'AWAY-plate': { durationMs: 0, positionMs: 0, running: false, visible: false } });
const fetch = h.c.fetch;
h.c.fetch = (url, options) => { if (url === '/api/hockey/vmix/sequence') model.apply(JSON.parse(options.body).commands);
return fetch(url, options); };
return model;
}
function deletedSnapshots(h) { return h.persisted.filter(row => row.penalty_board.penalties.length === 0); }
function prepareMatch(h) {
const counts = { home: 0, away: 0 }; for (const row of h.board().penalties) if (!row.finished && row.remainingMs > 0) counts[row.side]++;
Object.assign(h.server.strength, { home_penalties: counts.home, away_penalties: counts.away,
advantage_side: counts.home < counts.away ? 'home' : counts.away < counts.home ? 'away' : '',
state_label: counts.home === counts.away ? '4on4' : 'powerplay' });
for (const control of [h.state.data.hockey.game_control, h.state.hockeyGameControl['game-A']]) control.strength = clone(h.server.strength);
}
async function settlePhysical(h) { for (let pass = 0; pass < 4; pass++) { await h.state.vmixCommandQueue;
await h.state.vmixUrgentCommandQueue; await flush(); } }
async function beginGoal(h) { prepareMatch(h); await h.prime(); await settlePhysical(h); h.frames.length = 0;
await h.score(0, 0); await settlePhysical(h); h.frames.length = 0;
const hold = h.hold(); const goal = h.score(1, 0); await flush(); return { hold, goal }; }
cases.normal_goal_ack_before_row_delete = async () => {
const h = harness(), { hold, goal } = await beginGoal(h); assertInfrastructure(h);
assert.equal(hold.started, true, 'The goal must reach an actual ACK request');
assert.equal(h.board().penalties.length, 1, 'Keep the web row until vMix confirms its removal');
assert.equal(h.board().penalties[0].remainingMs, 90000, 'Pending output must not invent a finished web clock');
assert.equal(deletedSnapshots(h).length, 0, 'Do not persist a deletion before vMix ACK');
h.release(hold); await goal;
assert.equal(h.board().penalties.length, 0); assert.ok(deletedSnapshots(h).length > 0);
assert.ok(h.frames.every(frame => frame.ack || frame.body.delivery_mode === 'timer-fast-ordered'));
};
cases.f2_off_never_mutates_or_replays = async () => {
const h = harness(); h.server.flags.scoreboard_auxiliaries_enabled = false; h.state.data.hockey.game_control.flags.scoreboard_auxiliaries_enabled = false;
await h.prime(); await h.score(0, 0); await h.score(1, 0); assertInfrastructure(h);
assert.equal(h.board().penalties.length, 1, 'F2 OFF disables goal-driven removal');
h.server.flags.scoreboard_auxiliaries_enabled = true; h.state.data.hockey.game_control.flags.scoreboard_auxiliaries_enabled = true;
h.state.hockeyAuxiliaryGateVersion = (h.state.hockeyAuxiliaryGateVersion || 0) + 1;
await h.score(1, 0); assert.equal(h.board().penalties.length, 1, 'F2 ON must not replay a goal observed while OFF');
assert.equal(deletedSnapshots(h).length, 0);
};
cases.f2_off_during_ack_keeps_row = async () => {
const h = harness(), { hold, goal } = await beginGoal(h);
h.state.hockeyAuxiliaryGateVersion = (h.state.hockeyAuxiliaryGateVersion || 0) + 1;
h.server.flags.scoreboard_auxiliaries_enabled = false; h.state.data.hockey.game_control.flags.scoreboard_auxiliaries_enabled = false;
h.release(hold); await goal; assertInfrastructure(h);
assert.equal(h.board().penalties.length, 1); assert.equal(deletedSnapshots(h).length, 0);
};
cases.nack_keeps_row_without_replay = async () => {
const h = harness(), { hold, goal } = await beginGoal(h);
h.release(hold, { ok: false, confirmation: 'ack', applied: 0,
results: hold.row.body.commands.map(command => ({ ok: false, function: command.Function, ack_waited: true, reason: 'vmix_failed' })) });
await goal; assertInfrastructure(h); assert.equal(h.board().penalties.length, 1); assert.equal(deletedSnapshots(h).length, 0);
assert.ok(h.notices.some(row => /подтвержд|vMix/i.test(row[0])));
const sent = h.frames.length; await h.score(1, 0); assert.equal(h.frames.length, sent);
};
cases.unknown_ack_keeps_row_without_replay = async () => {
const h = harness(), { hold, goal } = await beginGoal(h);
hold.wait.reject(Object.assign(new Error('timeout'), { name: 'AbortError' })); await goal; assertInfrastructure(h);
assert.equal(h.board().penalties.length, 1); assert.equal(deletedSnapshots(h).length, 0);
assert.ok(h.board().penalties[0].goalResolution, 'Retain an explicit unresolved state on the visible row');
const sent = h.frames.length; await h.score(1, 0); assert.equal(h.frames.length, sent);
};
cases.double_first_ack_before_shortening = async () => {
const h = harness([penalty('double', 'away', '4', 180000)]), { hold, goal } = await beginGoal(h);
assertInfrastructure(h); assert.equal(h.board().penalties[0].remainingMs, 180000, 'Keep the original row until the two-minute seed is confirmed');
h.release(hold); await goal; assert.equal(h.board().penalties.length, 1); assert.equal(h.board().penalties[0].remainingMs, 120000);
assert.ok(hold.row.body.commands.every(command => /^OverlayInput[1-8]Out$/.test(command.Function)), 'The first ACK covers OUT only');
const wire = h.commands(), hidden = wire.findIndex(x => x.Function === 'OverlayInput2Out');
const seeded = wire.findIndex(x => x.Function === 'SetCountdown' && x.Value === '00:02:00');
const shown = wire.findLastIndex(x => x.Function === 'OverlayInput2In');
assert.ok(hidden >= 0 && seeded > hidden && shown > seeded, 'Prepare the next two minutes only after hiding the outgoing plate');
};
cases.double_second_ack_before_delete = async () => {
const h = harness([penalty('double', 'away', '2+2', 90000)]), { hold, goal } = await beginGoal(h);
assertInfrastructure(h); assert.equal(h.board().penalties.length, 1);
h.release(hold); await goal; assert.equal(h.board().penalties.length, 0);
};
cases.duplicate_success_only_one_removal = async () => {
const h = harness(), { hold, goal } = await beginGoal(h); h.release(hold); await goal;
const sent = h.frames.length; await h.score(1, 0); assert.equal(h.frames.length, sent); assert.equal(h.board().penalties.length, 0);
};
cases.rehydrate_pending_does_not_repeat_goal = async () => {
const h = harness(), { hold, goal } = await beginGoal(h);
assert.equal(h.board().penalties.length, 1);
const saved = h.c.hockeyGameTimerSnapshot(); h.c.hockeyApplySavedTimers('game-A', saved);
h.c.hockeyStoreGameControl('game-A', clone(h.server), { render: false }); await flush();
assert.equal(h.frames.length, 1); h.release(hold); await goal; assert.equal(h.board().penalties.length, 0);
};
cases.role_change_before_ack_keeps_row = async () => {
const h = harness(), { hold, goal } = await beginGoal(h); h.rights(false); h.release(hold); await goal;
assert.equal(h.board().penalties.length, 1); assert.equal(deletedSnapshots(h).length, 0);
};
cases.major_and_equal_strength_do_not_delete = async () => {
for (const penalties of [[penalty('major', 'away', '5', 200000)], [penalty('home', 'home'), penalty('away', 'away')]]) {
const h = harness(penalties); await h.prime(); await h.score(0, 0); await h.score(1, 0);
assertInfrastructure(h); assert.equal(h.board().penalties.length, penalties.length); assert.equal(h.commands().length, 0);
}
};
cases.ordinary_goal_does_not_need_shb = async () => {
const h = harness(); assert.equal(Boolean(h.server.flags.score_penalty_shot), false);
const { hold, goal } = await beginGoal(h); assert.equal(h.board().penalties.length, 1); h.release(hold); await goal;
assert.equal(h.board().penalties.length, 0);
};
cases.penalty_shot_exception_stays = async () => {
const h = harness(); await h.prime(); await h.score(0, 0); await h.score(1, 0, { goal_type: 'penalty_shot' });
assertInfrastructure(h); assert.equal(h.board().penalties.length, 1); assert.equal(h.commands().length, 0);
};
cases.undo_waits_for_vmix_before_restore = async () => {
const h = harness(), { hold, goal } = await beginGoal(h); h.release(hold); await goal;
assert.equal(h.board().penalties.length, 0);
const undoHold = h.hold(); const undo = h.score(0, 0); await flush();
assert.equal(undoHold.started, true); assert.equal(h.board().penalties.length, 0, 'Undo must await physical restoration before recreating the web row');
h.release(undoHold); await undo; assert.equal(h.board().penalties.length, 1); assert.equal(h.board().penalties[0].remainingMs, 90000);
};
cases.undo_nack_does_not_invent_restored_row = async () => {
const h = harness(), { hold, goal } = await beginGoal(h); h.release(hold); await goal;
const undoHold = h.hold(); const undo = h.score(0, 0); await flush();
h.release(undoHold, { ok: false, confirmation: 'ack', applied: 0, results: undoHold.row.body.commands.map(() => ({ ok: false, ack_waited: true, reason: 'failed' })) });
await undo; assert.equal(h.board().penalties.length, 0);
};
cases.held_ack_pause_keeps_latest_native_time = async () => {
const h = harness([penalty('double', 'away', '4', 180000, { running: true })]), native = nativeModel(h);
const { hold, goal } = await beginGoal(h);
assert.equal(native.clocks.get('HOME-plate').positionMs, 180000, 'The outgoing plate retains its previous time until OUT is confirmed');
h.advance(3000); native.advance(3000);
h.c.controlHockeyPenalty(h.component, 'double', 'pause'); await flush();
assert.equal(h.board().penalties[0].remainingMs, 180000, 'Only the proposed clock progresses while output awaits ACK');
assert.equal(native.clocks.get('HOME-plate').running, true, 'Pause is recorded locally and reaches the physical clock after the plate is hidden');
assert.equal(native.clocks.get('HOME-plate').positionMs, 177000);
h.release(hold); await goal; assertInfrastructure(h);
assert.equal(native.clocks.get('HOME-plate').positionMs, 117000);
assert.equal(native.clocks.get('HOME-plate').running, false);
assert.equal(h.board().penalties[0].remainingMs, 117000); assert.equal(h.board().penalties[0].running, false);
};
cases.held_ack_resume_keeps_latest_native_time = async () => {
const h = harness([penalty('double', 'away', '4', 180000)]), native = nativeModel(h);
const { hold, goal } = await beginGoal(h);
h.advance(3000); h.c.controlHockeyPenalty(h.component, 'double', 'start'); await flush();
assert.equal(native.clocks.get('HOME-plate').running, false, 'Start cannot mutate the outgoing stopped clock before OUT is confirmed');
assert.equal(native.clocks.get('HOME-plate').positionMs, 180000);
h.advance(2000); native.advance(2000);
h.release(hold); await goal; assertInfrastructure(h);
assert.equal(native.clocks.get('HOME-plate').positionMs, 118000);
assert.equal(native.clocks.get('HOME-plate').running, true);
assert.equal(h.board().penalties[0].remainingMs, 118000); assert.equal(h.board().penalties[0].running, true);
};
cases.unmapped_current_segment_uses_other_field_ack = async () => {
const h = harness([penalty('A42', 'away', '2+2', 94000), penalty('A16', 'away', '2+2', 205000)]);
const { hold, goal } = await beginGoal(h);
assert.equal(hold.started, true); assert.equal(h.board().penalties.find(x => x.id === 'A16').remainingMs, 205000);
assert.ok(hold.row.body.commands.some(x => x.Function === 'SetCountdown' && x.Value === '00:01:34'));
assert.ok(!hold.row.body.commands.some(x => /OverlayInput\d+Out/.test(x.Function)));
h.release(hold); await goal; assertInfrastructure(h);
assert.equal(h.board().penalties.find(x => x.id === 'A16').remainingMs, 120000);
assert.equal(h.board().penalties.find(x => x.id === 'A42').remainingMs, 94000);
const result = h.board().history.find(x => x.goal)?.goal;
assert.equal(result.transport, 'confirmed_no_display'); assert.equal(result.delivery.physical_ack, true);
assert.equal(result.delivery.verification_targets.length, 1);
};
cases.shared_input_removes_only_chosen_field = async () => {
const h = harness([penalty('first', 'away', '2', 50000), penalty('second', 'away', '2', 90000)]);
h.timerStep.penalty_display_mode = 'all'; h.timerStep.home_penalty_targets.push({ id: 'H2', input: 'HOME-plate', selected_name: 'Other.Text', overlay: '2', goal_hide_delay_seconds: 0 });
prepareMatch(h); await h.prime(); await settlePhysical(h); await h.score(0, 0); await settlePhysical(h);
h.state.vmixPenaltyTargetAssignments.set('native-step:home:H2', { eventKey: h.component.action_id + ':second',
input: 'HOME-plate', selectedName: 'Other.Text', overlay: '2', running: false, visible: true, mode: 'countdown' });
h.frames.length = 0; const hold = h.hold(), goal = h.score(1, 0); await flush();
assert.ok(hold.row.body.commands.length > 0 && hold.row.body.commands.every(x => /^OverlayInput[1-8]Out$/.test(x.Function)));
assert.ok(hold.row.body.commands.some(x => x.Function === 'OverlayInput2Out' && x.Input === 'HOME-plate'));
assert.ok(!hold.row.body.commands.some(x => x.SelectedName === 'Other.Text'));
h.release(hold); await goal; assertInfrastructure(h);
assert.deepEqual(Array.from(h.board().penalties, x => x.id), ['second']);
assert.equal(h.board().penalties[0].remainingMs, 90000); assert.equal(h.board().penalties[0].running, false);
const wire = h.commands(), hidden = wire.findIndex(x => x.Function === 'OverlayInput2Out');
const cleared = wire.findIndex(x => x.Function === 'SetCountdown' && x.SelectedName === 'Clock.Text' && x.Value === '00:00:00');
const preserved = wire.findIndex(x => x.Function === 'SetCountdown' && x.SelectedName === 'Other.Text' && x.Value === '00:01:30');
const shown = wire.findLastIndex(x => x.Function === 'OverlayInput2In');
assert.ok(hidden >= 0 && cleared > hidden && preserved > hidden && shown > cleared && shown > preserved);
// All-mode compacts the surviving row into the first field while hidden.
const assignment = [...h.state.vmixPenaltyTargetAssignments.values()].find(x => x.eventKey === h.component.action_id + ':second');
assert.equal(assignment.selectedName, 'Clock.Text');
const remainingSeed = wire.findLastIndex(x => x.Function === 'SetCountdown' && x.SelectedName === assignment.selectedName && x.Value === '00:01:30');
assert.ok(remainingSeed > hidden && shown > remainingSeed, 'Prepare the surviving row in its current field before restoring the whole title');
assert.ok(!wire.slice(shown + 1).some(x => /^(Set|Stop|Start)Countdown$/.test(x.Function)), 'No visible countdown reflow after the shared title returns');
assert.equal(h.state.vmixOverlayRuntime.get('2').input, 'HOME-plate');
assert.equal(h.board().history.find(x => x.goal).goal.delivery.shared_input_retained, true);
};
cases.joint_two_four_right_phase_goal_and_undo = async () => {
const h = harness([penalty('two', 'home', '2', 120000, { running: true }), penalty('four', 'away', '4', 240000, { running: true })]);
const native = nativeModel(h); prepareMatch(h); await h.prime(); await settlePhysical(h); await h.score(0, 0); await settlePhysical(h);
assert.equal(native.clocks.get('AWAY-plate').positionMs, 120000, 'The right physical clock starts with the current two-minute4on4 phase');
assert.equal(native.clocks.get('AWAY-plate').visible, true);
assert.equal(h.state.vmixOverlayRuntime.get('3').input, 'AWAY-plate');
assert.equal(h.state.hockeyPenaltyAdvantageCycle.displayOwnerSide, 'away');
assert.equal(h.c.penaltyDisplayEntriesByTargetSide(h.timerStep).holdingEqualStrength, true);
h.advance(120000); native.advance(120000); h.c.updateHockeyPenaltyBoards(h.c.performance.now()); await flush();
await h.c.rebalanceVmixPenaltyTargets(); await flush();
assert.deepEqual(Array.from(h.board().penalties, x => x.id), ['four']);
assert.equal(h.c.penaltyDisplayEntriesByTargetSide(h.timerStep).advantageSide, 'home');
assert.equal(native.clocks.get('AWAY-plate').positionMs, 120000); assert.equal(native.clocks.get('AWAY-plate').visible, true);
const hold = h.hold(), goal = h.score(1, 0); await flush();
assert.equal(hold.started, true); assert.ok(hold.row.body.commands.some(x => x.Function === 'OverlayInput3Out' && x.Input === 'AWAY-plate'));
assert.equal(h.board().penalties.length, 1, 'The actual right-plate OUT must be acknowledged before deleting its row');
h.release(hold); await goal; assertInfrastructure(h);
assert.equal(h.board().penalties.length, 0); assert.ok(deletedSnapshots(h).length > 0);
assert.equal(native.clocks.get('AWAY-plate').visible, false);
const undoHold = h.hold(), undo = h.score(0, 0); await flush();
assert.equal(h.board().penalties.length, 0); h.release(undoHold); await undo; assertInfrastructure(h);
assert.equal(h.board().penalties.length, 1); assert.equal(h.state.hockeyPenaltyAdvantageCycle.displayOwnerSide, 'away');
await h.c.rebalanceVmixPenaltyTargets(); await flush();
assert.equal(native.clocks.get('AWAY-plate').visible, true); assert.equal(native.clocks.get('HOME-plate').visible, false);
assert.equal(h.c.penaltyDisplayEntriesByTargetSide(h.timerStep).plateSide, 'away');
};
(async () => {
const name = process.argv[2]; assert.equal(typeof cases[name], 'function', 'Unknown BUILD200 ACK case ' + name);
await cases[name](); console.log('PASS BUILD200 goal ACK: ' + name);
})().catch(error => { console.error(error); process.exitCode = 1; });