Files
hockey_new/tests/hotfix_penalty_visibility.cjs
2026-09-26 23:18:54 +03:00

91 lines
7.0 KiB
JavaScript

const fs=require('node:fs'), vm=require('node:vm'), path=require('node:path'), assert=require('node:assert/strict');
const source=fs.readFileSync(path.join(__dirname,'../ui_builder/static/app.js'),'utf8');
const names=['currentHockeyPenaltyEntries','activeHockeyPenaltyEntries','hockeyPenaltyHasStarted',
'sortedPenaltyEntries','penaltyLocalAdvantageSide','penaltyDisplayEntriesByTargetSide',
'hockeyTimerSyncStepById','activateConfiguredPenaltyCountdowns','penaltyTargetAssignmentKey',
'penaltyMirrorKey','vmixCountdownValue','vmixGameCountdownControlCommands','vmixPenaltyCountdownControlCommands',
'rebalanceVmixPenaltyTargets','emitHockeyEventUpdated','hockeyEventReady','hockeyScoreboardIsLive',
'shortcutSequenceIsOnAir','sequenceMatchesRuntimeOverlay','runtimeOverlayMatchesInput',
'normalizeRuntimeVmixInputRef','runtimeOverlayInputRefs'];
const definitions=names.map(name=>{const start=source.search(new RegExp(' (?:async )?function '+name+'\\('));
assert.ok(start>=0,name);return source.slice(start,source.indexOf('\n }',start)+4);}).join('\n');
const target={id:'home-slot',input:'home-penalty',selected_name:'Time.Text',overlay:'2'};
const step={id:'timers',type:'hockey_vmix_timers_start',sync_vmix_penalties:true,home_penalty_targets:[target],away_penalty_targets:[]};
const scoreboard={id:'score',is_scoreboard_sequence:true};
const component={type:'hockey_penalty_dashboard',action_id:'penalties'};
const event={id:'p1',side:'away',player:{side:'away'},infraction:null,preset:'2',durationMs:120000,
remainingMs:120000,startedOnce:false,running:false,finished:false};
const board={penalties:[event]};
const sent=[];let allowed=true;
const state={config:{components:[component],shortcut_sequences:[scoreboard,{id:'space',steps:[step]}]},
data:{},hockeyPenaltyAdvantageCycle:{},activeHockeyVmixTimerSteps:new Set(),
vmixPenaltyTargetAssignments:new Map(),vmixPenaltyMirrors:new Map(),vmixOverlayTallyAuthoritative:true,
vmixOverlayRuntime:new Map([['1',{input:'score-input'}]]),shortcutSequenceOverlayState:new Map(),
quickPanelServerOnAirSequences:new Set(),quickPanelOnAirSequences:new Set()};
const noop=()=>{};
const ctx={console,state,performance,Date,ensureHockeyBoardState:()=>board,hockeyCanControl:()=>allowed,
padTimer:n=>String(n).padStart(2,'0'),sequencePenaltyTargets:(s,side)=>s[side+'_penalty_targets'],
getByPath:()=>({}),rememberPenaltyAdvantagePlan:noop,emitInteraction:noop,hockeyPenaltyContext:()=>({}),
addHockeyHistory:noop,hockeyInfractionWebLabel:()=>'',hockeySyncPenaltySideMappingContext:()=>Promise.resolve(),
sendRuntimeVmixTimerSequence:commands=>{sent.push(...commands);return Promise.resolve({ok:true});},
shortcutSequenceById:id=>state.config.shortcut_sequences.find(s=>s.id===id),
shortcutSequenceOverlayTargets:s=>s?.id==='score'?[{layer:'1',input:'score-input'}]:[],
shortcutSequencePreviewTargets:()=>[],sequenceStillOwnsRuntimeOverlay:()=>false};
vm.createContext(ctx);vm.runInContext(definitions,ctx);
const functions=()=>sent.map(c=>c.Function);
(async()=>{
// No local ON AIR latch, only authoritative vMix tally (e.g. after reload).
assert.equal(ctx.hockeyScoreboardIsLive(),true);
ctx.emitHockeyEventUpdated(component,event,'player');
assert.deepEqual(functions(),[],'an incomplete draft must not display');
event.infraction={id:'trip'};
ctx.emitHockeyEventUpdated(component,event,'infraction');
assert.deepEqual(functions(),['SetCountdown','StopCountdown','OverlayInput2In']);
assert.equal(sent[0].Value,'00:02:00');
assert.ok(sent.every(c=>c.Input==='home-penalty'));
assert.equal(event.running,false);assert.equal(event.startedOnce,false);
sent.length=0;ctx.emitHockeyEventUpdated(component,event,'player');
assert.deepEqual(functions(),[],'duplicate ready notification must not reseed or redisplay');
event.durationMs=event.remainingMs=240000;
ctx.emitHockeyEventUpdated(component,event,'preset');
assert.deepEqual(functions(),['SetCountdown','StopCountdown']);assert.equal(sent[0].Value,'00:04:00');
// Before F1, off-air preparation sends nothing. Showing the scorebug displays
// the penalty even if its target was assigned previously but hidden later.
sent.length=0;state.vmixOverlayRuntime.clear();
state.shortcutSequenceOverlayState.set('score',true);
assert.equal(ctx.hockeyScoreboardIsLive(),false,'authoritative OUT wins over stale local latch');
event.durationMs=event.remainingMs=300000;
ctx.emitHockeyEventUpdated(component,event,'preset');
assert.deepEqual(functions(),['SetCountdown','StopCountdown'],'editing a previously assigned hidden Input updates its stopped time');
assert.equal(sent[0].Value,'00:05:00');
sent.length=0;state.vmixPenaltyTargetAssignments.clear();state.activeHockeyVmixTimerSteps.clear();
ctx.emitHockeyEventUpdated(component,event,'preset');assert.deepEqual(functions(),[],'cold off-air draft sends nothing');
ctx.activateConfiguredPenaltyCountdowns();
await ctx.rebalanceVmixPenaltyTargets({scoreboardActive:true,forceOverlay:true});
assert.deepEqual(functions(),['SetCountdown','StopCountdown','OverlayInput2In']);
assert.equal(sent[0].Value,'00:05:00');
sent.length=0;await ctx.rebalanceVmixPenaltyTargets({scoreboardActive:true,forceOverlay:true});
assert.deepEqual(functions(),['OverlayInput2In'],'showing scorebug again must not restart native timer');
sent.length=0;allowed=false;state.vmixOverlayRuntime.set('1',{input:'score-input'});
ctx.emitHockeyEventUpdated(component,event,'preset');await ctx.rebalanceVmixPenaltyTargets({force:true});
assert.deepEqual(functions(),[],'viewer never sends a title or timer');
// A failed dispatch must not leave an Input marked as freshly seeded.
allowed=true;
const start=source.indexOf(' function sendRuntimeVmixTimerSequence(');
vm.runInContext(source.slice(start,source.indexOf('\n }',start)+4),ctx);
Object.assign(ctx,{compactVmixCommand:c=>c,currentRuntimeVmixDeviceId:()=> 'd',currentRuntimeHockeySessionToken:()=> 's',
trackRuntimeOverlayCommands:noop,applyServerRuntimeOverlayState:noop,console:{warn:noop}});
ctx.fetch=async()=>({ok:false,status:503,json:async()=>({})});
await ctx.sendRuntimeVmixTimerSequence([{Function:'SetCountdown',Input:'home-penalty',SelectedName:'Time.Text',Value:'00:05:00'}]);
assert.equal(state.vmixPenaltyTargetAssignments.size,0,'failed send clears the cached assignment for a later fresh seed');
const key='timers:home:home-slot';
state.vmixPenaltyTargetAssignments.set(key,{input:'home-penalty'});
let finish;
ctx.fetch=()=>new Promise(resolve=>{finish=resolve});
const oldRequest=ctx.sendRuntimeVmixTimerSequence([{Function:'StartCountdown',Input:'home-penalty'}]);
const newer={input:'home-penalty',running:true};state.vmixPenaltyTargetAssignments.set(key,newer);
finish({ok:false,status:503,json:async()=>({})});await oldRequest;
assert.equal(state.vmixPenaltyTargetAssignments.get(key),newer,'late failed response must keep newer assignment');
console.log('PASS: prepared penalty display, fresh stopped time, Agent tally, off-air, F1 redisplay, viewer');
})().catch(e=>{console.error(e);process.exitCode=1;});