Files
hockey_new/tests/hotfix_penalty_visibility.cjs

156 lines
12 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');
// Load new transport dependencies from production; existing I/O/DOM edges stay local.
function productionClosure(context, roots) {
const functions = new Map(), pattern = /^( {0,2})(?:async )?function (\w+)\(/gm;
let match;
while ((match = pattern.exec(source))) {
const start = match.index, lineEnd = source.indexOf('\n', start);
const singleLine = /\}\s*;?\s*$/.test(source.slice(start, lineEnd));
const end = singleLine ? lineEnd : source.indexOf('\n' + match[1] + '}', start) + match[1].length + 2;
assert.ok(end > start, 'Unclosed production helper ' + match[2]);
functions.set(match[2], source.slice(start, end));
}
const needed = new Set(roots);
function visit(name) {
assert.ok(functions.has(name), 'Missing actual production helper ' + name);
for (const candidate of functions.keys()) {
if (needed.has(candidate) || typeof context[candidate] === 'function') continue;
if (new RegExp('\\b' + candidate + '\\s*\\(').test(functions.get(name))) {
needed.add(candidate); visit(candidate);
}
}
}
roots.forEach(visit);
return [...needed].map(name => functions.get(name)).join('\n');
}
const names=['currentHockeyPenaltyEntries','activeHockeyPenaltyEntries','hockeyPenaltyHasStarted',
'sortedPenaltyEntries','penaltyLocalAdvantageSide','penaltyDisplayEntriesByTargetSide',
'hockeyTimerSyncStepById','activateConfiguredPenaltyCountdowns','penaltyTargetAssignmentKey',
'penaltyMirrorKey','vmixCountdownValue','vmixGameCountdownControlCommands','vmixPenaltyCountdownControlCommands',
'rebalanceVmixPenaltyTargets','emitHockeyEventUpdated','hockeyEventReady','hockeyScoreboardIsLive',
'shortcutSequenceIsOnAir','sequenceMatchesRuntimeOverlay','runtimeOverlayMatchesInput',
'normalizeRuntimeVmixInputRef','runtimeOverlayInputRefs',
'hockeyScoreboardSequenceIsLive','hockeyScoreboardPrimaryOverlayTargets','hockeyTeamStateSequenceStep',
'hockeyScoreboardDelaySeconds','hockeyScoreboardPresentationContext','hockeyObserveScoreboardPresentation','hockeyScoreboardAuxiliaryReady',
'hockeyScoreboardAuxiliaryDelay','hockeyScoreboardAuxiliaryTargetReady','hockeyMatchFlags',
'hockeyScoreboardTeamStateSettings','hockeyTeamStateSetting','hockeyGameControlLanguage','shortcutVmixStepCommands',
'hockeyVmixTransportContext','hockeyVmixTrace','hockeyVmixTraceNow','hockeyTrackVmixDelivery',
'buildShortcutRuntimeContext','isTimerComponent','getByPath','formatValue','templateValue','templateSequenceValue',
'vmixOverlayLayer','vmixInputByRef','vmixInventoryInputs','hockeyStoreGameControl',
'hockeyReadyPenaltyCounts','hockeyHasPendingPenaltyInteractions','hockeyFlushPendingPenaltyInteractions',
'hockeyStrengthMappingSignature','hockeyTeamStateFlagSignature','hockeyTimerSelectedGameId',
'hockeyAuxiliaryGateContext','hockeyScoreboardAuxiliariesEnabled',
'hockeyAuxiliaryVisibilityCommands','hockeyScoreboardAuxiliaryCommand',
'hockeyObserveScoreForPenaltyGoal','hockeyGoalPenaltyContext','noteHockeySelectionContext',
'hockeyObserveAuxiliaryGateControl', 'hockeyPenaltyEntryDeliveryPending', 'hockeyPenaltyDisplayTargetAllowed', 'hockeyPenaltyCaptionTargetAllowed',
'hockeyPenaltyTargetDeliveryPending', 'hockeyPenaltyTransportSampler', 'sendRuntimeVmixSequence'];
const clampDefinition=source.match(/^ const clamp = .+;$/m);assert.ok(clampDefinition,'actual production clamp');
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,steps:[{type:'vmix_command',function:'OverlayInput1In',input:'score-input'}]};
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=[], timeline=[];let allowed=true;
const state={config:{components:[component],shortcut_sequences:[scoreboard,{id:'space',steps:[step]}]},
data:{hockey:{selected_game:{external_id:'g'}}},formValues:{},hockeyGameControl:{},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,AbortController,URLSearchParams,setTimeout,clearTimeout,window:{setTimeout,clearTimeout,UIBuilderRuntime:{patchData:patch=>Object.assign(state.data.hockey,patch.hockey)},dispatchEvent:noop},clone:value=>JSON.parse(JSON.stringify(value)),ensureHockeyBoardState:()=>board,hockeyCanControl:()=>allowed,
currentRuntimeVmixDeviceId:()=> 'd',currentRuntimeHockeySessionToken:()=> 's',
refreshQuickPanelOnAirClasses:noop,hockeyRuntimeVmixInputTitle:async input=>String(input),
async fetch(url,options={}) {
assert.equal(options.method,'POST');
if(url.startsWith('/api/hockey/agents/devices/d/apply-mapping?')) return {ok:true,status:200,json:async()=>({ok:true,complete:true,mapping_ack_waited:true,confirmation:'ack',applied:1,
text_fields_total:1,text_fields_confirmed:1,missing:[],rule_missing:[],errors:[],warnings:[]})};
assert.equal(url,'/api/hockey/vmix/sequence');
const body=JSON.parse(options.body);timeline.push('countdown-transport');sent.push(...body.commands);
return {ok:true,status:200,json:async()=>({ok:true,confirmation:'ack',applied:body.commands.length,
results:body.commands.map(command=>({ok:true,function:command.Function,ack_waited:true}))})};
},
padTimer:n=>String(n).padStart(2,'0'),sequencePenaltyTargets:(s,side)=>s[side+'_penalty_targets'],
getByPath:()=>({}),rememberPenaltyAdvantagePlan:noop,emitInteraction:(_component,name)=>timeline.push(name),hockeyPenaltyContext:()=>({}),
addHockeyHistory:noop,hockeyInfractionWebLabel:()=>'',hockeySyncPenaltySideMappingContext:()=>Promise.resolve(),
sendRuntimeVmixTimerSequence:commands=>{timeline.push('countdown-transport');sent.push(...commands);return Promise.resolve({ok:true});},
hockeyApplyTimerRules:noop,hockeyBackupPlayerSelectionValues:noop,persistHockeyBoard:noop,
hockeyRefreshVmixMappingForStrength:async()=>{timeline.push('strength-mapping-confirmed');return true;},
hockeyTeamStateScoreboardIsLive:()=>false,hockeySyncScoreboardConditionalSteps:async()=>0,
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(clampDefinition[0]+'\n'+productionClosure(ctx,names),ctx);
const functions=()=>sent.map(c=>c.Function);
const settle=async()=>{for(let i=0;i<12;i++)await Promise.resolve();};
const serverState=()=>({game_id:'g',current_period:'1',flags:{scoreboard_auxiliaries_enabled:true},strength:{home_penalties:0,away_penalties:1,advantage_side:'home'}});
const afterSave=async()=>{ctx.hockeyStoreGameControl('g',serverState(),{render:false,dispatch:false});await settle();
await state.vmixUrgentCommandQueue;await state.vmixCommandQueue;await settle();};
(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(),[],'newly prepared penalty waits for authoritative strength and Mapping');
assert.equal(event.readyPending,true);
await afterSave();
assert.ok(timeline.indexOf('strength-mapping-confirmed')<timeline.indexOf('countdown-transport'));
assert.ok(timeline.indexOf('countdown-transport')<timeline.indexOf('penalty_ready'));
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');await afterSave();
assert.deepEqual(functions(),[],'duplicate ready notification must not reseed or redisplay');
event.durationMs=event.remainingMs=240000;
ctx.emitHockeyEventUpdated(component,event,'preset');
await ctx.rebalanceVmixPenaltyTargets({editedEventKey:ctx.penaltyMirrorKey(component,event)});
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');
await ctx.rebalanceVmixPenaltyTargets({editedEventKey:ctx.penaltyMirrorKey(component,event)});
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();
state.vmixOverlayRuntime.set('1',{input:'score-input'}); // F1's upper-score ACK/tally, before its auxiliary reconciliation.
await ctx.rebalanceVmixPenaltyTargets({scoreboardActive:true,forceOverlay:true});
assert.deepEqual(functions(),['SetCountdown','StopCountdown','OverlayInput2In']);
assert.equal(sent[0].Value,'00:05:00');
sent.length=0;state.vmixOverlayRuntime.delete('2'); // External penalty OUT tally: F1 must restore the plate without reseeding.
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;});