156 lines
12 KiB
JavaScript
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;});
|