Files
hockey_new/tests/mapping_browser_requests.cjs

248 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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;});