248 lines
18 KiB
JavaScript
248 lines
18 KiB
JavaScript
/* 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;});
|