Files
Endoleap/tests/headless/session-sources.test.mjs
T

43 lines
6.8 KiB
JavaScript

/**
* 会话按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)));}
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);
});
// 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();
});