Files
hockey_new/tests/broadcast_readiness_frontend.cjs

118 lines
11 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.

/* Real production readiness lifecycle/render helpers with controlled I/O edges. */
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),assert=require('node:assert/strict');
const source=fs.readFileSync(path.join(__dirname,'../hockey_data/static/agent-diagnostics.js'),'utf8');
const tick=async()=>{for(let i=0;i<35;i++)await Promise.resolve();};
const plain=value=>JSON.parse(JSON.stringify(value));
function definition(name){
const match=new RegExp('(^|\\n)([ \\t]*)(?:async )?function '+name+'\\(').exec(source);assert.ok(match,name);
const start=match.index+match[1].length,end=source.indexOf('\n'+match[2]+'}',start);assert.ok(end>start,name);
return source.slice(start,end+match[2].length+2);
}
function payload(device='devA',game='gameA'){
const labels={selection:'Выбранный контекст',assignment:'Назначение матча',agent:'Agent',vmix:'vMix',inventory:'Структура проекта',publication:'Mapping',targets:'Цели Mapping',delivery:'Обработка и ACK',pending:'Ожидающие команды'};
const details={agent:{heartbeat_age_seconds:1},inventory:{current_connection:true,scanned_at:'2026-10-21T12:00:00Z',input_count:10,field_count:50},publication:{version:3},targets:{target_count:20,matched_count:20,missing_count:0},delivery:{confirmed:20,full_profile:true,completed_at:'2026-10-21T12:00:00Z',errors_count:0},pending:{count:0,mapping_busy:false}};
return {ok:true,device_id:device,game_id:game,checked_at:'2026-10-21T12:00:00Z',status:'ready',checks:Object.entries(labels).map(([key,label])=>({key,label,status:'ready',message:'Подтверждено',details:details[key]||{}}))};
}
function harness(){
const values=new Map([['hockey.accountId','accountA'],['hockey.selectedGameId','oldGame'],['hockey.vmix.selected_device','oldDevice']]);
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,String(value))};
let game='gameA',device='devA',now=0,timerId=0;const timers=new Map(),requests=[];
const document={hidden:false};let renders=0;
const setTimer=(fn,ms)=>{const id=++timerId;timers.set(id,{at:now+ms,fn});return id;},clearTimer=id=>timers.delete(id);
const window={HockeyAgentRuntime:{currentDeviceId:()=>device},UIBuilderRuntime:{getData:()=>({hockey:{selected_game:game?{external_id:game}:null}})},setTimeout:setTimer,clearTimeout:clearTimer};
const context={window,document,runtimeStorage:storage,AbortController,URLSearchParams,Date,console,setTimeout:setTimer,clearTimeout:clearTimer,
render:()=>renders++,fetch:(url,options)=>new Promise((resolve,reject)=>{
const row={url,options,ignoreAbort:false,resolve:body=>resolve({ok:true,status:200,json:async()=>body}),fail:(status,detail)=>resolve({ok:false,status,json:async()=>({detail})})};requests.push(row);
options.signal?.addEventListener('abort',()=>{if(!row.ignoreAbort)reject(new DOMException('Aborted','AbortError'));},{once:true});
if(url==='/api/hockey/system/diagnostics')row.resolve({ok:true,database:{ok:true},devices:[]});
})};
vm.createContext(context);
const preamble=source.slice(source.indexOf(' const state ='),source.indexOf(' async function api('));
const names=['api','activeDeviceId','readinessSelection','invalidateReadiness','syncReadinessSelection','normalizedReadiness','readinessDetails','renderReadiness','loadReadiness','load','stopRequests','schedule','selectionChanged','formatClock','formatReadinessTime'];
vm.runInContext(preamble+'\n'+names.map(definition).join('\n'),context);
const state=vm.runInContext('state',context);
return {context,state,window,document,storage,timers,requests,renders:()=>renders,
readiness:()=>requests.filter(row=>row.url.startsWith('/api/hockey/system/readiness?')),
select:(nextGame,nextDevice)=>{game=nextGame;device=nextDevice;},
advance:async ms=>{now+=ms;for(const[id,timer]of [...timers])if(timer.at<=now){timers.delete(id);timer.fn();}await tick();}};
}
const cases={};
cases.closed_bootstrap=async()=>{
const h=harness();await h.context.loadReadiness();assert.equal(h.requests.length,0);
await h.context.load(true,{bootstrap:true});assert.equal(h.requests.length,1);assert.equal(h.readiness().length,0,'Bootstrap added a readiness request');
h.context.selectionChanged();h.context.schedule();await tick();assert.equal(h.requests.length,1);assert.equal(h.timers.size,0);
h.state.open=true;await tick();const work=h.context.loadReadiness();h.readiness()[0].resolve(payload());await work;
assert.equal(h.state.readinessPayload.status,'ready');
h.state.open=false;h.context.stopRequests();h.context.schedule();assert.equal(h.state.readinessPayload,null);
h.context.selectionChanged();await tick();assert.equal(h.readiness().length,1,'Closed panel refreshed readiness');
};
cases.coalesce_timeout=async()=>{
const h=harness();h.state.open=true;
const first=h.context.loadReadiness(),second=h.context.loadReadiness();assert.equal(h.readiness().length,1);
h.readiness()[0].resolve(payload());await Promise.all([first,second]);assert.equal(h.state.readinessPayload.status,'ready');
const slow=h.context.loadReadiness();await h.advance(10001);await slow;
assert.equal(h.state.readinessPayload,null,'Timeout left an old green payload');
assert.match(h.state.readinessError,/Время проверки истекло/);assert.equal(h.state.readinessLoading,false);assert.equal(h.timers.size,0);
assert.ok(!h.context.renderReadiness().includes('hockey-readiness-status ready'));
};
cases.aba_stale=async()=>{
const h=harness();h.state.open=true;
const oldA=h.context.loadReadiness();h.readiness()[0].ignoreAbort=true;
h.select('gameB','devB');h.context.selectionChanged();await tick();h.readiness()[1].ignoreAbort=true;
h.select('gameA','devA');h.context.selectionChanged();await tick();
assert.equal(h.readiness().length,3);assert.equal(h.state.readinessPayload,null);
h.readiness()[0].resolve(payload());await oldA;await tick();
assert.equal(h.state.readinessPayload,null,'Old A response survived A→B→A generation fence');assert.equal(h.state.readinessLoading,true);
h.readiness()[1].resolve(payload('devB','gameB'));await tick();assert.equal(h.state.readinessPayload,null);
h.readiness()[2].resolve(payload());await tick();assert.equal(h.state.readinessPayload.status,'ready');
h.select('gameA','');h.context.selectionChanged();assert.equal(h.context.activeDeviceId(),'','Explicit empty Agent fell back to stale storage');
assert.equal(h.state.readinessPayload,null);assert.equal(h.readiness().length,3);assert.match(h.context.renderReadiness(),/Agent: <b>не выбран/);
h.select('','devA');h.context.selectionChanged();assert.equal(h.context.readinessSelection().gameId,'','Explicit empty match fell back to storage');
assert.equal(h.readiness().length,3);
};
cases.account_hide_close=async()=>{
const h=harness();h.state.open=true;const old=h.context.loadReadiness();h.readiness()[0].ignoreAbort=true;
h.storage.setItem('hockey.accountId','accountB');h.context.selectionChanged();await tick();
assert.equal(h.state.readinessPayload,null);assert.equal(h.readiness().length,2);
h.readiness()[0].resolve(payload());await old;assert.equal(h.state.readinessPayload,null,'Previous-account payload rendered');
h.document.hidden=true;h.context.stopRequests();h.context.schedule();await tick();
assert.equal(h.state.readinessPayload,null);assert.equal(h.state.readinessLoading,false);assert.equal(h.timers.size,0);
const count=h.readiness().length;await h.context.loadReadiness();assert.equal(h.readiness().length,count,'Hidden panel requested readiness');
h.document.hidden=false;h.context.selectionChanged();await tick();assert.equal(h.readiness().length,count+1);
h.state.open=false;h.context.stopRequests();h.context.schedule();await tick();
assert.equal(h.timers.size,0);assert.equal(h.state.readinessPayload,null);
};
cases.errors_payload=async()=>{
const h=harness();h.state.open=true;
let work=h.context.loadReadiness();h.readiness()[0].fail(404,'foreign account details');await work;
assert.equal(h.state.readinessPayload,null);assert.match(h.state.readinessError,/недоступен для этого аккаунта/);assert.ok(!h.state.readinessError.includes('foreign account details'));
work=h.context.loadReadiness();h.readiness()[1].fail(503,'server unavailable');await work;assert.match(h.state.readinessError,/server unavailable/);
work=h.context.loadReadiness();h.readiness()[2].resolve(payload('anotherDevice'));await work;
assert.equal(h.state.readinessPayload,null);assert.match(h.state.readinessError,/не подтверждает выбранный матч/);
work=h.context.loadReadiness();const incomplete=payload();incomplete.checks.pop();h.readiness()[3].resolve(incomplete);await work;
assert.equal(h.state.readinessPayload,null);assert.match(h.state.readinessError,/Не все проверки/);
work=h.context.loadReadiness();const invalid=payload();invalid.ok='true';h.readiness()[4].resolve(invalid);await work;assert.equal(h.state.readinessPayload,null);
for(const[key,field,value]of [['delivery','confirmed',true],['delivery','full_profile','true'],['inventory','current_connection','true'],['pending','mapping_busy','false']]){
const input=payload();input.checks.find(check=>check.key===key).details[field]=value;
assert.equal(h.context.normalizedReadiness(input,h.context.readinessSelection()).status,'unknown','Truthy strings became green');
}
};
cases.render_readonly=async()=>{
const h=harness();h.state.open=true;const work=h.context.loadReadiness();const input=payload();
input.checks[0].message='<img src=x onerror=alert(1)>';input.checks[0].label='<script>unsafe</script>';
input.checks.find(check=>check.key==='delivery').status='error';input.status='ready';h.readiness()[0].resolve(input);await work;
assert.equal(h.state.readinessPayload.status,'error','Client promoted failed checks to green');
const html=h.context.renderReadiness();assert.ok(html.includes('&lt;img'));assert.ok(!html.includes('<img'));assert.ok(!html.includes('<script>'));
assert.match(html,/Актуальность данных внешних источников.*не подтверждается/);assert.match(html,/визуально.*Countdown/);
assert.match(html,/21\.10\.2026.*15:00:00.*МСК/,'Date/time must identify old observations in journal timezone');
const delivery=input.checks.find(check=>check.key==='delivery');delivery.details.full_profile=false;
assert.match(h.context.readinessDetails(delivery),/>Обработка и ACK:/);assert.ok(!h.context.readinessDetails(delivery).includes('Полная обработка'));
assert.ok(!html.includes('hockey-readiness-status ready'));
h.state.readinessLoading=true;const refreshing=h.context.renderReadiness();
assert.equal((refreshing.match(/data-readiness-check=/g)||[]).length,9,'Refreshing collapsed the readable previous snapshot');
assert.match(refreshing,/Показан предыдущий результат/);assert.match(refreshing,/is-refreshing/);assert.ok(!refreshing.includes('hockey-readiness-status ready'));
assert.ok(h.requests.every(row=>row.url.startsWith('/api/hockey/system/readiness?')||row.url==='/api/hockey/system/diagnostics'));
assert.ok(h.requests.every(row=>!row.options.method||row.options.method==='GET'),'Readiness sent a mutation');
};
(async()=>{const name=process.argv[2];assert.ok(cases[name],name);await cases[name]();console.log('PASS readiness frontend: '+name);})().catch(error=>{console.error(error);process.exitCode=1;});