Files
Endoleap/tests/headless/runtime-sources.test.mjs

69 lines
13 KiB
JavaScript
Raw Permalink 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.
/**
* 运行事实按资源/操作/作用域计数订阅的测试。用Tracked表记录get/values工作量,对照朴素过滤定义验证索引语义。
* 只读document更新不该重扫ledger/resource;请求结果、资源绑定与错误恢复则必须准确通知相关通道。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createRuntimeSources} from '../../src/lib/canvas-bindings/runtime-sources.ts';
/** Map探针:get记录点查并可注入异常,values记录全表扫描,reset仅清计数;数据仍按普通Map保存。 */
class Tracked extends Map{gets=0;scans=0;broken=false;get(id){this.gets++;if(this.broken)throw Error('table unavailable');return super.get(id);}values(){this.scans++;return super.values();}reset(){this.gets=0;this.scans=0;}}
/** 最小操作事实工厂,canvasIds/phase/blockers由用例指定,以覆盖多画布、仅项目及摘要等待情况。 */
const operation=(id,canvasIds=['a'],phase='pending',blockers=[])=>({intent:{id,canvasIds},phase,blockers});
/** 构造可替换root及带计数的ledger/resources/assetBindings;broken制造上游订阅失败,emit不额外合成变化清单。 */
function fixture(count=3){let root={document:{},runtime:{ledger:{operations:new Tracked(Array.from({length:count},(_,index)=>[String(index),operation(String(index))])),requests:new Tracked()},resources:new Tracked(Array.from({length:count},(_,index)=>[String(index),{id:String(index),name:'image',operationId:'upload-'+index}])),assetBindings:new Tracked()}},broken=false;const listeners=new Set();const source={getSnapshot:()=>root,subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get:()=>root,get size(){return listeners.size;},broken(value){broken=value;},emit(next){root=next;for(const fn of [...listeners])fn();}};}
// 2000通道共用上游,document-only不查三张表;改资源300仅通知asset300,不读取operations/requests。
test('2000 asset/operation subscribers share one listener and unrelated document cuts read no tables',()=>{
const f=fixture(1000),sources=createRuntimeSources(f.source),seen=[],stops=[];for(let index=0;index<1000;index++){const id=String(index);stops.push(sources.asset(id).subscribe(()=>seen.push('asset'+id)),sources.operation(id).subscribe(()=>seen.push('op'+id)));}assert.equal(f.size,1);const {resources,ledger}=f.get().runtime;resources.reset();ledger.operations.reset();ledger.requests.reset();f.emit({...f.get(),document:{edited:true}});assert.equal(resources.gets+ledger.operations.gets+ledger.requests.gets,0);assert.deepEqual(seen,[]);
const next=new Tracked(resources);next.set('300',{...resources.get('300'),name:'new image'});f.emit({...f.get(),runtime:{...f.get().runtime,resources:next}});assert.deepEqual(seen,['asset300']);assert.equal(ledger.operations.gets+ledger.requests.gets,0);for(const stop of stops)stop();assert.equal(f.size,0);
});
// 只改关联request结果/错误也应通知operation0,未关联operation1保持原对象引用。
test('request-only updates notify the linked operation while unrelated operation views stay stable',()=>{
const f=fixture(),ops=new Tracked(f.get().runtime.ledger.operations),requests=new Tracked([['request',{id:'request',phase:'waiting',outcome:'accepted'}]]);ops.set('0',{...ops.get('0'),requestId:'request'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests}}});const sources=createRuntimeSources(f.source),a=sources.operation('0'),b=sources.operation('1'),seen=[],before=b.getSnapshot(),stops=[a.subscribe(()=>seen.push('0')),b.subscribe(()=>seen.push('1'))];const changed=new Tracked(requests);changed.set('request',{id:'request',phase:'waiting',outcome:'unresolved',error:'transport'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests:changed}}});assert.deepEqual(seen,['0']);assert.equal(a.getSnapshot().request.error,'transport');assert.strictEqual(b.getSnapshot(),before);for(const stop of stops)stop();
});
// 较早监听先调用getSnapshot不能吞掉后续通知;新增shared绑定后订阅者仍收到一次更新。
test('early snapshot reads cannot consume a binding notification before other observers receive it',()=>{
const f=fixture();let channel;const earlier=f.source.subscribe(()=>channel?.getSnapshot()),sources=createRuntimeSources(f.source);channel=sources.asset('shared');let seen=0;const stop=channel.subscribe(()=>seen++),binding={authority:'scope',digest:'a'.repeat(64),asset:{id:'shared'}};f.emit({...f.get(),runtime:{...f.get().runtime,assetBindings:new Tracked([['shared',binding]])}});assert.equal(seen,1);assert.equal(channel.getSnapshot().binding.digest,binding.digest);stop();earlier();assert.equal(f.size,0);
});
// 多canvas请求按作用域只数一次,waiting_digest不当普通blocked;1000个空scope只需一次表扫描,loading改变正确重算。
test('sync counts index shared multi-canvas requests once and handles project-only operations',()=>{
const f=fixture(0),ops=new Tracked([['one',operation('one',['a','b','a'])],['two',operation('two',['b'],'blocked',['permission'])],['digest',operation('digest',['a'],'blocked',['waiting_digest'])],['done',operation('done',['a'],'resolved')],['project',operation('project',[])]]),requests=new Tracked([['r1',{phase:'waiting',outcome:'unresolved',error:'transport',operationIds:['one','two']}],['r2',{phase:'waiting',outcome:'unresolved',error:'old',operationIds:['done']}],['r3',{phase:'waiting',outcome:'unresolved',operationIds:['project']}],['r4',{phase:'waiting',outcome:'unresolved',error:'retry',retryAt:100,operationIds:['digest']}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests},loading:{version:6}}});const sources=createRuntimeSources(f.source);
assert.deepEqual(sources.counts().getSnapshot(),{pending:4,blocked:2,unknown:3,loading:true});assert.deepEqual(sources.counts('a').getSnapshot(),{pending:2,blocked:1,unknown:2,loading:true});assert.deepEqual(sources.counts('b').getSnapshot(),{pending:2,blocked:2,unknown:1,loading:true});for(let index=0;index<1000;index++)assert.deepEqual(sources.counts('missing-'+index).getSnapshot(),{pending:0,blocked:0,unknown:0,loading:true});assert.equal(ops.scans,1);assert.equal(requests.scans,1);
const channel=sources.counts('a'),stop=channel.subscribe(()=>{});ops.reset();requests.reset();f.emit({...f.get(),document:{unrelated:true}});channel.getSnapshot();assert.equal(ops.scans+requests.scans,0);f.emit({...f.get(),runtime:{...f.get().runtime,loading:undefined}});assert.equal(channel.getSnapshot().loading,false);assert.equal(ops.scans,1);assert.equal(requests.scans,1);stop();
});
// 资源深相等克隆不通知且复用视图;退订后资源删除,直接读取仍能见record缺失。
test('cloned equal tables preserve view identity and unsubscribed channels still read current facts',()=>{
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0'),before=channel.getSnapshot();let calls=0;const stop=channel.subscribe(()=>calls++);f.emit({...f.get(),runtime:{...f.get().runtime,resources:new Tracked([...f.get().runtime.resources].map(([id,row])=>[id,structuredClone(row)]))}});assert.equal(calls,0);assert.strictEqual(channel.getSnapshot(),before);stop();const resources=new Tracked(f.get().runtime.resources);resources.delete('0');f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(channel.getSnapshot().record,undefined);assert.equal(f.size,0);
});
// 表get抛错后读者收到异常,解除broken且无关document发布也应重试并通知恢复;stop幂等,上游最终清零。
test('lookup failures retry without poisoning cached facts, and failed subscription retains no live group',()=>{
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot(),resources=new Tracked(f.get().runtime.resources);resources.broken=true;f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/table unavailable/);resources.broken=false;f.emit({...f.get(),document:{metadata:true}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();stop();assert.equal(f.size,0);
});
// 资源观察者抛错不饿死操作观察者;更早回调先清计数通道时迟到上游回调也不调用已退订观察者。
test('observer failures do not starve another collection and late captured callbacks after cleanup are safe',()=>{
const f=fixture(),sources=createRuntimeSources(f.source);let other=0;const a=sources.asset('0').subscribe(()=>{throw Error('observer failed');}),b=sources.operation('0').subscribe(()=>other++),resources=new Tracked(f.get().runtime.resources),ops=new Tracked(f.get().runtime.ledger.operations);resources.set('0',{id:'0',operationId:'upload',name:'changed'});ops.set('0',operation('0',['a'],'blocked',['permission']));assert.throws(()=>f.emit({...f.get(),runtime:{...f.get().runtime,resources,ledger:{...f.get().runtime.ledger,operations:ops}}}),/observer failed/);assert.equal(other,1);a();b();assert.equal(f.size,0);
let stop;const earlier=f.source.subscribe(()=>stop());stop=sources.counts().subscribe(()=>{throw Error('detached observer');});assert.doesNotThrow(()=>f.emit({...f.get(),runtime:{...f.get().runtime,loading:{}}}));earlier();assert.equal(f.size,0);
});
// 关联请求由accepted变unresolved改变unknown/blocked,指向缺失操作的无关请求不影响scope a的计数引用。
test('request status changes update scoped sync counts and unrelated requests preserve cached count identity',()=>{
const f=fixture(1),request={phase:'waiting',outcome:'accepted',operationIds:['0']};f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:new Tracked([['r',request]])}}});const sources=createRuntimeSources(f.source),channel=sources.counts('a');let calls=0;const stop=channel.subscribe(()=>calls++);assert.deepEqual(channel.getSnapshot(),{pending:1,blocked:0,unknown:0,loading:false});const requests=new Tracked([['r',{...request,outcome:'unresolved',error:'transport'}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests}}});assert.equal(calls,1);const counted=channel.getSnapshot();assert.deepEqual(counted,{pending:1,blocked:1,unknown:1,loading:false});const unrelated=new Tracked(requests);unrelated.set('other',{phase:'waiting',outcome:'unresolved',error:'other',operationIds:['missing']});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:unrelated}}});assert.equal(calls,1);assert.strictEqual(sources.counts('a').getSnapshot(),counted);stop();
});
// 固定种子的40组混合事实,遍历项目/a/b/c/missing作用域,与朴素筛选逐项深比较,保证优化不改原计数定义。
test('indexed counts preserve the previous filter definition over mixed request and operation states',()=>{
const f=fixture(0),sources=createRuntimeSources(f.source);let seed=7;const random=n=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed%n;};
for(let sample=0;sample<40;sample++){
const operations=new Tracked(Array.from({length:24},(_,index)=>{const id='op'+index,canvases=Array.from({length:random(4)},()=>['a','b','c'][random(3)]);return[id,operation(id,canvases,['pending','blocked','frozen','resolved'][random(4)],random(2)?['waiting_digest']:['permission'])];})),requests=new Tracked(Array.from({length:12},(_,index)=>['r'+index,{phase:['prepared','dispatch-marked','waiting','resolved'][random(4)],outcome:[undefined,'unresolved','accepted','applied'][random(4)],error:random(2)?'transport':undefined,retryAt:random(2)?100:undefined,operationIds:Array.from({length:random(5)},()=>'op'+random(26))}]));f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations,requests},loading:sample%2?{}:undefined}});
for(const scope of [undefined,'a','b','c','missing']){
// 朴素实现作为独立对照:先筛scope内未解决操作,再按逻辑ID关联请求,分别计算pending/blocked/unknown。
const selected=[...operations.values()].filter(row=>row.phase!=='resolved'&&(scope===undefined||row.intent.canvasIds.includes(scope))),ids=new Set(selected.map(row=>row.intent.id)),visible=[...requests.values()].filter(row=>row.phase!=='resolved'&&row.operationIds.some(id=>ids.has(id))),expected={pending:selected.length,blocked:selected.filter(row=>row.phase==='blocked'&&row.blockers.some(reason=>reason!=='waiting_digest')).length+visible.filter(row=>row.error&&row.retryAt===undefined).length,unknown:visible.filter(row=>row.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(row.phase)).length,loading:!!f.get().runtime.loading};assert.deepEqual(sources.counts(scope).getSnapshot(),expected,`sample ${sample}, scope ${scope}`);
}
}
});