/* Execute the production coordinator and extracted UI helpers; no server/vMix. */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const root = path.join(__dirname, '../hockey_data/static'); const sources = Object.fromEntries(['mapping-requests', 'tournament-menu', 'agent-devices'].map(name => [name, fs.readFileSync(path.join(root, name + '.js'), 'utf8')])); const plain = value => JSON.parse(JSON.stringify(value)); const tick = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; const response = (payload, status = 200) => ({ok: status >= 200 && status < 300, status, json: async () => payload}); function deferred() { 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); assert.ok(match, 'Missing production helper: ' + name); const start = match.index + match[1].length; const end = source.indexOf('\n' + match[2] + '}', start); assert.ok(end > start, 'Unclosed production helper: ' + name); return source.slice(start, end + match[2].length + 2); } function harness(shared = true) { const requests = [], timers = new Map(), notices = [], events = [], redirects = []; let timerId = 0, now = 0; const values = new Map(Object.entries({'hockey.accountId':'user1', 'hockey.selectedGameId':'g1', 'hockey.operatorSessionToken':'s1', 'hockey.vmix.selected_device':'dev1', 'hockey.language':'ru'})); const runtimeStorage = {getItem: k => values.get(k) || null, setItem: (k,v) => values.set(k,String(v)), removeItem: k => values.delete(k)}; const window = {location:{assign: value => redirects.push(value)},dispatchEvent: event => events.push(event)}; const common = {window, runtimeStorage, localStorage:runtimeStorage, AbortController, DOMException, console:{warn(){}}, setTimeout:(fn,ms)=>{const id=++timerId;timers.set(id,{at:now+ms,fn});return id;},clearTimeout:id=>timers.delete(id), CustomEvent: class {constructor(type,options){this.type=type;this.detail=options?.detail;}}, fetch:(url,options={})=>{const pending=deferred();const item={url,options,...pending};requests.push(item); options.signal?.addEventListener('abort',()=>pending.reject(new DOMException('aborted','AbortError')),{once:true}); return pending.promise;}}; if (shared) vm.runInNewContext(sources['mapping-requests'], common); const storage = {account:'hockey.accountId',game:'hockey.selectedGameId',session:'hockey.operatorSessionToken',vmixDevice:'hockey.vmix.selected_device',language:'hockey.language'}; const state = {selectedGameId:'g1',selectedId:'t1',language:'ru',languageChangeGeneration:0,languagePreferencePromise:null,gameOpenGeneration:1,view:'tournaments'}; let renders = 0, reloads = 0; const menu = {...common,storage,state,sessionAssignmentRequests:new WeakMap(),notify:(...args)=>notices.push(args), renderStaticLabels:()=>renders++,loadNavigation:()=>{reloads++;return Promise.resolve();},loadGames:()=>{},loadTeamSchedule:()=>{}, reloadSelectedGameDetails:()=>{},loadTournamentStandings:()=>{},loadTournamentStatistics:()=>{}}; vm.createContext(menu); const menuNames=['currentAgentDeviceId','currentMappingContext','requestAgentMapping','mappingFailureMessage','saveLanguagePreference','changeRuntimeLanguage', 'rememberAssignedAgent','ensureSessionAgentAssignment','resolveSessionAgentAssignment','refreshSessionAgentMapping','request']; vm.runInContext(menuNames.map(name=>definition(sources['tournament-menu'],name)).join('\n'),menu); const agentState={selectedDeviceId:'dev1',selectionGeneration:0,deviceRevision:0,mappingPending:new Map()}; const runtime = {hockey:{selected_game:{external_id:'g1'},operator_session:{token:'s1'}}}; window.UIBuilderRuntime={getData:()=>runtime}; const messages=[]; const agent = {...common,state:agentState,selectedDeviceStorage:storage.vmixDevice,render:()=>{},load:async()=>{},setMessage:(...args)=>messages.push(args)}; vm.createContext(agent); vm.runInContext(['api','runtimeData','currentGameId','currentSessionToken','currentMappingContext','selectLocalDevice','refreshMappingInBackground','setMessage','clearStaleMappingNotice', 'bindSelectedDevice','connectAgentToCurrentMatch'].map(name=>definition(sources['agent-devices'],name)).join('\n'),agent); const setMessage=agent.setMessage; agent.setMessage=(...args)=>{messages.push(args);return setMessage(...args);}; window.HockeyAgentRuntime={currentDeviceId:()=>agentState.selectedDeviceId,selectDevice:id=>agent.selectLocalDevice(id)}; return {window,requests,timers,notices,messages,events,redirects,menu,agent,state,agentState,runtime,runtimeStorage, renders:()=>renders,reloads:()=>reloads, advance:async ms=>{now+=ms;for(const[id,timer]of timers)if(timer.at<=now){timers.delete(id);timer.fn();}await tick();}, resolve:(index,payload={ok:true})=>requests[index].resolve(response(payload))}; } const cases = {}; cases.long_request = async () => { const h=harness(), apply=h.window.HockeyMappingRequests.apply; const a=apply({deviceId:'dev1',context:'g1:s1',language:'ru'}); await h.advance(200_000); assert.equal(h.requests.length,1); assert.equal(h.timers.size,0,'Mapping inherited a short browser timeout'); assert.equal(h.requests[0].options.signal,undefined,'Browser deadline can abort a still-running server push'); const b=apply({deviceId:'dev1',context:'g1:s1',language:'ru'}); const c=apply({deviceId:'dev1',context:'g1:s1',language:'ru'}); await tick();assert.equal(h.requests.length,1,'Duplicate full pushes while the first one is pending'); h.resolve(0,{ok:true,applied:372,total:372}); assert.deepEqual(plain(await Promise.all([a,b,c])),Array(3).fill({ok:true,applied:372,total:372})); assert.equal(h.requests[0].options.credentials,'same-origin'); }; cases.latest_context = async () => { const h=harness(), apply=h.window.HockeyMappingRequests.apply; let current='a'; const start=(context,language='ru')=>apply({deviceId:'dev1',context,language,isCurrent:()=>current===context}); const a=start('a');current='b';const b=start('b','en');current='c';const c=start('c'); assert.equal((await b).superseded,true,'Replaced queued context must settle'); const other=apply({deviceId:'dev2',context:'other'}); assert.equal(h.requests.length,2,'Unrelated matches/devices must run independently'); h.resolve(0);await tick(); assert.equal((await a).superseded,true,'Old match must not report success to the new workspace'); assert.equal(h.requests.length,3,'Latest context must wait for the old server push'); h.resolve(1);h.resolve(2);assert.equal((await c).ok,true);await other; assert.equal(h.requests.filter(r=>r.url.includes('/dev1/')).length,2,'Intermediate match sent a full Mapping'); const returned=harness(), send=returned.window.HockeyMappingRequests.apply; const first=send({deviceId:'dev1',context:'a'}); const middle=send({deviceId:'dev1',context:'b'}); const latest=send({deviceId:'dev1',context:'a'}); assert.equal((await middle).superseded,true,'A→B→A left the obsolete B request queued'); const duplicateLatest=send({deviceId:'dev1',context:'a'}); returned.resolve(0);assert.equal((await first).ok,true);await tick(); assert.equal(returned.requests.length,2,'Returning after an intervening intent must start a fresh resolver read'); returned.resolve(1);assert.equal((await latest).ok,true);assert.equal((await duplicateLatest).ok,true); assert.equal(returned.requests.length,2,'Duplicate latest A did not join the fresh queued A'); }; cases.delayed_language_capture = async () => { const h=harness(), send=h.window.HockeyMappingRequests.apply; let generation=1, serverLanguage='ru'; const apply=(language,owner)=>send({deviceId:'dev1',language,context:'user1:game1:session1',isCurrent:()=>generation===owner}); const firstA=apply('ru',1); assert.equal(h.requests.length,1); // The backend HTTP request is waiting for its device lock/resolver; Mapping // carries no language parameter and reads committed preferences when it runs. generation=2;serverLanguage='en';const middleB=apply('en',2); const oldResolverLanguage=serverLanguage; generation=3;serverLanguage='ru';const latestA=apply('ru',3); const duplicateLatestA=apply('ru',3); assert.equal((await middleB).superseded,true); assert.equal(h.requests.length,1,'Fresh latest intent must wait for the running push'); assert.equal(h.requests[0].options.body,undefined,'Fixture must exercise server-resolved language, not an explicit request value'); h.resolve(0,{ok:true,applied_language:oldResolverLanguage});await tick(); assert.equal((await firstA).superseded,true); assert.equal(h.requests.length,2,'Latest RU joined the old HTTP request that actually captured EN'); const freshResolverLanguage=serverLanguage; h.resolve(1,{ok:true,applied_language:freshResolverLanguage}); for(const result of await Promise.all([latestA,duplicateLatestA])) { assert.equal(result.ok,true);assert.equal(result.applied_language,'ru','Latest RU acknowledged obsolete EN data'); } assert.equal(h.requests.length,2,'Duplicate latest intents must share the fresh queued resolver read'); }; cases.preference_write_without_queued_B = async () => { const h=harness(), intents=[]; const productionApply=h.window.HockeyMappingRequests.apply; h.window.HockeyMappingRequests.apply=options=>{intents.push(options.language);return productionApply(options);}; let serverLanguage='ru'; const oldA=h.menu.requestAgentMapping(); const changeB=h.menu.changeRuntimeLanguage('en');await tick(); assert.equal(h.requests.length,2); assert.equal(JSON.parse(h.requests[1].options.body).vmix_language,'en'); // Latest A is clicked before B's preference response. Therefore B's real // saveLanguagePreference returns false and never queues a Mapping intent. const latestA=h.menu.changeRuntimeLanguage('ru'); serverLanguage='en';const oldResolverLanguage=serverLanguage; h.resolve(1);await tick();await changeB; assert.equal(h.requests.length,3); assert.equal(JSON.parse(h.requests[2].options.body).vmix_language,'ru'); serverLanguage='ru';h.resolve(2);await tick(); assert.deepEqual(intents,['ru','ru'],'B must never reach Mapping apply in this regression'); assert.equal(h.requests.length,3,'Fresh A must wait for the actual running server push'); const duplicateLatest=h.menu.requestAgentMapping(); h.resolve(0,{ok:true,applied_language:oldResolverLanguage});await tick(); assert.equal(h.requests.length,4,'Latest A reused a stale resolver snapshot after B committed without queued Mapping'); assert.equal((await oldA).superseded,true,'Explicit language write did not invalidate old Mapping consumers'); h.resolve(3,{ok:true,applied_language:serverLanguage}); await latestA;const result=await duplicateLatest; assert.equal(result.applied_language,'ru');assert.equal(h.notices.length,0); assert.equal(h.requests.length,4,'Normal duplicate A should join the fresh queued A'); }; cases.consumer_cancel = async () => { const h=harness(), apply=h.window.HockeyMappingRequests.apply, controller=new AbortController(); const a=apply({deviceId:'dev1',context:'g1',signal:controller.signal}).catch(error=>error); const b=apply({deviceId:'dev1',context:'g1'}); controller.abort();assert.equal((await a).name,'AbortError'); const c=apply({deviceId:'dev1',context:'g1'}); const queuedController=new AbortController(); const queued=apply({deviceId:'dev1',context:'g2',signal:queuedController.signal}).catch(error=>error); queuedController.abort();assert.equal((await queued).name,'AbortError'); assert.equal(h.requests.length,1,'Cancelling one consumer started a second network push'); h.resolve(0);assert.equal((await b).ok,true);assert.equal((await c).ok,true);await tick(); assert.equal(h.requests.length,1,'Cancelled queued work was still sent'); const alreadyAborted=new AbortController();alreadyAborted.abort(); await assert.rejects(apply({deviceId:'dev1',signal:alreadyAborted.signal}),{name:'AbortError'}); }; cases.honest_failures = async () => { const h=harness(), apply=h.window.HockeyMappingRequests.apply; const failed=apply({deviceId:'dev1'});h.requests[0].reject(new TypeError('Failed to fetch')); await assert.rejects(failed,error=>error.code==='mapping_outcome_unknown'&&error.message.includes('не подтверждён')); await h.advance(600_000);assert.equal(h.requests.length,1,'Unknown network outcome retried automatically'); const invalid=apply({deviceId:'dev1'});h.requests[1].resolve({ok:true,json:async()=>{throw new SyntaxError('truncated JSON');}}); await assert.rejects(invalid,error=>error.code==='mapping_outcome_unknown'); const empty=apply({deviceId:'dev1'});h.resolve(2,{});await assert.rejects(empty,error=>error.code==='mapping_outcome_unknown'); const partial=apply({deviceId:'dev1'});h.resolve(3,{ok:false,total:372,applied:369,errors:[{error:'bad SelectedName'}]}); assert.equal((await partial).ok,false,'HTTP200 partial failure became success'); const denied=apply({deviceId:'dev1'});h.requests[4].resolve(response({detail:'no access'},403));await assert.rejects(denied,/no access/); }; cases.ui_paths = async () => { const h=harness(); const initial=h.menu.refreshSessionAgentMapping({token:'s1',agent_assignment:{device_id:'dev1',delivered:true}}); await tick();assert.equal(h.requests.length,1); const background=h.agent.refreshMappingInBackground('dev1',{delivered:true}); await tick();assert.equal(h.requests.length,1,'Menu and Agent paths did not share a lane'); assert.equal(h.agentState.mappingPending.size,1); await h.advance(200_000);assert.equal(h.requests.length,1); h.resolve(0,{ok:false,total:372,applied:369,errors:[{error:'bad SelectedName'}]}); assert.equal((await initial).ok,false);await background; assert.equal(h.agentState.mappingPending.size,0); assert.ok(h.messages.some(([text,error,duration])=>error&&duration===0&&text.includes('369/372')&&text.includes('bad SelectedName')),'Partial Mapping failure hidden by background selection'); const stale=h.agent.refreshMappingInBackground('dev1',{delivered:true}); h.runtime.hockey.selected_game.external_id='g2';h.runtime.hockey.operator_session.token='s2'; h.resolve(1,{ok:false,reason:'old game error'});await stale; assert.equal(h.messages.length,1,'Stale background failure leaked into another match'); h.agent.clearStaleMappingNotice();assert.equal(h.agentState.message,'','Previous match Mapping notice survived a context switch'); }; cases.language_latest = async () => { const h=harness(); const first=h.menu.changeRuntimeLanguage('en');await tick(); assert.equal(h.requests.length,1);assert.equal(h.requests[0].url,'/api/hockey/preferences'); const second=h.menu.changeRuntimeLanguage('ru');const third=h.menu.changeRuntimeLanguage('en'); assert.equal(h.renders(),3);assert.equal(h.reloads(),3,'Language UI waited for Mapping before updating'); await tick();assert.equal(h.requests.length,1,'Concurrent preference PUTs can commit out of order'); h.resolve(0);await tick(); assert.equal(h.requests.length,2);assert.equal(h.requests[1].url,'/api/hockey/preferences'); assert.equal(JSON.parse(h.requests[1].options.body).vmix_language,'en','Obsolete intermediate RU was persisted'); h.resolve(1);await tick();assert.equal(h.requests.length,3);assert.ok(h.requests[2].url.endsWith('/dev1/apply-mapping')); const fourth=h.menu.changeRuntimeLanguage('ru');await tick(); assert.equal(h.requests.length,4,'Running Mapping blocked the next preference save'); h.resolve(3);await tick();assert.equal(h.requests.length,4,'Second full Mapping ran over the first'); h.resolve(2,{ok:false,reason:'obsolete EN error'});await tick();assert.equal(h.requests.length,5); assert.equal(h.notices.length,0,'Old language failure was reported as current'); h.resolve(4,{ok:false,total:10,applied:8,errors:[{error:'missing Field'}]}); await Promise.all([first,second,third,fourth]); assert.ok(h.notices.some(([message,error])=>error&&message.includes('8/10')&&message.includes('missing Field'))); }; cases.assignment_handoff = async () => { const h=harness(), session={token:'s1'}; const first=h.menu.ensureSessionAgentAssignment(session), second=h.menu.ensureSessionAgentAssignment(session); assert.equal(h.requests.length,1,'Assignment recovery duplicated the device-list request'); h.resolve(0,{devices:[{device_id:'dev1',paired_to_me:true,active_for_account:true,online:true}]});await tick(); assert.equal(h.requests.length,2,'Assignment recovery sent multiple select-session POSTs'); assert.equal(JSON.parse(h.requests[1].options.body).apply_mapping,false); h.resolve(1,{assignment:{device_id:'dev1',delivered:true}}); assert.equal((await first).delivered,true);assert.equal((await second).delivered,true); const oldSession={token:'s1'}, old=h.menu.ensureSessionAgentAssignment(oldSession);assert.equal(h.requests.length,3); h.state.gameOpenGeneration++;h.agent.selectLocalDevice('dev2'); h.resolve(2,{devices:[{device_id:'dev1',paired_to_me:true,active_for_account:true,online:true}]}); assert.equal(await old,null);assert.equal(h.requests.length,3,'Stale recovery re-bound the old match'); const latest=h.menu.ensureSessionAgentAssignment({token:'s2'}); h.resolve(3,{devices:[{device_id:'dev2',paired_to_me:true,active_for_account:true,online:true}]});await tick(); h.agent.selectLocalDevice('dev3');h.resolve(4,{assignment:{device_id:'dev2',delivered:true}}); assert.equal(await latest,null);assert.equal(h.agentState.selectedDeviceId,'dev3','Late assignment response replaced a newer device selection'); }; cases.standalone = async () => { const h=harness(false); const background=h.agent.refreshMappingInBackground('dev1',{delivered:true}); const initial=h.menu.requestAgentMapping({deviceId:'dev1'}); assert.equal(h.requests.length,2);assert.equal(h.timers.size,0,'Isolated legacy UI retained a short Mapping timeout'); await h.advance(200_000);assert.equal(h.requests.length,2); h.resolve(0,{ok:true});h.resolve(1,{ok:true});await background;assert.equal((await initial).ok,true); const generic=h.menu.request('/api/hockey/me');await h.advance(15_001);await assert.rejects(generic,/время ожидания/); const genericAgent=h.agent.api('/api/hockey/agents/devices');await h.advance(5_001);await assert.rejects(genericAgent,/превысил 5 с/); }; (async()=>{ const name=process.argv[2];assert.ok(cases[name],'Unknown scenario '+name);await cases[name](); console.log('PASS Mapping browser contract: '+name); })().catch(error=>{console.error(error);process.exitCode=1;});