docs(tests): explain input routing and subscription lifecycle coverage

This commit is contained in:
2026-11-12 02:36:36 +08:00
parent 3da19e288a
commit 74748d6e23
8 changed files with 83 additions and 0 deletions
+14
View File
@@ -1,12 +1,19 @@
/**
* 按元素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)));}
@@ -14,31 +21,38 @@ test('one ID edit among 1000 subscriptions evaluates only that target; runtime-o
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();
});
+12
View File
@@ -1,26 +1,38 @@
/**
* 呈现帧队列与外部store适配的调度测试。用可手动推进或故意不执行cancel的时钟,验证合并、立即回显、迟到回调及重入隔离。
* 延迟的是通知呈现,不是editor提交或getSnapshot中的最新事实;这里不运行真实requestAnimationFrame。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createFrameQueue,createFrameSource} from '../../src/lib/canvas-bindings/frame-queue.ts';
import {createSessionFrameSource} from '../../src/lib/canvas-bindings/session-frames.ts';
/** Map保存待执行任务,step先取出并清一批再执行,回调中新排的任务留下一帧;size用于检查调度是否泄漏。 */
function clock(){let id=0;const callbacks=new Map();return{request(fn){callbacks.set(++id,fn);return id;},cancel(id){callbacks.delete(id);},step(){const batch=[...callbacks.values()];callbacks.clear();for(const fn of batch)fn();},get size(){return callbacks.size;}};}
// 重复a与b合成一个dirty集合,flush期间新增next留后帧;dispose后排队和新invalidate都不能发布。
test('frame dirty Set deduplicates bursts, flushes last invalidation and preserves reentrant work for the next frame',()=>{const c=clock(),batches=[];let q;q=createFrameQueue(c,dirty=>{batches.push([...dirty]);if(dirty.has('a'))q.invalidate(['next']);});q.invalidate(['a','a']);q.invalidate(['b']);assert.equal(c.size,1);q.flush();assert.deepEqual(batches,[['a','b']]);assert.equal(c.size,1);c.step();assert.deepEqual(batches,[['a','b'],['next']]);q.invalidate(['discard']);q.dispose();c.step();q.invalidate(['closed']);assert.equal(batches.length,2);assert.equal(c.size,0);});
// 假cancel不移除函数,手动执行旧回调模拟迟到;代次检查必须阻止旧批次或已dispose队列再次通知。
test('canceled late callbacks cannot publish a replaced or disposed frame',()=>{const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},batches=[];const q=createFrameQueue(c,dirty=>batches.push([...dirty]));q.invalidate(['first']);q.flush();q.invalidate(['second']);callbacks[0]();assert.deepEqual(batches,[['first']]);q.dispose();callbacks[1]();assert.equal(batches.length,1);});
// 20次预览只排一帧,但读取立即得到x=19;取消后读取立即变null,退订清理上游及待呈现通知。
test('session presentation coalesces preview bursts but live reads and final cancel remain immediate; unsubscribe drops pending frame',()=>{const c=clock(),listeners=new Set();let snapshot={views:new Map(),gesture:null,connectionGesture:null};const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createSessionFrameSource(source,c);let paints=0;const stop=framed.subscribe(()=>paints++);const emit=next=>{snapshot=next;for(const fn of listeners)fn();};for(let i=0;i<20;i++)emit({...snapshot,gesture:{targets:[{id:'a',incarnation:1}],preview:new Map([['a',{x:i}]])}});assert.equal(c.size,1);assert.equal(paints,0);assert.equal(framed.getSnapshot().gesture.preview.get('a').x,19);c.step();assert.equal(paints,1);emit({...snapshot,gesture:null});assert.equal(framed.getSnapshot().gesture,null);c.step();assert.equal(paints,2);emit({...snapshot,views:new Map([['v',{}]])});stop();c.step();assert.equal(paints,2);assert.equal(listeners.size,0);});
// 三次编辑已提交且产生三条历史,画面通知合成一次;getSnapshot在帧执行前也返回最新editor根。
test('root presentation batches a burst while commands, history and authoritative reads advance immediately',async()=>{
const {createDocument,createEditor,createRegistry,builtinComponents}=await import('../../src/lib/canvas-core/index.ts');let id=0;
const editor=createEditor({document:createDocument('p',[{id:'canvas',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:'before',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]),registry:createRegistry(builtinComponents()),createId:()=>`op-${++id}`}),c=clock(),commits=[],source={getSnapshot:editor.getSnapshot,subscribe:notify=>editor.subscribe((commit,next)=>{commits.push(next.rootRevision);notify();})},framed=createFrameSource(source,c),painted=[],stop=framed.subscribe(()=>painted.push(framed.getSnapshot()));
for(const title of ['first','second','third'])assert.equal(editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title}}).status,'applied');
assert.equal(commits.length,3);assert.equal(painted.length,0);assert.equal(c.size,1);assert.strictEqual(framed.getSnapshot(),editor.getSnapshot());assert.equal(framed.getSnapshot().document.elements.get('a').data.title,'third');assert.equal(editor.getSnapshot().history.undo.length,3);c.step();assert.equal(painted.length,1);assert.strictEqual(painted[0],editor.getSnapshot());stop();editor.dispose();
});
// A退订后B新订阅,各自回调代次隔离;手动执行A旧函数不能通知B或恢复A。
test('frame source subscriptions own their queues and late callbacks cannot notify an unsubscribed view',()=>{
const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},listeners=new Set();let snapshot=0;
const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createFrameSource(source,c);let a=0,b=0;const stopA=framed.subscribe(()=>a++);snapshot++;for(const fn of listeners)fn();stopA();const stopB=framed.subscribe(()=>b++);snapshot++;for(const fn of listeners)fn();callbacks[0]();assert.equal(a,0);assert.equal(b,0);callbacks[1]();assert.equal(b,1);assert.equal(framed.getSnapshot(),2);stopB();assert.equal(listeners.size,0);
});
// 上游subscribe先通知再抛错要清掉任务;正常通知回调重入触发状态2,必须等下一帧而非混入状态1当前批。
test('eager failed subscriptions cancel presentation work and reentrant changes wait for the next frame',()=>{
const c=clock(),broken=createFrameSource({getSnapshot:()=>0,subscribe(notify){notify();throw Error('failed subscribe');}},c);assert.throws(()=>broken.subscribe(()=>assert.fail('failed subscription painted')),/failed subscribe/);assert.equal(c.size,0);
let current=0,listener;const source={getSnapshot:()=>current,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>{seen.push(framed.getSnapshot());if(current===1){current=2;listener();}});current=1;listener();c.step();assert.deepEqual(seen,[1]);assert.equal(c.size,1);c.step();assert.deepEqual(seen,[1,2]);stop();
});
// 后台值1尚待帧时输入值2以immediate通知,直接发布2并取消旧帧;随后值3仍按普通帧更新。
test('an immediate input invalidation publishes the newest Root and consumes any pending background frame',()=>{
const c=clock();let value=0,listener;const source={getSnapshot:()=>value,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>seen.push(framed.getSnapshot()));value=1;listener();assert.equal(c.size,1);value=2;listener(true);assert.deepEqual(seen,[2]);assert.equal(c.size,0);c.step();assert.deepEqual(seen,[2]);value=3;listener();c.step();assert.deepEqual(seen,[2,3]);stop();
});
+8
View File
@@ -1,8 +1,15 @@
/**
* 键盘输入路由的纯逻辑回归。通过上下文标记和简化closest/composedPath对象,测试输入优先级而不创建真实浏览器DOM。
* 回调计数用于确认被文本/IME/浮层认领的事件不会继续触发文档快捷键。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {routeCanvasKey,canvasKeyContext} from '../../src/lib/canvas-bindings/input-router.ts';
// 逐个设置textEditing/composing/overlay/defaultPrevented,Backspace必须在动作层之前退出,gesture与shortcut调用数都为0。
test('text, IME, overlays and already consumed input never execute document shortcuts',()=>{for(const flags of [{textEditing:true},{composing:true},{overlay:true},{defaultPrevented:true}]){let calls=0;const result=routeCanvasKey({key:'Backspace',...flags},{gesture:()=>{calls++;return true;},shortcut:()=>{calls++;return true;}});assert.equal(calls,0);assert.ok(['text','overlay','unclaimed'].includes(result));}});
// 依次让gesture/tool/shortcut成为首个赢家,只运行到认领层;无处理器返回unclaimed。
test('gesture then tool then shortcut claim once; declining handlers fall through',()=>{for(const winner of ['gesture','tool','shortcut']){const calls=[];const handlers=Object.fromEntries(['gesture','tool','shortcut'].map(layer=>[layer,()=>{calls.push(layer);return layer===winner;}]));assert.equal(routeCanvasKey({key:'Escape'},handlers),winner);assert.deepEqual(calls,['gesture','tool','shortcut'].slice(0,['gesture','tool','shortcut'].indexOf(winner)+1));}assert.equal(routeCanvasKey({key:'x'},{}),'unclaimed');});
// 用伪closest模拟select和注册浮层,并传空/无效路径项,确认上下文识别不依赖全局Element。
test('DOM input context recognizes registered overlays and native selects without browser globals',()=>{
const select={closest:selector=>selector.split(',').includes('select')?select:null};
const overlay={closest:selector=>selector.includes('[data-canvas-overlay-view]')?overlay:null};
@@ -10,6 +17,7 @@ test('DOM input context recognizes registered overlays and native selects withou
assert.deepEqual(canvasKeyContext({target:overlay}),{textEditing:false,overlay:true});
assert.deepEqual(canvasKeyContext({target:null,composedPath:()=>[null,{},42]}),{textEditing:false,overlay:false});
});
// 事件target已被Shadow DOM重定向到host,composedPath仍含textarea;Escape必须留给内部文本而非取消画布手势。
test('retargeted shadow events retain their inner text owner and never reach a gesture',()=>{
const input={closest:selector=>selector.split(',').includes('textarea')?input:null},host={closest:()=>null};
const context=canvasKeyContext({target:host,composedPath:()=>[input,host]});let canceled=false;
+7
View File
@@ -1,12 +1,19 @@
/**
* 节点连接点测量的批处理测试。用任务数组代替调度器,验证大量invalidate合并、每挂载owner租约与store隔离。
* 10000节点断言是确定性批次数量检查,不是浏览器布局或帧率性能测试。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createNodeInternalsBatch} from '../../src/lib/canvas-bindings/node-internals.ts';
// 每个lease重复invalidate,调度仍只有一个任务,执行一次提交完整ID列表;末尾释放所有owner。
test('10000 mounted nodes coalesce repeated measurement requests into one renderer update',()=>{
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),leases=Array.from({length:10000},(_,i)=>batch.acquire('n'+i));for(const lease of leases){lease.invalidate();lease.invalidate();}assert.equal(tasks.length,1);assert.equal(calls.length,0);tasks.shift()();assert.deepEqual(calls,[Array.from({length:10000},(_,i)=>'n'+i)]);for(const lease of leases)lease.dispose();
});
// A有两个owner、B卸载,旧A释放不取消新A的待测量;最后owner释放后迟到任务不能再次调用renderer。
test('unmounted and replaced owners cannot retain or cancel another lease for the same node',()=>{
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),old=batch.acquire('A'),next=batch.acquire('A'),removed=batch.acquire('B');old.invalidate();next.invalidate();removed.invalidate();old.dispose();removed.dispose();old.invalidate();tasks.shift()();assert.deepEqual(calls,[['A']]);next.invalidate();next.dispose();tasks.shift()();assert.equal(calls.length,1);
});
// 两个batch可各自更新同名A;one回调重入invalidate只排下一批,不影响two的任务或顺序。
test('renderer stores and later updates remain independent, including reentrant invalidation',()=>{
const tasks=[],calls=[];let again;const one=createNodeInternalsBatch(ids=>{calls.push(['one',ids]);again?.();},fn=>tasks.push(fn)),two=createNodeInternalsBatch(ids=>calls.push(['two',ids]),fn=>tasks.push(fn)),a=one.acquire('A'),b=two.acquire('A');let reentered=false;again=()=>{if(!reentered){reentered=true;a.invalidate();}};a.invalidate();b.invalidate();assert.equal(tasks.length,2);tasks.shift()();assert.equal(tasks.length,2);tasks.shift()();tasks.shift()();assert.deepEqual(calls,[['one',['A']],['two',['A']],['one',['A']]]);a.dispose();b.dispose();
});
+7
View File
@@ -1,5 +1,12 @@
/**
* 浮层索引的作用域、可见性与租约测试。纯屏幕矩形代替DOM测量,检查焦点/组合态保留及旧实例清理不会误删替换实例。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';import {createOverlayIndex} from '../../src/lib/canvas-bindings/overlays.ts';
/** 默认title浮层指向a/incarnation,x可移到视口外;viewport固定200×200供可见性比较。 */
const value=(incarnation=1,x=0)=>({placement:'title',target:{id:'a',incarnation},bounds:{x,y:0,width:100,height:30},focused:false,composing:false}),viewport={x:0,y:0,width:200,height:200};
// 两个view可用同portal ID而独立;屏幕外普通项隐藏,focused/composing保留,但身份失效仍必须排除。
test('screen placements are scoped by view, reject stale incarnations and preserve focused or composing offscreen editors',()=>{const a=createOverlayIndex('view-a'),b=createOverlayIndex('view-b');a.register('portal',value());b.register('portal',value(2));assert.equal(a.visible(viewport,target=>target.incarnation===1).length,1);assert.equal(b.visible(viewport,target=>target.incarnation===1).length,0);const lease=a.register('portal',value(1,1000));assert.equal(a.visible(viewport,()=>true).length,0);lease.update({...value(1,1000),composing:true});assert.equal(a.visible(viewport,()=>true).length,1);lease.update({...value(1,1000),focused:true});assert.equal(a.visible(viewport,()=>true).length,1);assert.equal(a.visible(viewport,()=>false).length,0);});
// 同注册ID替换后旧lease.release/update无权改新项,相同测量也不换快照或通知,只有fresh释放才移除。
test('late old-instance updates or cleanup cannot remove a replacement overlay; unchanged measurements do not notify',()=>{const index=createOverlayIndex('v');let changes=0;const stop=index.subscribe(()=>changes++),old=index.register('same',value()),fresh=index.register('same',value(2));const snapshot=index.getSnapshot(),baseline=changes;old.release();old.update(value());fresh.update(value(2));assert.strictEqual(index.getSnapshot(),snapshot);assert.equal(changes,baseline);fresh.release();assert.equal(index.getSnapshot().length,0);stop();});
// NaN矩形替换被拒绝,原快照与可见项保留;显式clear才清空。
test('invalid overlay replacement leaves the prior placement and index intact',()=>{const index=createOverlayIndex('v');index.register('x',value());const before=index.getSnapshot();assert.throws(()=>index.register('x',{...value(),bounds:{x:NaN,y:0,width:1,height:1}}));assert.strictEqual(index.getSnapshot(),before);assert.equal(index.visible(viewport,()=>true).length,1);index.clear();assert.equal(index.visible(viewport,()=>true).length,0);});
+17
View File
@@ -1,50 +1,67 @@
/**
* 运行事实按资源/操作/作用域计数订阅的测试。用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}`);
}
}
+7
View File
@@ -1,6 +1,13 @@
/**
* selector外部store的缓存与观察者测试。模拟不可变上游快照,核对相等选择复用引用、异常重读及多个订阅独立清理。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
/** 最小store:set替换state并遍历监听副本,size观察退订;没有真实React挂载或渲染。 */
function source(initial){let state=initial;const listeners=new Set();return{getSnapshot:()=>state,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},set(next){state=next;for(const fn of [...listeners])fn();},get size(){return listeners.size;}};}
// selector只取a,自定义比较器忽略新对象引用;改b不通知,改a才发布,重复读取不重复选择计算。
test('selector store caches immutable reads and notifies only changed selections',()=>{const s=source({a:1,b:1});let selections=0,notifications=0;const store=createSelectorStore(s,value=>{selections++;return{a:value.a};},(a,b)=>a.a===b.a),first=store.getSnapshot();assert.strictEqual(store.getSnapshot(),first);assert.equal(selections,1);const stop=store.subscribe(()=>notifications++);s.set({a:1,b:2});assert.strictEqual(store.getSnapshot(),first);assert.equal(notifications,0);s.set({a:2,b:2});assert.equal(notifications,1);assert.equal(store.getSnapshot().a,2);stop();assert.equal(s.size,0);});
// 负值使selector抛错,仍通知读者重读;恢复2再次通知,不能用旧成功值掩盖错误。
test('selector errors notify React to retry the snapshot and retain no invalid cached value',()=>{const s=source({value:1});const store=createSelectorStore(s,next=>{if(next.value<0)throw Error('invalid');return next.value;});let notifications=0;const stop=store.subscribe(()=>notifications++);s.set({value:-1});assert.equal(notifications,1);assert.throws(store.getSnapshot,/invalid/);s.set({value:2});assert.equal(store.getSnapshot(),2);assert.equal(notifications,2);stop();});
// A在第一次通知中退订,B仍收到两次更新;最后退订上游无监听残留。
test('distinct subscribers observe changes independently and unsubscribe during notification',()=>{const s=source(0),store=createSelectorStore(s,n=>n);let a=0,b=0,stopA;stopA=store.subscribe(()=>{a++;stopA();});const stopB=store.subscribe(()=>b++);s.set(1);s.set(2);assert.equal(a,1);assert.equal(b,2);stopB();assert.equal(s.size,0);});
+11
View File
@@ -1,8 +1,14 @@
/**
* 会话按view/元素分桶订阅测试。模拟两视图、mode与多目标gesture,检查一个上游连接下的局部通知及退订生命周期。
* 预览可共享给其它视图显示,但active所有权和incarnation必须精确,不因同ID就认领手势。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createSessionSources} from '../../src/lib/canvas-bindings/session-sources.ts';
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
/** 内存source计数读取与监听,broken模拟订阅失败;emit替换快照,gesture/preview/mode构造最小合法测试变化。 */
function fixture(){let state={views:new Map([['v',{modes:new Map(),viewport:{x:0,y:0,zoom:1}}],['w',{modes:new Map()}]]),gesture:null,connectionGesture:null},reads=0,broken=false;const listeners=new Set();const source={getSnapshot(){reads++;return state;},subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get size(){return listeners.size;},get reads(){return reads;},reset(){reads=0;},broken(value){broken=value;},get:()=>state,emit(next){state=next;for(const fn of [...listeners])fn();},gesture(id='300',viewId='v',incarnation=1){this.emit({...state,gesture:{token:1,viewId,targets:[{id,incarnation}],preview:new Map(),phase:'active'}});},preview(id,x){this.emit({...state,gesture:{...state.gesture,preview:new Map(state.gesture.preview).set(id,{x,y:0,width:10,height:10,rotation:0})}});},mode(view,id,value){const current=state.views.get(view),modes=new Map(current.modes);if(value)modes.set(id,value);else modes.delete(id);this.emit({...state,views:new Map(state.views).set(view,{...current,modes})});}};}
// 2000通道共用一个上游,更新300预览只重新选择gesture300;只改viewport不通知这些mode/gesture通道。
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)));}
@@ -10,22 +16,27 @@ test('1000 gesture and mode subscribers share one upstream and only the moved ta
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);
});
// w:a与v:b分别通知;删除v只清其活动mode,不误清w或唤醒从未有mode的v:a。
test('mode changes route by view and target, and detaching a view resets only its active modes',()=>{
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.mode('v','a'),b=sources.mode('w','a'),c=sources.mode('v','b'),stops=[a.subscribe(()=>seen.push('v:a')),b.subscribe(()=>seen.push('w:a')),c.subscribe(()=>seen.push('v:b'))];f.mode('w','a','editing');assert.deepEqual(seen,['w:a']);f.mode('v','b','resize');assert.deepEqual(seen,['w:a','v:b']);const views=new Map(f.get().views);views.delete('v');f.emit({...f.get(),views});assert.deepEqual(seen,['w:a','v:b','v:b']);assert.equal(a.getSnapshot(),null);assert.equal(b.getSnapshot(),'editing');for(const stop of stops)stop();
});
// w看到a的预览但active=false,错误incarnation不见预览;gesture结束两者都回到无活动值。
test('preview is shared across views while active ownership and incarnation remain exact',()=>{
const f=fixture(),sources=createSessionSources(f.source),v=sources.gesture('v',{id:'a',incarnation:1}),w=sources.gesture('w',{id:'a',incarnation:1}),stale=sources.gesture('v',{id:'a',incarnation:2}),stops=[v.subscribe(()=>{}),w.subscribe(()=>{}),stale.subscribe(()=>{})];f.gesture('a');f.preview('a',77);assert.equal(v.getSnapshot().active,true);assert.equal(w.getSnapshot().active,false);assert.equal(w.getSnapshot().preview.x,77);assert.deepEqual(stale.getSnapshot(),{preview:undefined,active:false});f.emit({...f.get(),gesture:null});assert.deepEqual(v.getSnapshot(),{preview:undefined,active:false});for(const stop of stops)stop();
});
// 同一手势含a/b,逐个更新只通知对应目标;无关connectionGesture变化复用a原快照。
test('multi-target preview only notifies the target whose geometry changed',()=>{
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.gesture('v',{id:'a',incarnation:1}),b=sources.gesture('v',{id:'b',incarnation:1}),stops=[a.subscribe(()=>seen.push('a')),b.subscribe(()=>seen.push('b'))];f.emit({...f.get(),gesture:{token:1,viewId:'v',targets:[{id:'a',incarnation:1},{id:'b',incarnation:1}],preview:new Map(),phase:'active'}});seen.length=0;f.preview('a',5);assert.deepEqual(seen,['a']);f.preview('b',8);assert.deepEqual(seen,['a','b']);const before=a.getSnapshot();f.emit({...f.get(),connectionGesture:{target:{id:'edge',incarnation:1}}});assert.strictEqual(a.getSnapshot(),before);for(const stop of stops)stop();
});
// 先制造subscribe失败,再让更早监听提前退订当前通道;迟到回调不通知,但直接读取仍最新,重新订阅正常且重复清理安全。
test('unsubscribed reads stay fresh; stale captured upstream callbacks and startup failure clean up safely',()=>{
const f=fixture();let stop;const earlier=f.source.subscribe(()=>stop?.()),sources=createSessionSources(f.source),channel=sources.mode('v','a');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;stop=channel.subscribe(()=>calls++);f.mode('v','a','editing');assert.equal(calls,0);assert.equal(channel.getSnapshot(),'editing');stop();earlier();assert.equal(f.size,0);const again=channel.subscribe(()=>calls++);f.mode('v','a','resize');assert.equal(calls,1);again();again();assert.equal(f.size,0);
});
// 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();
});