docs(tests): clarify scoped subscriptions and resource lifecycle assertions
This commit is contained in:
@@ -7,6 +7,7 @@ import {createElementSources} from '../../src/lib/canvas-bindings/element-source
|
||||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||
/** 按count建元素表;lookup区分present/unloaded/absent并记录ID,edit携带field变化,emit可模拟整根替换。 */
|
||||
function fixture(count=3){
|
||||
// 元素故意只有订阅器需要的字段;这个夹具不是可通过完整文档 schema 校验的业务模型。
|
||||
const element=id=>({id,incarnation:1,ownerCanvasId:'root',data:{title:id}}),document={elements:new Map(Array.from({length:count},(_,index)=>{const id=String(index);return[id,element(id)];}))};let root={document,runtime:{acknowledged:document}},throwSubscribe=false;const listeners=new Set(),lookups=[];
|
||||
// throwSubscribe模拟启动失败,lookups观察实际读过哪些ID;订阅返回的清理函数只移除对应监听。
|
||||
const ports={getSnapshot:()=>root,subscribe(fn){if(throwSubscribe)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);},lookup(snapshot,id){lookups.push(id);const element=snapshot.document.elements.get(id);return element?{status:'present',element}:{status:snapshot.runtime.loading?'unloaded':'absent'};}};
|
||||
@@ -24,6 +25,7 @@ test('one ID edit among 1000 subscriptions evaluates only that target; runtime-o
|
||||
// ACK没有changes清单但替换document:应检查活跃目标,深相等克隆保持0原快照,真正改1仍通知。
|
||||
test('ACK replacement checks active targets, ignores equal cloned records and delivers unlisted changes',()=>{
|
||||
const f=fixture(),sources=createElementSources(f.ports),seen=[];const a=sources.forElement('0'),b=sources.forElement('1'),stopA=a.subscribe(()=>seen.push('0')),stopB=b.subscribe(()=>seen.push('1')),old=a.getSnapshot();
|
||||
// 不提供 changes 时用整个新文档模拟 canonical 替换;0 的克隆等价、1 的正文变化,分别检查复用和通知。
|
||||
const elements=new Map([...f.root().document.elements].map(([id,row])=>[id,structuredClone(row)]));elements.get('1').data.title='canonical';f.emit({...f.root(),document:{elements}});assert.deepEqual(seen,['1']);assert.strictEqual(a.getSnapshot(),old);assert.equal(b.getSnapshot().element.data.title,'canonical');stopA();stopB();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ test('immutable history ranges are reused across retention passes while working
|
||||
const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,0);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']);
|
||||
});
|
||||
// 历史字段修改可从已解决创建记录推断类型,并按 history/显式 owner 顺序保留资源;缺少类型证据时改为不完整扫描。
|
||||
// resolved 创建只提供已删除对象的组件类型证据,其 unreferenced 资产不能因此成为当前 pin;实际 pin 来自历史前后值和显式 owner。
|
||||
test('historical data fields retain schemas from resolved creations, conservative fallback pins and explicit owner order',()=>{
|
||||
const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'old',after:'new'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',label:'h',changes:[field],operationIds:[]}]])},ledger={operations:new Map([['resolved',{phase:'resolved',intent:{id:'resolved',sequence:1,operationType:'document.change',changes:[{kind:'element',id:'deleted',before:null,after:node('deleted','unreferenced')}]} }]])},snapshot={document:doc(),history},result=resourcePins(snapshot,ledger,registry,new Map([['explicit',['old','extra']]]));assert.equal(result.complete,true);assert.deepEqual([...result.pins].map(([id,owners])=>[id,[...owners]]),[['old',['history:h','explicit']],['new',['history:h']],['extra',['explicit']]]);ledger.operations.clear();assert.equal(resourcePins(snapshot,ledger,registry,new Map()).complete,false);
|
||||
});
|
||||
@@ -29,6 +30,7 @@ test('cached immutable transition ranges still use each current registry resourc
|
||||
const element={...node('historical','first'),data:{...node('historical','first').data,otherAsset:'second'}},entry={id:'h',label:'h',changes:[{kind:'element',id:element.id,before:null,after:element}],operationIds:[]};
|
||||
const transition=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])};
|
||||
const otherRegistry=createRegistry(builtinComponents().map(component=>component.id==='image'?{...component,resourceFields:['otherAsset']}:component));
|
||||
// 两次扫描共享同一不可变 transition,但传入不同 registry;缓存可复用历史结构,不能复用旧字段解释结果。
|
||||
const first=createResourcePinScan(empty,ledger,registry)(doc()),second=createResourcePinScan(empty,ledger,otherRegistry)(doc());
|
||||
assert.deepEqual([...first.pins.keys()],['first']);assert.deepEqual([...second.pins.keys()],['second']);assert.equal(first.complete,true);assert.equal(second.complete,true);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
function fixture() {
|
||||
const requests = [], updates = [], errors = [], released = [];
|
||||
const view = createResourceView({ resolve(resource, signal) { const gate = deferred(); requests.push({ resource, signal, gate }); return gate.promise; }, onChange(sources, failures) { updates.push({ sources, failures }); }, onError: error => errors.push(error) });
|
||||
/** 解析指定请求为URL句柄,release幂等记录一次,便于核对替换/卸载是否漏清理或重复释放。 */
|
||||
/** 解析为幂等 URL 句柄;released 记录有效释放,不记录重复调用次数,不能单靠此数组证明 release 从未被多调。 */
|
||||
const finish = (index, url = `blob:${index}`) => { let disposed = false; requests[index].gate.resolve({ url, release() { if (!disposed) { disposed = true; released.push(index); } } }); };
|
||||
return { view, requests, updates, errors, released, finish, latest: () => updates.at(-1) };
|
||||
}
|
||||
@@ -34,6 +34,7 @@ test('a failed reader does not hide other ready images and retry touches only fa
|
||||
// 同节点由old切new,旧signal被取消;旧实现忽略abort后才返回,也只释放旧句柄,不能覆盖新URL。
|
||||
test('replacement aborts the old reader and fences a late result that ignored cancellation', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'old']]); await tick(); f.view.sync([['a', 'new']]); await tick(); assert.equal(f.requests[0].signal.aborted, true);
|
||||
// 故意让新句柄先完成、旧句柄后到,隔离迟到成功的身份护栏,而非只检查 abort 信号已设置。
|
||||
f.finish(1, 'blob:new'); await tick(); f.finish(0, 'blob:old'); await tick();
|
||||
assert.deepEqual(f.latest().sources.get('a'), { resourceId: 'new', url: 'blob:new' }); assert.deepEqual(f.released, [0]); f.view.dispose(); assert.deepEqual(f.released, [0, 1]);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,8 @@ test('early snapshot reads cannot consume a binding notification before other ob
|
||||
// 多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);
|
||||
// 项目 pending 含 one/two/digest/project;waiting_digest 本身不算 blocked,r1 的无重试错误另计一次。
|
||||
// unknown 按相关请求去重而非按操作相加:项目为 r1/r3/r4,a 为 r1/r4,b 只有 r1。
|
||||
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();
|
||||
});
|
||||
@@ -57,6 +59,7 @@ test('request status changes update scoped sync counts and unrelated requests pr
|
||||
|
||||
// 固定种子的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}});
|
||||
|
||||
@@ -12,6 +12,7 @@ function fixture(){let state={views:new Map([['v',{modes:new Map(),viewport:{x:0
|
||||
test('1000 gesture and mode subscribers share one upstream and only the moved target is evaluated',()=>{
|
||||
const f=fixture(),sources=createSessionSources(f.source),calls=new Map(),notified=new Set(),stops=[];
|
||||
for(let index=0;index<1000;index++)for(const kind of ['mode','gesture']){const id=String(index),key=kind+id,channel=kind==='mode'?sources.mode('v',id):sources.gesture('v',{id,incarnation:1}),store=createSelectorStore(channel,value=>{calls.set(key,(calls.get(key)??0)+1);return value;});stops.push(store.subscribe(()=>notified.add(key)));}
|
||||
// 先建立活动手势再清通知,后续计数只针对预览坐标更新,不混入“开始手势”的目标状态变化。
|
||||
assert.equal(f.size,1);f.gesture();notified.clear();const before=new Map(calls);f.reset();f.preview('300',42);assert.deepEqual([...notified],['gesture300']);assert.equal(f.reads,2);for(const[key,count]of calls)assert.equal(count,before.get(key)+(key==='gesture300'?1:0));
|
||||
notified.clear();f.reset();f.emit({...f.get(),views:new Map(f.get().views).set('v',{...f.get().views.get('v'),viewport:{x:50,y:40,zoom:2}})});assert.equal(f.reads,1);assert.equal(notified.size,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
||||
});
|
||||
@@ -37,6 +38,7 @@ test('unsubscribed reads stay fresh; stale captured upstream callbacks and start
|
||||
});
|
||||
|
||||
// a回调内读取b必须已更新,即使a抛错b仍被通知,最终才向外抛观察者错误。
|
||||
// 这要求先更新各分桶基线再执行回调,不能更新一个目标就立即通知而让其它目标仍读到旧值。
|
||||
test('observer errors do not starve other targets and all values advance before callbacks',()=>{
|
||||
const f=fixture(),sources=createSessionSources(f.source),a=sources.mode('v','a'),b=sources.mode('v','b');let calls=0;const stops=[a.subscribe(()=>{assert.equal(b.getSnapshot(),'editing');throw Error('observer failed');}),b.subscribe(()=>calls++)];assert.throws(()=>f.emit({...f.get(),views:new Map(f.get().views).set('v',{modes:new Map([['a','editing'],['b','editing']])})}),/observer failed/);assert.equal(calls,1);for(const stop of stops)stop();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user