216 lines
16 KiB
JavaScript
216 lines
16 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.equal(h.commands().some(command => /OverlayInput\d+Out/.test(command.Function)), false);
|
|
};
|
|
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, 120000);
|
|
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, false);
|
|
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, true);
|
|
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' });
|
|
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.some(x => x.Function === 'SetCountdown' && x.SelectedName === 'Clock.Text' && x.Value === '00:00:00'));
|
|
assert.ok(!hold.row.body.commands.some(x => /OverlayInput\d+Out/.test(x.Function)));
|
|
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.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; });
|