Files
hockey_new/tests/runtime_timer_contracts.cjs

357 lines
25 KiB
JavaScript

// 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')<h.order.indexOf('transport'));
const result=await promise;assert.equal(result.ok,true);
assert.deepEqual(h.functions(),['SetCountdown','StartCountdown']);
h.clear();await h.context.runShortcutSequenceStep(h.sequence,h.step);
assert.equal(h.main.running,false);assert.deepEqual(h.functions(),['SuspendCountdown']);
assert.ok(h.order.indexOf('event:timer_pause')<h.order.indexOf('transport'));
};
cases.shortcut_resume = async()=>{
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;});