/** * ViewSession 的身份、工具、导航和释放边界测试。交互预览属于会话,不能借切换工具或清理旧视图修改业务文档。 * 多个 view 共享 editor,但 mode 和 tool 各自保存;手势取消与失效 mode 清理必须作为一次最终状态通知。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts'; // 构造带固定 incarnation 和完整几何的节点;owner 和 component 可按分区或类型场景替换。 const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); // 根 canvas 中有两个文本目标、连线与子分区入口;v、w 两个视图用于检查会话间隔离及过期 detach。 function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),detach=sessions.attach('v');sessions.attach('w');return{editor,sessions,detach};} // 同 ID 原子替换为第二代元素后,旧 resize mode 不得继承到新身份。 test('an atomic restored incarnation cannot inherit the old element editing mode',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);}); // 手势所属视图切到 pan 时一次发布新工具和取消后的预览;pan 不接受新变换或连线手势,业务 Root 保持原引用。 test('switching tools clears the owned preview atomically and blocks fresh transform/connection gestures in pan',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot();const observed=[];sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.tool('v','pan');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').tool,'pan');assert.equal(observed[0].gesture,null);assert.equal(observed[0].lastCancelReason,'tool_changed');assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'tool_not_select');assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).code,'tool_not_select');assert.strictEqual(editor.getSnapshot(),before);}); // 同名视图重新 attach 后,旧的 detach 回调再次调用也不能移除新实例。 test('old detach cleanup cannot remove a freshly attached view with the same ID',()=>{const{sessions,detach}=fixture();detach();sessions.attach('v');detach();assert.equal(sessions.getSnapshot().views.has('v'),true);}); // 普通文字修改保留两个视图的 mode,owner 迁移则清空;不存在的元素不能进入 mode,清理其空 mode 是无变化操作。 test('mode lifetime follows identity and placement while ordinary content edits preserve it',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','editing');sessions.mode('w','A','resize');editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'new content'}});assert.equal(sessions.getSnapshot().views.get('v').modes.get('A'),'editing');assert.equal(sessions.getSnapshot().views.get('w').modes.get('A'),'resize');assert.equal(editor.dispatch({type:'element.move',target:{id:'A',incarnation:1},canvasId:'child'}).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.size,0);assert.equal(sessions.getSnapshot().views.get('w').modes.size,0);assert.throws(()=>sessions.mode('v','missing','editing'),/element_missing/);const before=sessions.getSnapshot();sessions.mode('v','missing',null);assert.strictEqual(sessions.getSnapshot(),before);}); // 目标换代时只通知一次,同时清除 mode 和变换预览,并报告 target_changed。 test('target replacement publishes mode invalidation and gesture cancellation together',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('v').modes.has('A'),false);assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'target_changed');}); // 其他视图切工具不影响当前手势,重复选同工具不发布;未知工具被拒绝,连线手势也受所属视图工具约束。 test('another view tool change preserves the gesture, and same-tool selection is idempotent',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;sessions.tool('w','pan');assert.strictEqual(sessions.getSnapshot().gesture,gesture);const before=sessions.getSnapshot();sessions.tool('v','select');assert.strictEqual(sessions.getSnapshot(),before);assert.throws(()=>sessions.tool('v','unknown'),/invalid_tool/);assert.strictEqual(sessions.getSnapshot(),before);sessions.cancel();sessions.beginConnection('w',{id:'edge',incarnation:1});assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.tool('w','select');assert.equal(sessions.beginConnection('w',{id:'edge',incarnation:1}).enabled,true);sessions.tool('w','pan');assert.equal(sessions.getSnapshot().connectionGesture,null);}); // 导航和 detach 都只暴露最终会话状态;不能出现目标 canvas 已换但旧手势仍在的中间通知。 test('canvas navigation and detach notify only a final session, never a new target with the old gesture',()=>{const{sessions,detach}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let observed=[];const stop=sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.canvas('v','child');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').canvasId,'child');assert.equal(observed[0].gesture,null);sessions.canvas('v','root');sessions.begin('v',[{id:'A',incarnation:1}]);observed=[];detach();assert.equal(observed.length,1);assert.equal(observed[0].views.has('v'),false);assert.equal(observed[0].gesture,null);stop();}); // dispose 先封闭入口再通知清理,因此监听器重入不能创建手势;反复 dispose 不再通知或留下视图。 test('dispose fences reentrant admission and cannot leave a gesture or accept repeated cleanup',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let attempt,calls=0;sessions.subscribe(()=>{calls++;attempt=sessions.begin('v',[{id:'A',incarnation:1}]);});sessions.dispose();assert.equal(attempt.enabled,false);assert.equal(attempt.code,'view_detached');assert.equal(calls,1);assert.equal(sessions.getSnapshot().views.size,0);assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.dispose();assert.equal(calls,1);}); // 类型或 dataVersion 改变都会使原 mode 失效;非字符串 mode 被拒绝且不替换会话快照。 test('component/version changes invalidate modes while invalid tags never enter a view',()=>{for(const patch of [{componentId:'plain-text'},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.mode('v','A','editing');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);const snapshot=sessions.getSnapshot();assert.throws(()=>sessions.mode('v','B',{}),/invalid_mode/);assert.strictEqual(sessions.getSnapshot(),snapshot);}});