diff --git a/tests/headless/element-sources.test.mjs b/tests/headless/element-sources.test.mjs index 4ef3f91..4ddc8e7 100644 --- a/tests/headless/element-sources.test.mjs +++ b/tests/headless/element-sources.test.mjs @@ -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(); }); diff --git a/tests/headless/resource-pin-scan.test.mjs b/tests/headless/resource-pin-scan.test.mjs index 3c1f792..752abd4 100644 --- a/tests/headless/resource-pin-scan.test.mjs +++ b/tests/headless/resource-pin-scan.test.mjs @@ -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); }); diff --git a/tests/headless/resource-view.test.mjs b/tests/headless/resource-view.test.mjs index 103bba5..2ecbd22 100644 --- a/tests/headless/resource-view.test.mjs +++ b/tests/headless/resource-view.test.mjs @@ -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]); }); diff --git a/tests/headless/runtime-sources.test.mjs b/tests/headless/runtime-sources.test.mjs index 0303f79..a188277 100644 --- a/tests/headless/runtime-sources.test.mjs +++ b/tests/headless/runtime-sources.test.mjs @@ -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}}); diff --git a/tests/headless/session-sources.test.mjs b/tests/headless/session-sources.test.mjs index 80f4287..021b312 100644 --- a/tests/headless/session-sources.test.mjs +++ b/tests/headless/session-sources.test.mjs @@ -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(); });