Files
Endoleap/tests/headless/gesture-placement.test.mjs

38 lines
9.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 手势坐标上下文与目标可见性的回归。三层canvas加独立子视图,验证祖先位置/缩放/身份变化何时取消预览。
* 同时检查owner链读取缓存与未知祖先数据隔离;这里没有DOM指针捕获,测试的是core会话及placement读取器。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createGesturePlacementReader} from '../../src/lib/canvas-core/gesture-placement.ts';
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
/** 合法100×100节点工厂,可指定owner和frame组件,用来搭建坐标链。 */
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局部空间中的单个曲线点,连接手势用它验证owner祖先变化会失效路径预览。 */
const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:-10,y:0},out:{x:10,y:0}}]};
/** root→child→grand由F/G入口连接,A/D及其边归child,B归grand;同时挂root与child-view比较坐标边界。 */
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}},{id:'grand',parent:{canvasId:'child',entryElementId:'G'}}],[node('F','root','frame'),node('G','child','frame'),node('A','child'),node('B','grand'),node('D','child'),node('C'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'child',source:{elementId:'A'},target:{elementId:'D'},path,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor);sessions.attach('v','root');sessions.attach('child-view','child');return{editor,sessions};}
// 根视图预览B时移动祖先F,手势先取消;迟到commit拒绝且B的持久几何仍为0。
test('moving an ancestor cancels a descendant preview before a late commit can use old coordinates',()=>{const{editor,sessions}=fixture();assert.equal(sessions.begin('v',[{id:'B',incarnation:1}]).enabled,true);sessions.update('v',{id:'B',incarnation:1},{x:50});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);const before=editor.getSnapshot();assert.equal(sessions.commit('v').status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(before.document.elements.get('B').geometry.x,0);});
// 折叠F隐藏A,独立child-view也不能编辑根节点C;分别断言target_hidden与target_outside_view。
test('collapsed and outside-view targets cannot acquire a gesture',()=>{const{editor,sessions}=fixture();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'target_hidden');assert.equal(sessions.begin('child-view',[{id:'C',incarnation:1}]).code,'target_outside_view');});
// 对child局部路径预览后移动F,连接手势必须取消,不能把旧路径坐标提交到新上下文。
test('moving a path owner ancestor cancels its connection preview',()=>{const{editor,sessions}=fixture();assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).enabled,true);sessions.updateConnection('v',{...path,knots:[]});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().connectionGesture,null);});
// 分别改contentScale/unitScale/collapsed要求坐标失效;仅标签/标题或无关C几何改变则保留同一gesture对象。
test('scale and collapse cancel active descendant gestures but presentation labels do not',()=>{for(const presentation of [{contentScale:2},{unitScale:2},{collapsed:true}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);sessions.update('v',{id:'B',incarnation:1},{x:50});editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation}});assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'coordinate_context_changed');}const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'another title',presentation:{labelMode:'hidden',contentScale:1,unitScale:1}}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);editor.dispatch({type:'element.geometry',target:{id:'C',incarnation:1},patch:{x:80}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);});
// child-view以child为坐标根,上方F移动不影响本地B预览,commit仍可合法写x=50。
test('ancestor changes above an independently opened view do not alter its local coordinate space',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);sessions.update('child-view',{id:'B',incarnation:1},{x:50});const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);assert.equal(sessions.commit('child-view').status,'applied');assert.equal(editor.getSnapshot().document.elements.get('B').geometry.x,50);});
// 祖先生命周期、未来版本或同几何换owner都改变上下文;即使像素位置看似一致,也必须取消旧手势。
test('ancestor restore, future version and equal-geometry reparent invalidate the old context',()=>{for(const patch of [{incarnation:2},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='F'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}const{editor,sessions}=fixture();editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('H','root','frame'),geometry:{...node('H').geometry,x:-24,y:-36}}});tx.dispatch({type:'canvas.create',canvas:{id:'sibling',parent:{canvasId:'root',entryElementId:'H'}}});});sessions.begin('v',[{id:'B',incarnation:1}]);assert.equal(editor.dispatch({type:'element.move',target:{id:'F',incarnation:1},canvasId:'sibling',geometry:node('F').geometry}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);});
// 目标A由text变plain-text后旧预览取消,理由target_changed,不能只凭相同ID保留租约。
test('direct target component changes cannot retain an old geometry lease',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,componentId:'plain-text'}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'target_changed');});
// 删除F使child-view回退root,订阅只收到一次同时包含视图变更与手势取消的快照。
test('deleting the viewed canvas publishes its root fallback and cancellation together',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('child-view').canvasId,'root');assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'canvas_changed');});
// 用禁止遍历的表包络读取grand链,1000次复用同一结果且仅两次元素get,验证按链而非全图扫描。
test('placement readers share a checked owner chain and never enumerate the project',()=>{const{editor}=fixture(),document=editor.getSnapshot().document;let reads=0;const protectedDocument={...document,canvases:{has:id=>document.canvases.has(id),get:id=>document.canvases.get(id),[Symbol.iterator](){throw Error('project enumeration');}},elements:{get(id){reads++;return document.elements.get(id);},[Symbol.iterator](){throw Error('project enumeration');}}},read=createGesturePlacementReader(protectedDocument,'root',editor.registry),first=read('grand');assert.equal(first.length,2);for(let i=0;i<1000;i++)assert.strictEqual(read('grand'),first);assert.equal(reads,2);});
// 未来版本F的data getter一读就抛错;placement应以target_hidden拒绝且getter读取数为0。
test('future ancestor data is never interpreted to decide visibility',()=>{const{editor}=fixture(),document=editor.getSnapshot().document,frame=document.elements.get('F');let inspected=0;const opaque={...frame,dataVersion:99,get data(){inspected++;throw Error('opaque payload read');}},elements=new Map(document.elements);elements.set('F',opaque);const read=createGesturePlacementReader({...document,elements},'root',editor.registry);assert.throws(()=>read('grand'),/target_hidden/);assert.equal(inspected,0);});