// Run actual production functions in a VM; mocks cover I/O and DOM only. // vMix29: SuspendCountdown pauses only; StopCountdown resets; PauseCountdown toggles. // https://www.vmix.com/help29/ShortcutFunctionReference.html const fs = require('node:fs'); const vm = require('node:vm'); const path = require('node:path'); const assert = require('node:assert/strict'); const source = fs.readFileSync(path.join(__dirname, '../ui_builder/static/app.js'), 'utf8'); function extract(name) { const start = source.search(new RegExp('^ (?:async )?function ' + name + '\\(', 'm')); assert.ok(start >= 0, `missing production function ${name}`); const end = source.indexOf('\n }', start); assert.ok(end > start, `missing function end ${name}`); return source.slice(start, end + 4); } // 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'); } function runtime(extra = []) { const noop = () => {}; const game = { action_id:'hockey_game_timer', type:'timer', props:{mode:'count_down',startTime:'20:00'} }; const boardComponent = { action_id:'penalties', type:'hockey_penalty_dashboard', props:{} }; const main = { currentMs:1200000, running:false, paused:true, startedOnce:false, finished:false, reached:{}, lastTimestamp:1000 }; const board = { penalties:[], selectedEventId:null }; const target = {id:'home-slot',input:'Penalty Home',selected_name:'Penalty.Text',overlay:'2'}; const step = {id:'clock-step',enabled:true,type:'hockey_vmix_timers_start',sync_vmix_game:true, game_timer_action_id:game.action_id,game_vmix_input:'Score',game_vmix_selected_name:'Clock.Text', game_vmix_mode:'countdown',sync_vmix_penalties:false,start_web_game:true,start_web_penalties:false, hockey_timer_command:'toggle',home_penalty_targets:[target],away_penalty_targets:[]}; const sequence = {id:'clock-sequence',enabled:true,steps:[step]}; const sent = [], events = [], order = []; const state = {config:{components:[game,boardComponent],shortcut_sequences:[sequence]}, activeHockeyVmixTimerSteps:new Set(),vmixGameCountdownSeededTargets:new Set(), vmixPenaltyTargetAssignments:new Map(),vmixPenaltyMirrors:new Map(),vmixTimerMirrors:new Map(), hockeyPenaltyAdvantageCycle:{hadAdvantage:false,lastAdvantageSide:''},vmixOverlayRuntime:new Map(), runningShortcutSequences:new Set(),pendingShortcutSequenceRuns:new Map(),data:{}}; let allowed = true; const compact = raw => Object.fromEntries(Object.entries(raw).map(([key,value])=>[key,typeof value==='function'?value():value])); const context = {console,Date,AbortController,URLSearchParams,performance:{now:()=>1000},state,boot:{mode:'runtime'},setTimeout,clearTimeout, window:{setTimeout,clearTimeout,HockeyMatchAccess:{storage:{getItem:()=> 'account-a'}}}, refreshQuickPanelOnAirClasses:noop,hockeyRuntimeVmixInputTitle:async input=>String(input), async fetch(url,options={}) { assert.equal(options.method,'POST'); if(url.startsWith('/api/hockey/agents/devices/OUTPUT-A/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);order.push('transport');sent.push(body.commands.map(compact)); 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:value=>String(value).padStart(2,'0'),clamp:(n,l,h)=>Math.max(l,Math.min(h,n)), hockeyCanControl:()=>allowed,componentByActionId:id=>id===game.action_id?game:id===boardComponent.action_id?boardComponent:null, isTimerComponent:c=>c===game,ensureTimerState:()=>main, ensureHockeyBoardState:()=>board,hockeyEventReady:event=>Boolean(event.ready!==false && event.durationMs>0), currentHockeyPenaltyEntries:()=>board.penalties.filter(event=>!event.finished && context.hockeyEventReady(event)).map(event=>({component:boardComponent,event,side:event.side})), sequencePenaltyTargets:(s,side)=>s[side+'_penalty_targets'], penaltyDisplayEntriesByTargetSide:()=>({home:context.currentHockeyPenaltyEntries(),away:[]}), rememberPenaltyAdvantagePlan:noop,vmixInputByRef:()=>null, emitInteraction:(component,event,detail)=>{events.push({component,event,detail});order.push('event:'+event);}, updateTimerNodes:noop,formatTimerValue:()=>String(main.currentMs),persistTimer:noop, timerInitialMilliseconds:c=>context.parseTimerMilliseconds(c.props.startTime), persistHockeyBoard:noop,refreshHockeyBoardNodes:noop,emitHockeyEventUpdated:noop, hockeyPenaltyContext:()=>({}),fireConfiguredTimerFinishActions:noop, timerEndMilliseconds:()=>null,timerMilestones:()=>[], hockeyScheduleTimerSave:noop,addHockeyHistory:noop,hockeyInfractionWebLabel:()=>'', hockeySyncPenaltySideMappingContext:async()=>true, buildShortcutRuntimeContext:()=>({}),sequenceConditionMatches:()=>true, currentRuntimeVmixDeviceId:()=> 'OUTPUT-A',currentRuntimeHockeySessionToken:()=> 'session-a', hockeyMainTimerComponent:()=>game, penaltyFullStrengthSide:()=> 'home',resetPenaltyAdvantageCycle:()=>{state.hockeyPenaltyAdvantageCycle.hadAdvantage=false;state.hockeyPenaltyAdvantageCycle.lastAdvantageSide='';}, hockeyClearSelectedPenaltyMappingContext:async()=>true,updateHockeyBoardTickerNodes:noop, getByPath:(object,key)=>key.split('.').reduce((value,part)=>value?.[part],object), hockeyGameClockSnapshot:()=>({formatted:'18:42',milliseconds:1122000}), uid:()=> 'draft-id',clone:value=>JSON.parse(JSON.stringify(value)),closeHockeyInfractionDropdown:noop, applyPresetToHockeyEvent:(_component,event,preset)=>{event.preset=preset;event.durationMs=event.remainingMs=120000;}, sendRuntimeVmixTimerSequence:commands=>{order.push('transport');sent.push(commands.map(compact));return new Promise(()=>{});}, }; const names = ['parseTimerMilliseconds','vmixCountdownValue','vmixGameCountdownControlCommands', 'vmixPenaltyCountdownControlCommands','vmixGameCountdownTargetKey','markVmixGameCountdownSeeded', 'isVmixGameCountdownSeeded','hockeyTimerSyncStepById','syncActiveVmixGameCountdown', 'configuredHockeyVmixGameCountdownTargets','syncConfiguredScoreboardCountdownsToRuntime', 'emitTimerEvent','controlTimer','runShortcutSequenceStep','updateSingleTimer', 'penaltyMirrorKey','penaltyTargetAssignmentKey','activateConfiguredPenaltyCountdowns', 'rebalanceVmixPenaltyTargets','controlHockeyPenalty','hockeyApplyTimerRules', 'hockeyTimerSelectedGameId','normalizeRuntimeVmixInputRef','runtimeOverlayInputRefs','runtimeOverlayMatchesInput','vmixOverlayLayer', 'templateSequenceValue','templateValue','formatValue', 'hockeyTeamStateSequenceStep','hockeyScoreboardPrimaryOverlayTargets','hockeyScoreboardSequenceIsLive','hockeyScoreboardIsLive', 'hockeyScoreboardDelaySeconds','hockeyScoreboardPresentationContext','hockeyObserveScoreboardPresentation','hockeyScoreboardAuxiliaryReady', 'hockeyScoreboardAuxiliaryDelay','hockeyScoreboardAuxiliaryTargetReady','hockeyMatchFlags', 'hockeyScoreboardTeamStateSettings','hockeyTeamStateSetting','hockeyGameControlLanguage','shortcutVmixStepCommands', 'hockeyAuxiliaryGateContext','hockeyScoreboardAuxiliariesEnabled','hockeyPenaltyEntryDeliveryPending', 'hockeyPenaltyDisplayTargetAllowed', 'hockeyPenaltyCaptionTargetAllowed', 'hockeyPenaltyTargetDeliveryPending', 'hockeyPenaltyTransportSampler', 'sendRuntimeVmixSequence',...extra]; vm.createContext(context); vm.runInContext(productionClosure(context,[...new Set(names)]),context); return {context,game,main,board,boardComponent,step,sequence,target,state,sent,events,order, allow:value=>{allowed=value;},clear:()=>{sent.length=events.length=order.length=0;}, functions:()=>sent.flat().map(command=>command.Function)}; } function functions(commands) {return Array.from(commands,command=>command.Function);} function seededCommands(h,action,value=125000) { return h.context.vmixGameCountdownControlCommands(' Score ',' Clock.Text ',()=>value,action); } const cases = {}; cases.first_start = async()=>{ const h=runtime(); const commands=seededCommands(h,'start',1200000); assert.deepEqual(functions(commands),['SetCountdown','StartCountdown']); assert.equal(commands[0].Value,'00:20:00'); assert.ok(commands.every(c=>c.Input==='Score' && c.SelectedName==='Clock.Text')); assert.deepEqual(Array.from(h.context.vmixGameCountdownControlCommands('','Clock.Text',1000,'start')),[]); assert.deepEqual(Array.from(h.context.vmixGameCountdownControlCommands('Score','',1000,'start')),[]); }; cases.pause_freezes = async()=>{ const h=runtime(); for(const action of ['pause','stop']) { let reads=0; const commands=h.context.vmixGameCountdownControlCommands('Score','Clock.Text',()=>{reads++;return 940000;},action); assert.deepEqual(functions(commands),['SuspendCountdown']); assert.equal(reads,0,'pause cannot read/write browser time or reset native duration'); assert.ok(commands.every(c=>!('Value' in c))); } }; cases.resume_preserves_native = async()=>{ const h=runtime();let reads=0; const commands=h.context.vmixGameCountdownControlCommands('Score','Clock.Text',()=>{reads++;return 1200000;},'resume'); assert.deepEqual(functions(commands),['StartCountdown']);assert.equal(reads,0); const native={position:83,running:false}; for(const c of commands) if(c.Function==='StartCountdown')native.running=true; assert.equal(native.position,83,'resume must keep the native paused position'); }; cases.explicit_edit = async()=>{ const h=runtime(); assert.equal(h.context.controlTimer(h.game.action_id,'set_time','16:01'),true); assert.deepEqual(h.functions(),['SetCountdown','StopCountdown']); assert.equal(h.sent[0][0].Value,'00:16:01');assert.equal(h.main.currentMs,961000); h.clear();h.main.running=true; h.context.controlTimer(h.game.action_id,'set_time','05:17'); assert.deepEqual(h.functions(),['SetCountdown','StopCountdown','StartCountdown']); assert.equal(h.sent[0][0].Value,'00:05:17');assert.equal(h.main.running,true); }; cases.first_edit_before_activation = async()=>{ const h=runtime();assert.equal(h.state.activeHockeyVmixTimerSteps.size,0); for(const [event,value] of [['timer_set_time',300000],['timer_reset',1200000],['timer_add_time',1210000],['timer_subtract_time',1190000]]) { h.clear();h.main.currentMs=value; assert.equal(await h.context.syncActiveVmixGameCountdown(h.game,h.main,event),true); assert.deepEqual(h.functions(),['SetCountdown','StopCountdown']); assert.equal(h.sent[0][0].Value,h.context.vmixCountdownValue(value)); } }; cases.native_events = async()=>{ const h=runtime();h.state.activeHockeyVmixTimerSteps.add(h.step.id); await h.context.syncActiveVmixGameCountdown(h.game,h.main,'timer_start',{wasStartedOnce:false}); assert.deepEqual(h.functions(),['SetCountdown','StartCountdown']); for(const name of ['timer_pause','timer_stop','timer_finished']) { h.clear();await h.context.syncActiveVmixGameCountdown(h.game,h.main,name); assert.deepEqual(h.functions(),['SuspendCountdown']); } for(const [name,detail] of [['timer_resume',{}],['timer_start',{wasStartedOnce:true}]]) { h.clear();await h.context.syncActiveVmixGameCountdown(h.game,h.main,name,detail); assert.deepEqual(h.functions(),['StartCountdown']); } h.clear();await h.context.syncActiveVmixGameCountdown(h.game,h.main,'timer_restart'); assert.deepEqual(h.functions(),['SetCountdown','StartCountdown']); }; cases.no_tick_transport = async()=>{ const h=runtime();h.state.activeHockeyVmixTimerSteps.add(h.step.id); h.main.running=true;h.main.lastTimestamp=1000; h.main.lastWholeSecond=1200; for(let second=1;second<=60;second++) h.context.updateSingleTimer(h.game,h.main,1000+1000*second); assert.equal(h.main.currentMs,1140000,'web countdown still advances'); assert.equal(h.events.filter(e=>e.event==='timer_tick').length,60,'UI receives every second'); assert.deepEqual(h.functions(),[],'native vMix clock receives no per-second packets'); assert.equal(await h.context.syncActiveVmixGameCountdown(h.game,h.main,'timer_tick'),false); }; cases.web_first = async()=>{ const h=runtime(); const promise=h.context.runShortcutSequenceStep(h.sequence,h.step); assert.equal(h.main.running,true,'web state changes before caller awaits'); assert.ok(h.order.indexOf('event:timer_start'){ const h=runtime();h.step.sync_vmix_penalties=true;h.step.start_web_penalties=true; h.board.penalties.push({id:'p1',side:'home',durationMs:120000,remainingMs:120000,running:false,startedOnce:false}); await h.context.runShortcutSequenceStep(h.sequence,h.step); assert.deepEqual(h.functions(),['SetCountdown','StartCountdown','SetCountdown','StopCountdown','StartCountdown']); h.clear();await h.context.runShortcutSequenceStep(h.sequence,h.step); assert.deepEqual(h.functions(),['SuspendCountdown','SuspendCountdown']); h.clear();h.main.currentMs=1182000;h.board.penalties[0].remainingMs=102000; await h.context.runShortcutSequenceStep(h.sequence,h.step); assert.deepEqual(h.functions(),['StartCountdown','StartCountdown']); assert.ok(h.sent.flat().every(c=>!('Value'in c)),'resume cannot reseed from delayed web values'); }; cases.period_base = async()=>{ const h=runtime(); for(const [minutes,expected] of [[20,'00:20:00'],[5,'00:05:00'],[20,'00:20:00']]) { h.clear();h.main.currentMs=47000;h.main.startedOnce=true;h.main.running=true; h.context.hockeyApplyTimerRules({timer_rules:{minutes}}); h.context.controlTimer(h.game.action_id,'reset'); assert.equal(h.main.currentMs,minutes*60000);assert.equal(h.main.startedOnce,false); assert.deepEqual(h.functions(),['SetCountdown','StopCountdown']); h.clear();await h.context.runShortcutSequenceStep(h.sequence,h.step); assert.deepEqual(h.functions(),['SetCountdown','StartCountdown']);assert.equal(h.sent[0][0].Value,expected); } }; cases.scoreboard_seed = async()=>{ const h=runtime();let sent=false;let release; h.context.sendRuntimeVmixTimerSequence=commands=>{sent=true;h.sent.push(commands);return new Promise(r=>{release=r;});}; const pending=h.context.syncConfiguredScoreboardCountdownsToRuntime(); assert.equal(sent,true);assert.deepEqual(h.functions(),['SetCountdown','StopCountdown']); let finished=false;pending.then(()=>{finished=true;});await Promise.resolve();assert.equal(finished,false); release({ok:true});await pending;assert.equal(finished,true); h.clear();h.main.running=true; await h.context.syncConfiguredScoreboardCountdownsToRuntime();assert.deepEqual(h.functions(),[],'showing scoreboard cannot disturb running native clock'); }; cases.prepared_penalty = async()=>{ const h=runtime();h.step.sync_vmix_penalties=true;h.state.activeHockeyVmixTimerSteps.add(h.step.id); const penalty={id:'p1',side:'home',durationMs:120000,remainingMs:120000,running:false,startedOnce:false};h.board.penalties.push(penalty); h.state.data.hockey={selected_game:{external_id:'game-a'}, game_control:{game_id:'game-a',flags:{scoreboard_auxiliaries_enabled:true}}}; h.state.config.shortcut_sequences.push({id:'scoreboard',enabled:true,is_scoreboard_sequence:true, steps:[{type:'vmix_command',enabled:true,function:'OverlayInput1In',input:'Upper score',condition:'always'}]}); h.state.vmixOverlayRuntime.set('1',{input:'Upper score'}); // Actual upper-score ACK/tally before auxiliary IN. await h.context.rebalanceVmixPenaltyTargets({scoreboardActive:true}); assert.deepEqual(h.functions(),['SetCountdown','StopCountdown','OverlayInput2In']); assert.equal(penalty.startedOnce,false);assert.equal(penalty.running,false); h.clear();h.context.controlHockeyPenalty(h.boardComponent,penalty.id,'start'); assert.equal(penalty.startedOnce,true);assert.deepEqual(h.functions(),['SetCountdown','StopCountdown','StartCountdown']); }; cases.penalty_pause = async()=>{ const h=runtime();h.step.sync_vmix_penalties=true;h.state.activeHockeyVmixTimerSteps.add(h.step.id); const penalty={id:'p1',side:'home',durationMs:120000,remainingMs:120000,running:false,startedOnce:false};h.board.penalties.push(penalty); h.context.controlHockeyPenalty(h.boardComponent,'p1','start');h.clear(); penalty.remainingMs=89000;h.context.controlHockeyPenalty(h.boardComponent,'p1','pause'); assert.deepEqual(h.functions(),['SuspendCountdown']); h.clear();h.context.controlHockeyPenalty(h.boardComponent,'p1','start'); assert.deepEqual(h.functions(),['StartCountdown']); assert.ok(h.sent.flat().every(c=>!('Value'in c))); }; cases.penalty_edit_reset = async()=>{ const h=runtime();h.step.sync_vmix_penalties=true;h.state.activeHockeyVmixTimerSteps.add(h.step.id); const penalty={id:'p1',side:'home',durationMs:120000,remainingMs:120000,running:false,startedOnce:false};h.board.penalties.push(penalty); h.context.controlHockeyPenalty(h.boardComponent,'p1','start');h.clear(); h.context.controlHockeyPenalty(h.boardComponent,'p1','set_time','01:11'); assert.deepEqual(h.functions(),['SetCountdown','StopCountdown','StartCountdown']);assert.equal(h.sent[0][0].Value,'00:01:11'); h.clear();h.context.controlHockeyPenalty(h.boardComponent,'p1','reset'); assert.deepEqual(h.functions(),['SetCountdown','StopCountdown']);assert.equal(penalty.remainingMs,120000);assert.equal(penalty.running,false); }; cases.no_legacy_text_packets = async()=>{ const h=runtime();h.step.game_vmix_mode='text';h.step.penalty_vmix_mode='text'; await h.context.runShortcutSequenceStep(h.sequence,h.step); assert.deepEqual(h.functions(),['SetCountdown','StartCountdown']); assert.equal(h.state.vmixTimerMirrors.size,0);assert.equal(h.state.vmixPenaltyMirrors.size,0); h.clear();h.context.emitTimerEvent(h.game,'timer_tick',h.main);assert.deepEqual(h.functions(),[]); }; cases.target_scope = async()=>{ const h=runtime(); h.sequence.steps.push({...h.step,id:'same-target'}); h.sequence.steps.push({...h.step,id:'disabled-target',enabled:false,game_vmix_input:'Wrong'}); h.sequence.steps.push({...h.step,id:'other-timer',game_timer_action_id:'other',game_vmix_input:'Other'}); await h.context.syncActiveVmixGameCountdown(h.game,h.main,'timer_set_time'); assert.equal(h.sent.flat().length,2);assert.ok(h.sent.flat().every(c=>c.Input==='Score'&&c.SelectedName==='Clock.Text')); h.clear();h.step.game_vmix_selected_name='';h.sequence.steps=[h.step]; assert.equal(await h.context.syncActiveVmixGameCountdown(h.game,h.main,'timer_set_time'),false);assert.deepEqual(h.functions(),[]); }; cases.busy_timer = async()=>{ const h=runtime(['shortcutSequenceTimerControlSteps','runShortcutTimerControlsWhileBusy']); h.main.running=true;h.main.startedOnce=true; h.sequence.steps.push({id:'title-step',enabled:true,type:'vmix_command',function:'Cut'}); assert.equal(h.context.runShortcutTimerControlsWhileBusy(h.sequence,{queued:true}),true); assert.equal(h.main.running,false,'busy title sequence cannot delay the next Space pause'); assert.deepEqual(h.functions(),['SuspendCountdown']); assert.equal(h.context.shortcutSequenceTimerControlSteps(h.sequence).length,1,'non-timer steps cannot run twice'); }; cases.penalty_priority = async()=>{ const h=runtime(['hockeyPenaltyHasStarted','activeHockeyPenaltyEntries','sortedPenaltyEntries', 'penaltyLocalAdvantageSide','penaltyDisplayEntriesByTargetSide']); h.board.penalties.push( {id:'paused',side:'home',durationMs:120000,remainingMs:20000,running:false,startedOnce:true}, {id:'running',side:'home',durationMs:120000,remainingMs:80000,running:true,startedOnce:true}, {id:'prepared',side:'home',durationMs:120000,remainingMs:120000,running:false,startedOnce:false}); assert.deepEqual(Array.from(h.context.sortedPenaltyEntries('home'),entry=>entry.event.id),['running','paused']); const display=h.context.penaltyDisplayEntriesByTargetSide(h.step); assert.equal(display.transitionEntry.event.id,'running');assert.equal(display.away.length,1);assert.equal(display.home.length,0); h.board.penalties[1].running=false; assert.equal(h.context.sortedPenaltyEntries('home')[0].event.id,'paused'); }; cases.prepared_emission = async()=>{ const h=runtime(['createHockeyPenaltyDraft','emitHockeyEventUpdated']);h.step.sync_vmix_penalties=true; const event=h.context.createHockeyPenaltyDraft(h.boardComponent); assert.equal(event.startedOnce,false);assert.equal(event.running,false);assert.equal(event.durationMs,0); assert.equal(event.readyPending,undefined);assert.deepEqual(h.functions(),[]); event.durationMs=event.remainingMs=120000;event.side='home';event.infraction={id:'minor'};event.preset='2'; h.context.emitHockeyEventUpdated(h.boardComponent,event,'preset'); assert.equal(event.readyPending,true);assert.equal(event.assignedPending,true); assert.equal(event.startedOnce,false);assert.equal(event.running,false); assert.equal(h.state.activeHockeyVmixTimerSteps.has(h.step.id),true); assert.ok(!h.events.some(e=>e.event==='penalty_ready'||e.event==='penalty_assigned')); assert.deepEqual(h.functions(),[],'ready triggers and plate wait for saved authoritative strength'); }; cases.strength_plate_cycle = async()=>{ const h=runtime(['hockeyPenaltyHasStarted','activeHockeyPenaltyEntries','sortedPenaltyEntries', 'penaltyLocalAdvantageSide','penaltyDisplayEntriesByTargetSide','rememberPenaltyAdvantagePlan']); const entry=(id,side,remaining=120000)=>({id,side,durationMs:120000,remainingMs:remaining,running:false,startedOnce:false}); h.board.penalties.push(entry('h1','home'));let display=h.context.penaltyDisplayEntriesByTargetSide(h.step); assert.equal(display.advantageSide,'away');assert.equal(display.away.length,1,'ready, not started penalty already owns a plate'); h.context.rememberPenaltyAdvantagePlan(display);assert.equal(h.state.hockeyPenaltyAdvantageCycle.hadAdvantage,true); h.board.penalties.push(entry('a1','away')); display=h.context.penaltyDisplayEntriesByTargetSide(h.step); assert.equal(display.holdingEqualStrength,true);assert.equal(display.away.length,1);assert.equal(display.home.length,0); h.state.hockeyPenaltyAdvantageCycle={hadAdvantage:false,lastAdvantageSide:''}; display=h.context.penaltyDisplayEntriesByTargetSide(h.step); assert.equal(display.holdingEqualStrength,false);assert.equal(display.home.length+display.away.length,0); }; cases.full_strength_waits_ready = async()=>{ for(const method of ['manual','tick'])for(const ready of [true,false]){ const h=runtime(['updateHockeyPenaltyBoards']);const completed=[]; h.context.fireConfiguredTimerFinishActions=(_source,detail)=>completed.push(detail); h.state.hockeyPenaltyAdvantageCycle={hadAdvantage:true,lastAdvantageSide:'away'}; h.board.penalties.push({id:'ending',side:'home',durationMs:120000,remainingMs:100,running:true,startedOnce:true,lastTimestamp:1000}, {id:'waiting',side:'home',durationMs:ready?120000:0,remainingMs:ready?120000:0,running:false,startedOnce:false,ready}); if(method==='manual')assert.equal(h.context.controlHockeyPenalty(h.boardComponent,'ending','finish'),true); else h.context.updateHockeyPenaltyBoards(1200); for(let turn=0;turn<5;turn++)await Promise.resolve(); assert.equal(completed.length,1,method); assert.equal(completed[0].remaining_total,ready?1:0,`${method}: complete prepared penalty counts; incomplete draft does not`); assert.equal(h.state.hockeyPenaltyAdvantageCycle.hadAdvantage,ready,method); } }; (async()=>{const scenario=process.argv[2];assert.ok(cases[scenario],`unknown timer scenario ${scenario}`);await cases[scenario]();console.log(`PASS: ${scenario}`);})().catch(error=>{console.error(error);process.exitCode=1;});