184 lines
31 KiB
JavaScript
184 lines
31 KiB
JavaScript
/* Behavioral contracts for actual match/read/provider helpers. Only I/O and UI edges are fixtures. */
|
|
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),assert=require('node:assert/strict');
|
|
const root=path.join(__dirname,'..');
|
|
const sources={menu:fs.readFileSync(path.join(root,'hockey_data/static/tournament-menu.js'),'utf8'),app:fs.readFileSync(path.join(root,'ui_builder/static/app.js'),'utf8')};
|
|
const copy=value=>JSON.parse(JSON.stringify(value));
|
|
const tick=async()=>{for(let i=0;i<60;i++)await Promise.resolve();};
|
|
function defer(){let resolve,reject;const promise=new Promise((yes,no)=>{resolve=yes;reject=no;});return{promise,resolve,reject};}
|
|
function definition(source,name){
|
|
const match=new RegExp('(^|\\n)([ \\t]*)(?:async )?function '+name+'\\(').exec(source);
|
|
if(!match){const assignment=new RegExp('(^|\\n)([ \\t]*)const '+name+' =').exec(source);assert.ok(assignment,'Missing actual production helper '+name);const start=assignment.index+assignment[1].length,end=source.indexOf(';\n',start);assert.ok(end>start,'Unclosed actual helper '+name);return source.slice(start,end+1);}
|
|
const start=match.index+match[1].length,end=source.indexOf('\n'+match[2]+'}',start);assert.ok(end>start,'Unclosed actual helper '+name);return source.slice(start,end+match[2].length+2);
|
|
}
|
|
function install(context,kind,names){vm.createContext(context);vm.runInContext(names.map(name=>definition(sources[kind],name)).join('\n'),context,{filename:'actual-'+kind+'-helpers.js'});return context;}
|
|
function full(id,version=1,{finished=true}={}){return{external_id:id,tournament_external_id:'t',is_finished:finished,status:finished?'finished':'live',home:{name:id+' home',score:version,players:[{id:id+'-home-v'+version}]},away:{name:id+' away',score:0,players:[{id:id+'-away-v'+version}]},referees:[{id:id+'-referee-v'+version}]};}
|
|
function compact(id,{finished=true}={}){const value=full(id,1,{finished});delete value.home.players;delete value.away.players;delete value.referees;return value;}
|
|
function details(id,version=1,options={}){return{game:full(id,version,options),details:{loaded:true,synced_at:'2026-10-'+String(19+version).padStart(2,'0')+'T12:00:00Z'}};}
|
|
const menuNames=['captureGameReadContext','gameReadContextCurrent','applyLoadedGameSnapshot','loadCachedGameSnapshot','pollSelectedGame','stopSelectedGamePolling','scheduleSelectedGamePoll','startSelectedGamePolling','selectedGameIsFinished','applyGameData','reloadSelectedGameDetails','restoreSelection','enrichOpenedGame','openGame','clearRuntimeMatchSpecificData','cachedMatchHasRosters'];
|
|
function menuHarness({onRequest,initialId='A',finished=true}={}){
|
|
const requests=[],events=[],patches=[],notices=[],timers=new Map();let nextTimer=0;
|
|
const state={selectedId:'t',selectedGameId:initialId,selectedGame:full(initialId,1,{finished}),selectedMatchDetails:details(initialId).details,gameOpenGeneration:1,gameOpenBusy:false,gamePollBusy:false,gamePollErrorCount:0,view:'games',open:false,games:{items:[compact('A'),compact('B')]},language:'ru',teamSchedule:null,standings:null,tournamentStatistics:null,statLabels:null};
|
|
const storage={game:'game',session:'session',tournament:'tournament',vmixDevice:'device',gameDate:'date',account:'account'};
|
|
const values=new Map([['session','session'+initialId],['account','account1']]);
|
|
let runtimeData={hockey:{selected_game:copy(state.selectedGame),home:copy(state.selectedGame.home),away:copy(state.selectedGame.away),match_details:copy(state.selectedMatchDetails),operator_session:{token:'session'+initialId}}};
|
|
const timer=(fn,ms)=>{const id=++nextTimer;timers.set(id,{fn,ms});return id;};
|
|
const window={HockeyAgentRuntime:{currentDeviceId:()=> 'device1'},HockeyMatchAccess:{beforeGameChange:async()=>{},selectGame:async()=>{},finishOpening:()=>{}},dispatchEvent:event=>events.push(event),UIBuilderRuntime:{getData:()=>copy(runtimeData),patchData:patch=>{patches.push(copy(patch));runtimeData=context.mergeExternalData(runtimeData,patch);}}};
|
|
|
|
const context={state,storage,window,clone:copy,AbortController,URLSearchParams,Date,Map,Set,performance,console:{warn(){},error(){}},document:{hidden:false,body:{classList:{contains:()=>false}},querySelector:()=>null},setTimeout:timer,clearTimeout:id=>timers.delete(id),
|
|
runtimeStorage:{getItem:key=>values.get(key)||'',setItem:(key,value)=>values.set(key,String(value)),removeItem:key=>values.delete(key)},CustomEvent:class{constructor(type,options){this.type=type;this.detail=options?.detail;}},
|
|
schedulePayload:()=>({items:state.games?.items||[]}),operatorSettingsEditingActive:()=>false,renderSelectedChip:()=>{},renderGames:()=>{},t:value=>value,notify:(...args)=>notices.push(args),
|
|
currentAgentDeviceId:()=> 'device1',refreshSessionAgentMapping:async()=>({ok:true}),ensureSessionAgentAssignment:async()=>null,setMatchLoading:()=>{},updateMatchLoading:()=>{},setOpen:()=>{},matchLabelById:id=>id,gameById:id=>state.games?.items?.find(game=>game.external_id===id)||null,
|
|
applyTournamentData:()=>{},loadGames:async()=>{},loadTeamSchedule:async()=>{},loadTournamentStandings:async()=>{},loadTournamentStatistics:async()=>{},loadStatLabels:()=>{},
|
|
request:async(url,options={})=>{requests.push({url,options});if(onRequest){const custom=onRequest(url,options);if(custom!==undefined)return custom;}if(url==='/api/hockey/sessions'){const id=JSON.parse(options.body).game_external_id;return{token:'session'+id,agent_assignment:{delivered:true,device_id:'device1'}};}if(url.startsWith('/api/hockey/tournaments/'))return{external_id:'t'};const match=/\/games\/([^/?]+)\/details\?/.exec(url);if(match)return details(decodeURIComponent(match[1]));const simple=/\/games\/([^/?]+)\?/.exec(url);if(simple)return compact(decodeURIComponent(simple[1]));throw Error('Unexpected production request '+url);}};
|
|
install(context,'app',['mergeExternalData']);install(context,'menu',menuNames);
|
|
return{c:context,state,requests,events,patches,notices,timers,values,data:()=>copy(runtimeData)};
|
|
}
|
|
const appNames=['getByPath','noteHockeySelectionContext','mergeRuntimeProviderData','loadData','mergeExternalData','applyExternalDataPatch'];
|
|
function appHarness({mode='runtime',source='hockey_stat2tv',onApi}={}){
|
|
const renders=[],toasts=[],events=[];const state={data:{},config:{data_source:source},dataPaths:[]};
|
|
const context={state,boot:{mode},api:url=>onApi(url),clone:copy,hockeySettingsDataRefreshPaused:()=>false,renderRuntime:(...args)=>renders.push(args),toast:(...args)=>toasts.push(args),el:{},flattenPaths:()=>[],renderDataPaths:()=>{},renderCanvas:()=>{},scheduleExternalRuntimeRender:()=>{},window:{dispatchEvent:event=>events.push(event)},CustomEvent:class{constructor(type,options){this.type=type;this.detail=options?.detail;}}};
|
|
install(context,'app',appNames);return{c:context,state,renders,toasts,events};
|
|
}
|
|
function control(id='A',home=1,away=1,{manual=false,revision=7,timers={game:{remaining_ms:120000,running:false}}}={}){return{game_id:id,revision,current_period:'1',timers,score:{home,away,external_home:5,external_away:4,manual,source:manual?'manual':'external',differs:manual&&(home!==5||away!==4)},flags:{},strength:{}};}
|
|
const scoreNames=['applyExternalDataPatch','hockeyControlReadContext','hockeyControlReadContextCurrent','noteHockeySelectionContext','getByPath','mergeExternalData','hockeyScoreControl','hockeyScoreDockMarkup','escapeHtml','hockeyApplyScoreToRuntime','hockeyRefreshScoreMapping','hockeyStoreGameControl','hockeyLoadGameControl','hockeyTimerSelectedGameId','hockeyGameControlLanguage','hockeyStrengthMappingSignature','hockeyTeamStateFlagSignature','applyHockeyControlSnapshot'];
|
|
function scoreHarness({onRequest=async()=>control(),onFetch=async()=>({ok:true,json:async()=>({ok:true,applied:1,errors:[],missing:[]})})}={}){
|
|
const initial=control(),events=[],renders=[],savedTimers=[],patches=[],listeners={},activations=[],accounts=new Map([['hockey.accountId','account1']]);
|
|
const state={data:{hockey:{operator_session:{token:'sessionA'},selected_game:full('A',5),game_control:copy(initial),home:{score:1},away:{score:1}}},hockeyGameControl:{A:copy(initial)},hockeyGameControlLoading:new Set(),hockeyTimerGameId:'A',hockeyTimerSelectionGeneration:1,hockeyControlSelectionGeneration:1,hockeyTimerRevision:42,hockeyTimerDirty:true,activeHockeyVmixTimerSteps:new Set(),activeTab:'main'};
|
|
const context={state,boot:{mode:'runtime'},hockeySettingsDataRefreshPaused:()=>false,scheduleExternalRuntimeRender:()=>{},window:{HockeyMatchAccess:{storage:{getItem:key=>accounts.get(key)||''}},addEventListener:(name,callback)=>{listeners[name]=callback;},dispatchEvent:event=>events.push(event),UIBuilderRuntime:{patchData:(patch,options)=>{patches.push(copy(patch));return context.applyExternalDataPatch(patch,options);}}},CustomEvent:class{constructor(type,options){this.type=type;this.detail=options?.detail;}},
|
|
hockeyGameControlRequest:(url,options)=>onRequest(url,options),fetch:(...args)=>onFetch(...args),currentRuntimeVmixDeviceId:()=> 'device1',currentRuntimeHockeySessionToken:()=> 'sessionA',
|
|
hockeyApplyTimerRules:()=>{},hockeyBackupPlayerSelectionValues:()=>{},hockeyReadyPenaltyCounts:()=>({home:0,away:0}),hockeyHasPendingPenaltyInteractions:()=>false,hockeyTeamStateScoreboardIsLive:()=>false,hockeySyncScoreboardConditionalSteps:async()=>{},hockeyRefreshVmixMappingForStrength:async()=>{},hockeySyncTeamStateOverlays:async()=>{},
|
|
hockeyActivateGameTimers:id=>{activations.push(id);state.hockeyTimerGameId=id;},hockeyApplySavedTimers:(...args)=>savedTimers.push(copy(args)),hockeyLoadCustomPlusesWorkspace:async()=>{},renderRuntime:()=>renders.push(context.hockeyScoreDockMarkup()),renderHockeyQuickCommandDock:()=>renders.push(context.hockeyScoreDockMarkup()),console:{error(){},warn(){}},clone:copy,Map,Set,Date,JSON};
|
|
install(context,'app',scoreNames);const start=sources.app.indexOf(' window.addEventListener(\"hockey:game-selected\",');assert.ok(start>=0);const end=sources.app.indexOf('\n });',start);assert.ok(end>start);vm.runInContext(sources.app.slice(start,end+8),context,{filename:'actual-game-selected-listener.js'});return{c:context,state,events,renders,savedTimers,patches,listeners,activations,accounts};
|
|
}
|
|
const cases={};
|
|
cases.poll_scope_and_owner=async()=>{
|
|
const oldA=defer(),newB=defer();const h=menuHarness({finished:false,onRequest:url=>url.includes('/games/A/live')?oldA.promise:url.includes('/games/B/live')?newB.promise:undefined});
|
|
const first=h.c.pollSelectedGame();assert.equal(h.state.gamePollBusy,true);
|
|
h.c.stopSelectedGamePolling();h.state.gameOpenGeneration++;h.state.selectedGameId='B';h.state.selectedGame=full('B',2,{finished:false});h.values.set('session','sessionB');
|
|
const current=h.c.pollSelectedGame();assert.equal(h.state.gamePollBusy,true);
|
|
const eventCount=h.events.length;oldA.resolve({changed:true,game:full('A',9),details:details('A',9).details});await first;
|
|
assert.equal(h.state.selectedGameId,'B');assert.equal(h.state.selectedGame.external_id,'B','Old live A replaced selected B');assert.equal(h.state.gamePollBusy,true,'Old request finally released the newer B request ownership');assert.equal(h.events.length,eventCount,'Old A emitted a selected-game event with B session');
|
|
newB.resolve({changed:true,game:full('B',3,{finished:false}),details:details('B',3).details});await current;assert.equal(h.state.selectedGame.home.players[0].id,'B-home-v3');assert.equal(h.state.gamePollBusy,false);
|
|
};
|
|
cases.poll_ABA_generation=async()=>{
|
|
const old=defer();const h=menuHarness({finished:false,onRequest:url=>url.includes('/live')?old.promise:undefined});const work=h.c.pollSelectedGame();
|
|
h.c.stopSelectedGamePolling();h.state.gameOpenGeneration++;h.state.selectedGameId='B';h.state.selectedGame=full('B');h.state.gameOpenGeneration++;h.state.selectedGameId='A';h.state.selectedGame=full('A',3,{finished:false});
|
|
old.resolve({changed:true,game:full('A',1),details:details('A',1).details});await work;
|
|
assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v3','A→B→A reused the old A live response');assert.equal(h.events.length,0);
|
|
};
|
|
cases.details_and_enrich_scope=async()=>{
|
|
const local=defer(),remote=defer();const h=menuHarness({onRequest:url=>url.includes('/details/sync')?remote.promise:url.includes('/details?')?local.promise:undefined});
|
|
const read=h.c.reloadSelectedGameDetails(),enrich=h.c.enrichOpenedGame('A','t',h.state.gameOpenGeneration,{opening:true});
|
|
h.state.gameOpenGeneration++;h.state.selectedGameId='B';h.state.selectedGame=full('B',4);h.state.selectedMatchDetails=details('B',4).details;h.values.set('session','sessionB');
|
|
local.resolve(details('A',8));remote.resolve(details('A',9));await Promise.all([read,enrich]);
|
|
assert.equal(h.state.selectedGame.external_id,'B');assert.equal(h.state.selectedGame.home.players[0].id,'B-home-v4');assert.equal(h.events.length,0,'Late details/enrichment emitted another match');
|
|
};
|
|
cases.cache_vs_enrichment=async()=>{
|
|
const cache=defer(),fresh=defer();const h=menuHarness({onRequest:url=>url.includes('/details/sync')?fresh.promise:url.includes('/details?')?cache.promise:undefined});
|
|
const cached=h.c.reloadSelectedGameDetails();const enriched=h.c.enrichOpenedGame('A','t',h.state.gameOpenGeneration,{opening:true});
|
|
fresh.resolve(details('A',3));await enriched;assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v3');
|
|
cache.resolve({game:{...compact('A'),home:{name:'A home',players:[]},away:{name:'A away',players:[]}},details:{loaded:false,synced_at:null}});await cached;
|
|
assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v3','Late empty cached snapshot erased fresh rich finished rosters');assert.equal(h.state.selectedMatchDetails.loaded,true);assert.equal(h.state.selectedGame.is_finished,true);
|
|
};
|
|
cases.snapshot_context_and_order=async()=>{
|
|
const h=menuHarness(),first=h.c.captureGameReadContext(),latest=h.c.captureGameReadContext();
|
|
assert.equal(h.c.applyLoadedGameSnapshot(details('A',3),latest),true);const count=h.events.length;
|
|
assert.equal(h.c.applyLoadedGameSnapshot(details('A',1),first),false,'Older loaded snapshot replaced a newer enrichment');assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v3');
|
|
const wrongBody=h.c.captureGameReadContext();assert.equal(h.c.applyLoadedGameSnapshot(details('B',9),wrongBody),false,'Wrong game body escaped the requested scope');
|
|
const oldLanguage=h.c.captureGameReadContext();h.state.language='en';assert.equal(h.c.applyLoadedGameSnapshot(details('A',8),oldLanguage),false,'Old language response replaced current selection');
|
|
const oldAccount=h.c.captureGameReadContext();h.values.set('account','account2');assert.equal(h.c.applyLoadedGameSnapshot(details('A',7),oldAccount),false,'Old account response replaced current selection');assert.equal(h.events.length,count);
|
|
};
|
|
cases.cache_context_coalescing=async()=>{
|
|
const first=defer(),english=defer(),h=menuHarness({onRequest:url=>url.includes('/details?')?(url.includes('language=en')?english.promise:first.promise):undefined});
|
|
const a=h.c.loadCachedGameSnapshot('A',1),duplicate=h.c.loadCachedGameSnapshot('A',1);assert.equal(a,duplicate,'Same context local cache reads were not coalesced');assert.equal(h.requests.length,1);
|
|
h.state.language='en';const changed=h.c.loadCachedGameSnapshot('A',1);assert.notEqual(changed,a,'Language change reused old cache request');assert.equal(h.requests.length,2);
|
|
first.resolve(details('A'));english.resolve(details('A',2));const[old,fresh]=await Promise.all([a,changed]);assert.equal(h.c.applyLoadedGameSnapshot(old.payload,old.ticket),false);assert.equal(h.c.applyLoadedGameSnapshot(fresh.payload,fresh.ticket),true);assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v2');
|
|
};
|
|
cases.enrichment_after_newer_live_score=async()=>{
|
|
const remote=defer(),live=defer(),h=menuHarness({finished:false,onRequest:url=>url.includes('/details/sync')?remote.promise:url.includes('/live')?live.promise:undefined});
|
|
const cachedTicket=h.c.captureGameReadContext();assert.equal(h.c.applyLoadedGameSnapshot(details('A',1,{finished:false}),cachedTicket),true);
|
|
const enriched=h.c.enrichOpenedGame('A','t',h.state.gameOpenGeneration,{opening:true});const polling=h.c.pollSelectedGame();
|
|
const newest=compact('A');newest.home.score=6;newest.away.score=5;live.resolve({changed:false,game:newest,details:{loaded:false,synced_at:null}});await polling;assert.equal(h.state.selectedGame.home.score,6);assert.equal(h.state.selectedGame.away.score,5);assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v1');
|
|
remote.resolve(details('A',3,{finished:false}));await enriched;assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v3','Newer-detail enrichment was discarded behind lightweight score polling');assert.equal(h.state.selectedGame.away.players[0].id,'A-away-v3');assert.equal(h.state.selectedGame.home.score,6,'Earlier details rolled the fresh live score backwards');assert.equal(h.state.selectedGame.away.score,5);assert.equal(h.state.selectedGame.is_finished,true,'Earlier details rolled a completed match back to live');assert.equal(h.state.selectedMatchDetails.loaded,true);assert.equal(h.timers.size,0,'Finished live result left an endless poll');
|
|
};
|
|
cases.restore_actual_tournament=async()=>{
|
|
const h=menuHarness({onRequest:url=>{if(url.includes('/games/A/details?')){const loaded=details('A',2);loaded.game.tournament_external_id='T2';return loaded;}const match=/\/tournaments\/([^?]+)/.exec(url);if(match)return{external_id:match[1],name:match[1]};}});h.state.selectedId='T1';h.state.selectedGame=null;h.state.selectedMatchDetails=null;
|
|
await h.c.restoreSelection();await tick();assert.equal(h.state.selectedId,'T2','DB match tournament was ignored in favor of stale saved tournament');assert.equal(h.values.get('tournament'),'T2');assert.equal(h.state.selectedTournament.external_id,'T2');assert.equal(h.state.selectedGame.external_id,'A');assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v2');assert.equal(h.state.selectedMatchDetails.loaded,true);assert.ok(!h.requests.some(row=>row.url.includes('/details/sync')));assert.equal(h.timers.size,0);
|
|
};
|
|
cases.late_loaded_cache_after_enrichment=async()=>{
|
|
const remote=defer(),cache=defer(),h=menuHarness({onRequest:url=>url.includes('/details/sync')?remote.promise:url.includes('/details?')?cache.promise:undefined});
|
|
const enriched=h.c.enrichOpenedGame('A','t',1,{opening:true}),cached=h.c.reloadSelectedGameDetails();remote.resolve(details('A',3));await enriched;assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v3');const events=h.events.length;
|
|
cache.resolve(details('A',1));await cached;assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v3','Older loaded DB snapshot after enrichment erased fresh roster');assert.equal(h.state.selectedGame.home.score,3,'Older loaded DB snapshot rolled the fresh score backwards');assert.equal(h.state.selectedMatchDetails.synced_at,details('A',3).details.synced_at);assert.equal(h.events.length,events,'Older database result emitted a selected-game update');
|
|
};
|
|
cases.finished_cached_restore=async()=>{
|
|
const h=menuHarness();h.state.selectedGame=null;h.state.selectedMatchDetails=null;
|
|
await h.c.restoreSelection();await tick();
|
|
assert.equal(h.state.selectedGameId,'A');assert.equal(h.state.selectedGame.home.players[0].id,'A-home-v1','Finished restore did not use local DB roster snapshot');assert.equal(h.state.selectedMatchDetails.loaded,true);
|
|
assert.ok(h.requests.some(row=>/\/games\/A\/details\?/.test(row.url)),'No local selected-match details GET');assert.ok(!h.requests.some(row=>row.url.includes('/details/sync')),'Finished cached match triggered remote enrichment');assert.equal(h.timers.size,0,'Finished match kept a live poll timer');
|
|
};
|
|
cases.cached_open_ABA=async()=>{
|
|
const h=menuHarness();h.state.selectedGameId='';h.state.selectedGame=null;h.state.selectedMatchDetails=null;
|
|
for(const id of ['A','B','A']){
|
|
await h.c.openGame(id);await tick();assert.equal(h.state.selectedGameId,id);assert.equal(h.state.selectedGame.home.players[0].id,id+'-home-v1');assert.equal(h.data().hockey.selected_game.external_id,id);assert.equal(h.data().hockey.home.players[0].id,id+'-home-v1');assert.equal(h.data().hockey.operator_session.token,'session'+id);
|
|
}
|
|
assert.ok(!h.requests.some(row=>row.url.includes('/details/sync')),'Known finished cached matches performed upstream enrichment');assert.equal(h.timers.size,0);
|
|
};
|
|
cases.provider_runtime_overlay=async()=>{
|
|
const delayed=defer(),h=appHarness({onApi:()=>delayed.promise});const load=h.c.loadData();const roster=full('B',3);
|
|
h.c.applyExternalDataPatch({hockey:{language:{display:'en',fallback:'en',available:['en']},stat_labels:{old_label:'old'},selected_game:roster,home:roster.home,away:roster.away,referees:roster.referees,match_details:details('B',3).details,operator_session:{token:'sessionB'},game_control:{game_id:'B',score:{home:0,away:0,manual:true}}}});
|
|
delayed.resolve({provider_revision:2,hockey:{api:{configured:true,last_sync:'fresh-base'},language:{display:'ru',fallback:'ru',available:['ru','en']},stat_labels:{new_label:'fresh'},selected_game:null,home:{name:'',players:[]},away:{name:'',players:[]},referees:[],match_details:null,operator_session:null,game_control:null}});await load;
|
|
assert.equal(h.state.data.provider_revision,2,'Fix skipped every provider update');assert.equal(h.state.data.hockey.api.last_sync,'fresh-base');assert.ok(h.state.data.hockey.selected_game,'Generic provider erased the active selected match');assert.equal(h.state.data.hockey.language.display,'en');assert.equal(h.state.data.hockey.language.fallback,'ru');assert.deepEqual(copy(h.state.data.hockey.language.available),['ru','en']);assert.deepEqual(copy(h.state.data.hockey.stat_labels),{new_label:'fresh'});assert.equal(h.state.data.hockey.selected_game.external_id,'B');assert.equal(h.state.data.hockey.home.players[0].id,'B-home-v3');assert.equal(h.state.data.hockey.operator_session.token,'sessionB');assert.equal(h.state.data.hockey.game_control.score.manual,true);assert.equal(h.state.data.hockey.game_control.score.home,0);
|
|
const failed=defer();h.c.api=()=>failed.promise;const retry=h.c.loadData();failed.reject(new Error('offline'));await retry;assert.equal(h.state.data.hockey.selected_game.external_id,'B','Failed generic refresh erased the selected match');assert.equal(h.state.data.hockey.home.players[0].id,'B-home-v3');assert.ok(h.toasts.some(([message])=>message.includes('offline')));
|
|
};
|
|
cases.provider_other_sources_and_editor=async()=>{
|
|
for(const[mode,source]of [['editor','hockey_stat2tv'],['runtime','vmix_demo']]){
|
|
const data={revision:2,hockey:{selected_game:null,home:{name:'provider',players:[]}}};const h=appHarness({mode,source,onApi:async()=>copy(data)});h.state.data={revision:1,hockey:{selected_game:full('A'),home:full('A').home}};await h.c.loadData();assert.deepEqual(copy(h.state.data),data,mode+'/'+source+' was incorrectly forced to preserve runtime hockey selection');
|
|
}
|
|
const old=defer(),h=appHarness({onApi:url=>url.endsWith('hockey_stat2tv')?old.promise:Promise.resolve({source:'vmix_demo',demo_revision:2})});const first=h.c.loadData();h.state.config.data_source='vmix_demo';await h.c.loadData();old.resolve({source:'old_hockey'});await first;assert.equal(h.state.data.source,'vmix_demo','Late old provider response replaced the newly selected data source');
|
|
};
|
|
cases.score_authority_and_foreign_context=async()=>{
|
|
const h=scoreHarness();
|
|
for(const[home,away]of [[2,3],[0,0]]){h.state.data.hockey.game_control=control('A',home,away,{manual:true});const value=h.c.hockeyScoreControl();assert.equal(value.home,home);assert.equal(value.away,away);assert.equal(value.manual,true);}
|
|
h.state.data.hockey.game_control=control('A',0,0);let value=h.c.hockeyScoreControl();assert.equal(value.home,0,'Valid SQL zero was replaced with prior score');assert.equal(value.away,0);
|
|
h.state.data.hockey.game_control=control('B',9,8,{manual:true});value=h.c.hockeyScoreControl();assert.equal(value.home,5,'Foreign B control painted selected A');assert.equal(value.away,0);assert.equal(value.manual,false);
|
|
const prior=copy(h.state.hockeyGameControl);h.c.hockeyStoreGameControl('A',control('B',9,8));assert.deepEqual(copy(h.state.hockeyGameControl),prior,'Wrong-body game was stored');assert.equal(h.patches.length,0);
|
|
h.state.data.hockey.selected_game=full('B',4);h.state.hockeyTimerGameId='B';h.c.hockeyStoreGameControl('A',control('A',9,8));assert.deepEqual(copy(h.state.hockeyGameControl),prior,'Late A mutated cache/runtime after selected B');assert.equal(h.patches.length,0);
|
|
};
|
|
cases.score_snapshot_same_revision=async()=>{
|
|
const h=scoreHarness(),beforeTimerRevision=h.state.hockeyTimerRevision;const snapshot=control('A',5,4);h.c.applyHockeyControlSnapshot('A',snapshot);await tick();
|
|
assert.equal(h.state.data.hockey.home.score,5);assert.equal(h.state.data.hockey.away.score,4);assert.equal(h.state.hockeyGameControl.A.revision,7);
|
|
assert.ok(h.renders.length>0,'Same revision new DB score did not repaint dock');assert.match(h.renders.at(-1),/<strong>5<\/strong>/);assert.match(h.renders.at(-1),/<strong>4<\/strong>/);
|
|
assert.equal(h.savedTimers.length,0,'A score-only snapshot reset saved timer state');assert.equal(h.state.hockeyTimerRevision,beforeTimerRevision,'Score-only snapshot advanced timer hydration revision');assert.equal(h.state.hockeyTimerDirty,true,'Score-only snapshot discarded unsaved running timer changes');
|
|
const count=h.renders.length;h.c.applyHockeyControlSnapshot('A',copy(snapshot));assert.equal(h.renders.length,count,'Identical snapshot needlessly repainted');
|
|
h.c.applyHockeyControlSnapshot('A',control('A',5,4,{timers:{game:{remaining_ms:90000,running:false}}}));assert.equal(h.savedTimers.length,1,'A real timer snapshot change was never applied');assert.equal(h.state.hockeyTimerRevision,beforeTimerRevision+1);
|
|
const stored=copy(h.state.hockeyGameControl);h.c.applyHockeyControlSnapshot('A',control('B',9,9));assert.deepEqual(copy(h.state.hockeyGameControl),stored);
|
|
};
|
|
cases.score_load_scope_and_ABA=async()=>{
|
|
const old=defer(),fresh=defer();let requests=0;const h=scoreHarness({onRequest:()=>++requests===1?old.promise:fresh.promise});
|
|
const first=h.c.hockeyLoadGameControl('A',{force:true}),same=h.c.hockeyLoadGameControl('A',{force:true});assert.equal(requests,1,'Concurrent same-context control reads were duplicated');
|
|
h.c.applyExternalDataPatch({hockey:{selected_game:full('B',4)}},{render:false});h.state.hockeyTimerGameId='B';
|
|
h.c.applyExternalDataPatch({hockey:{selected_game:full('A',5)}},{render:false});h.state.hockeyTimerGameId='A';const current=h.c.hockeyLoadGameControl('A',{force:true});assert.equal(requests,2,'A→B→A reused a previous-generation control load');
|
|
old.resolve(control('A',9,9));assert.equal(await first,null);assert.equal(await same,null);assert.equal(h.state.hockeyGameControl.A.score.home,1,'Late A generation painted new A context');assert.equal(h.state.hockeyGameControlLoading.has('A'),true,'Old A finally released new A ownership');
|
|
fresh.resolve(control('A',5,4));await current;assert.equal(h.state.hockeyGameControl.A.score.home,5);assert.equal(h.state.data.hockey.home.score,5);assert.equal(h.state.hockeyGameControlLoading.has('A'),false);
|
|
const wrong=scoreHarness({onRequest:async()=>control('B',8,8)});assert.equal(await wrong.c.hockeyLoadGameControl('A',{force:true}),null);assert.equal(wrong.state.hockeyGameControl.A.score.home,1);assert.equal(wrong.patches.length,0);
|
|
for(const changed of ['account','session']){const delayed=defer(),scope=scoreHarness({onRequest:()=>delayed.promise}),request=scope.c.hockeyLoadGameControl('A',{force:true});if(changed==='account')scope.accounts.set('hockey.accountId','account2');else scope.c.applyExternalDataPatch({hockey:{operator_session:{token:'new-session'}}},{render:false});delayed.resolve(control('A',9,9));assert.equal(await request,null,'Old '+changed+' control read escaped scope');assert.equal(scope.state.hockeyGameControl.A.score.home,1);}
|
|
};
|
|
cases.score_selected_event=async()=>{
|
|
let requests=0;const h=scoreHarness({onRequest:async()=>{requests++;return control('A',5,4);}});
|
|
h.listeners['hockey:game-selected']({detail:{game:full('A',5)}});await tick();assert.equal(requests,1,'Same-game selection did not fetch fresh DB control');assert.equal(h.state.data.hockey.home.score,5);assert.equal(h.state.data.hockey.away.score,4);assert.equal(h.savedTimers.length,0);assert.deepEqual(h.activations,[],'Same-game score refresh switched/reset game timers');assert.ok(h.renders.length>0);
|
|
h.c.applyExternalDataPatch({hockey:{selected_game:full('B',4)}},{render:false});h.listeners['hockey:game-selected']({detail:{game:full('B',4)}});assert.deepEqual(h.activations,['B'],'Changing the real selected match did not activate its own timers');
|
|
h.listeners['hockey:game-selected']({detail:{game:full('A',9)}});assert.deepEqual(h.activations,['B'],'Late old event switched the selected match timer');
|
|
const pending=defer(),manual=scoreHarness({onRequest:()=>pending.promise});manual.state.hockeyGameControl.A=control('A',0,0,{manual:true});manual.state.data.hockey.game_control=control('A',0,0,{manual:true});manual.state.data.hockey.home.score=5;manual.state.data.hockey.away.score=4;manual.listeners['hockey:game-selected']({detail:{game:full('A',5)}});assert.equal(manual.state.data.hockey.home.score,0,'Selected-game refresh rolled manual 0:0 back to DB5:4');assert.equal(manual.state.data.hockey.away.score,0);pending.resolve(control('A',0,0,{manual:true}));await tick();assert.equal(manual.state.data.hockey.home.score,0);assert.equal(manual.savedTimers.length,0);
|
|
};
|
|
cases.score_snapshot_fences_pending_read=async()=>{
|
|
const old=defer(),fresh=defer();let requests=0;const h=scoreHarness({onRequest:()=>++requests===1?old.promise:fresh.promise});const first=h.c.hockeyLoadGameControl('A',{force:true});assert.equal(requests,1);
|
|
assert.equal(h.c.applyHockeyControlSnapshot('A',control('A',5,4)),true);const current=h.c.hockeyLoadGameControl('A',{force:true});assert.equal(requests,2,'Fresh read after authoritative snapshot rejoined an older pending request');
|
|
old.resolve(control('A',1,1));assert.equal(await first,null,'Pending old control response remained authoritative after newer snapshot');assert.equal(h.state.hockeyGameControl.A.score.home,5);assert.equal(h.state.data.hockey.home.score,5);assert.equal(h.state.data.hockey.away.score,4);assert.equal(h.state.hockeyGameControlLoading.has('A'),true,'Old request cleared newer snapshot-context loading ownership');assert.equal(h.savedTimers.length,0);
|
|
fresh.resolve(control('A',6,5));await current;assert.equal(h.state.data.hockey.home.score,6);assert.equal(h.state.data.hockey.away.score,5);assert.equal(h.state.hockeyGameControlLoading.has('A'),false);assert.equal(h.savedTimers.length,0);
|
|
};
|
|
cases.score_mapping_result=async()=>{
|
|
for(const payload of [{ok:false,errors:[{reason:'vmix_http_error'}]},{ok:true,errors:[{reason:'failed'}]},{ok:true,missing:[{target:'score.home'}]}]){const h=scoreHarness({onFetch:async()=>({ok:true,json:async()=>payload})});assert.equal(await h.c.hockeyRefreshScoreMapping(),false,'HTTP200 partial Mapping was reported as complete');assert.equal(h.state.data.hockey.home.score,1,'Agent failure changed independent DB score');}
|
|
const good=scoreHarness();assert.equal(await good.c.hockeyRefreshScoreMapping(),true);
|
|
};
|
|
(async()=>{const name=process.argv[2];assert.ok(cases[name],'Unknown case '+name);await cases[name]();console.log('PASS match selection frontend: '+name);})().catch(error=>{console.error(error);process.exitCode=1;});
|