59 lines
7.8 KiB
JavaScript
59 lines
7.8 KiB
JavaScript
/**
|
||
* 按元素ID分桶的文档订阅测试。用lookup计数、加载覆盖及无change列表的ACK替换,检查局部通知不漏更新也不做无关读取。
|
||
* 模拟ports只实现测试需要的记录查找,不执行真实editor命令或网络请求。
|
||
*/
|
||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||
import {createElementSources} from '../../src/lib/canvas-bindings/element-sources.ts';
|
||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||
/** 按count建元素表;lookup区分present/unloaded/absent并记录ID,edit携带field变化,emit可模拟整根替换。 */
|
||
function fixture(count=3){
|
||
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'};}};
|
||
return{ports,lookups,element,get size(){return listeners.size;},root:()=>root,throwSubscribe(value){throwSubscribe=value;},emit(next,changes=[]){root=next;for(const fn of [...listeners])fn({changes},root);},edit(id,title){const elements=new Map(root.document.elements);elements.set(id,{...elements.get(id),data:{title}});this.emit({...root,document:{elements}},[{kind:'field',target:{id,incarnation:1},field:'data'}]);}};
|
||
}
|
||
|
||
// 1000元素通道共享上游,编辑300只查300/通知300;无关runtime发布不重新lookup任何元素。
|
||
test('one ID edit among 1000 subscriptions evaluates only that target; runtime-only cuts do no element work',()=>{
|
||
const f=fixture(1000),sources=createElementSources(f.ports),calls=new Map(),notified=new Set(),stops=[];
|
||
for(let index=0;index<1000;index++){const id=String(index),store=createSelectorStore(sources.forElement(id),view=>{calls.set(id,(calls.get(id)??0)+1);return view.element.data.title;});stops.push(store.subscribe(()=>notified.add(id)));}
|
||
assert.equal(f.size,1);const before=new Map(calls);f.lookups.length=0;f.edit('300','changed');assert.deepEqual([...new Set(f.lookups)],['300']);assert.deepEqual([...notified],['300']);for(const[id,count]of calls)assert.equal(count,before.get(id)+(id==='300'?1:0));
|
||
f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,metadata:1}});assert.equal(f.lookups.length,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
||
});
|
||
|
||
// 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();
|
||
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();
|
||
});
|
||
|
||
// 加载结束让missing从unloaded变absent,只唤醒缺失目标,present对象不受影响。
|
||
test('loading coverage invalidates missing lookups at EOF without waking present targets',()=>{
|
||
const f=fixture(),sources=createElementSources(f.ports);f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6}}});const missing=sources.forElement('later'),present=sources.forElement('0'),seen=[];const a=missing.subscribe(()=>seen.push('missing')),b=present.subscribe(()=>seen.push('present'));assert.equal(missing.getSnapshot().status,'unloaded');f.emit({...f.root(),runtime:{...f.root().runtime,loading:undefined}});assert.deepEqual(seen,['missing']);assert.equal(missing.getSnapshot().status,'absent');a();b();
|
||
});
|
||
|
||
// 退订期间编辑不通知但getSnapshot仍新;再次订阅从当前基线开始,重复stop不泄漏上游。
|
||
test('getSnapshot stays fresh while unsubscribed, repeated cleanup is safe and late subscriptions do not reuse old baselines',()=>{
|
||
const f=fixture(),sources=createElementSources(f.ports),channel=sources.forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++);stop();stop();f.edit('0','unsubscribed');assert.equal(calls,0);assert.equal(channel.getSnapshot().element.data.title,'unsubscribed');const again=channel.subscribe(()=>calls++);f.edit('0','current');assert.equal(calls,1);again();assert.equal(f.size,0);
|
||
});
|
||
|
||
// 上游启动失败不残留;同次修改0/1时0观察者抛错,1仍收到通知,最后全部清理。
|
||
test('observer failures do not starve other buckets and failed upstream subscription retains no listener',()=>{
|
||
const f=fixture(),sources=createElementSources(f.ports);f.throwSubscribe(true);assert.throws(()=>sources.forElement('0').subscribe(()=>{}),/subscribe failed/);f.throwSubscribe(false);let b=0;const a=sources.forElement('0').subscribe(()=>{throw Error('observer failed');}),stopB=sources.forElement('1').subscribe(()=>b++),elements=new Map(f.root().document.elements);for(const id of ['0','1'])elements.set(id,{...elements.get(id),data:{title:'new'}});assert.throws(()=>f.emit({...f.root(),document:{elements}},['0','1'].map(id=>({kind:'element',id}))),/observer failed/);assert.equal(b,1);a();stopB();assert.equal(f.size,0);
|
||
});
|
||
|
||
// 更早监听先退掉唯一元素订阅,后续捕获的上游回调须安全忽略,不读已清除基线。
|
||
test('last unsubscribe before the captured upstream callback runs does not retain or dereference a dead baseline',()=>{
|
||
const f=fixture();let stop;const earlier=f.ports.subscribe(()=>stop()),sources=createElementSources(f.ports);let notified=0;stop=sources.forElement('0').subscribe(()=>notified++);assert.doesNotThrow(()=>f.edit('0','after detach'));assert.equal(notified,0);assert.equal(f.size,1);earlier();assert.equal(f.size,0);
|
||
});
|
||
|
||
// lookup暂时抛错也需通知,读者重读看到异常;恢复到同一旧值仍要再通知,解除失败状态。
|
||
test('lookup failure notifies readers and clearing the failure re-notifies even when the prior value is restored',()=>{
|
||
const f=fixture(),lookup=f.ports.lookup;let fail=false;f.ports.lookup=(...args)=>{if(fail)throw Error('lookup failed');return lookup(...args);};const channel=createElementSources(f.ports).forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot();fail=true;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/lookup failed/);fail=false;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();
|
||
});
|
||
|
||
// loadingRead文档未变时ACK前进不改变missing的读取依据;只有捕获读取基线换引用才重新lookup。
|
||
test('an advanced ACK does not recheck missing IDs against an unchanged captured loading baseline',()=>{
|
||
const f=fixture(),base=f.root().document;f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6},loadingRead:{document:base}}});const sources=createElementSources(f.ports),stop=sources.forElement('missing').subscribe(()=>{});f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,acknowledged:{...base}}});assert.deepEqual(f.lookups,[]);f.emit({...f.root(),runtime:{...f.root().runtime,loadingRead:{document:{...base}}}});assert.deepEqual(f.lookups,['missing']);stop();
|
||
});
|