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;});