Files
Endoleap/tests/headless/scene-projection.test.mjs
T

105 lines
22 KiB
JavaScript
Raw 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.
/**
* 增量场景投影的缓存与发布回归。renderer解析计数观察局部工作量,并以projectCanvas完整遍历作为结果对照。
* 会话捕获、文档hint连续性、scope/renderer身份及未知组件规则都必须保留,性能优化不能隐藏漏帧或改变端点。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
import {builtinRendererCatalog} from '../../src/lib/canvas-bindings/renderers.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
import {createSceneProjection} from '../../src/lib/canvas-bindings/scene-projection.ts';
/** 默认root文本节点工厂,可换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}});
/** root含F/B,child含A,edge从A连B;两view共享sessions,catalog.resolve累计计数,project允许传旧会话/替换scope。 */
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','child'),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),scene=createSceneProjection(),owner={};sessions.attach('v');sessions.attach('w');let reads=0;const catalog={...builtinRendererCatalog,resolve(record){reads++;return builtinRendererCatalog.resolve(record);}};return{editor,sessions,scene,owner,registry,catalog,get reads(){return reads;},project(snapshot=sessions.getSnapshot(),document=editor.getSnapshot().document,scope=owner,renderers=catalog){return scene.project(scope,document,snapshot.views.get('v'),snapshot,registry,renderers,editor.getQueries(document));}};}
// 不影响当前图形内容的session变化复用整场景;本view选择改变selected并重新投影。
test('viewport, tool, modes and other-view selection do no scene traversal; this-view selection does',()=>{const f=fixture(),first=f.project(),reads=f.reads;assert.ok(reads>0);f.sessions.viewport('v',{x:40,y:20,zoom:2});f.sessions.tool('v','pan');f.sessions.mode('v','B','editing');f.sessions.select('w',['A']);assert.strictEqual(f.project(),first);assert.equal(f.reads,reads);f.sessions.select('v',['B']);const selected=f.project();assert.equal(selected.nodes.find(n=>n.id==='B').selected,true);assert.notStrictEqual(selected,first);assert.ok(f.reads>reads);});
// 捕获w手势x=60后再改120,传旧snapshot仍显示60,live显示120且后代跟随;取消回原位置。
test('captured geometry previews remain coherent even after live update, and are shared across views',()=>{const f=fixture(),first=f.project();f.sessions.begin('w',[{id:'F',incarnation:1}]);assert.strictEqual(f.project(),first);f.sessions.update('w',{id:'F',incarnation:1},{x:60});const captured=f.sessions.getSnapshot();f.sessions.update('w',{id:'F',incarnation:1},{x:120});const old=f.project(captured),live=f.project();assert.equal(old.nodes.find(n=>n.id==='F').position.x,60);assert.equal(live.nodes.find(n=>n.id==='F').position.x,120);assert.equal(live.nodes.find(n=>n.id==='A').position.x-old.nodes.find(n=>n.id==='A').position.x,60);f.sessions.cancel();assert.equal(f.project().nodes.find(n=>n.id==='F').position.x,0);});
// 只有phase变committing不影响显示;path预览进入场景,取消使路径显示复原。
test('connection path preview and cancel invalidate, ownership-only phase does not',()=>{const f=fixture();f.sessions.beginConnection('w',{id:'edge',incarnation:1});const initial=f.project(),snapshot=f.sessions.getSnapshot();assert.strictEqual(f.project({...snapshot,connectionGesture:{...snapshot.connectionGesture,phase:'committing'}}),initial);const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:40},in:{x:-10,y:0},out:{x:10,y:0}}]};assert.equal(f.sessions.updateConnection('w',path).enabled,true);const preview=f.project();assert.deepEqual(preview.edges[0].data.path,path);f.sessions.cancel();assert.notStrictEqual(f.project(),preview);assert.equal(f.project().edges[0].data.path,undefined);});
// 正文修改、scope或renderer替换、clear都不能返回过期场景,最新document必须随结果采用。
test('document and renderer/scope replacement and clearing cannot retain stale scene data',()=>{const f=fixture(),first=f.project();assert.equal(f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'edited'}}).status,'applied');const next=f.project();assert.equal(next.document,f.editor.getSnapshot().document);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'edited');assert.notStrictEqual(next,first);assert.notStrictEqual(f.project(undefined,undefined,{}),next);const previous=f.project(),custom={...f.catalog,resolve(element){const value=f.catalog.resolve(element);return value&&{...value,rendererId:'replacement'};}};assert.equal(f.project(undefined,undefined,undefined,custom).nodes[0].type,'replacement');f.scene.clear();assert.notStrictEqual(f.project(),previous);});
// 即使场景已缓存,也先拒绝错误document/registry的query,不可命中缓存后跳过来源检查。
test('a cached scene does not hide an invalid query provenance',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;f.project();const project=queries=>f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog,queries);assert.throws(()=>project({document:{...document},registry:f.registry}),/stale_query_snapshot/);assert.throws(()=>project({document,registry:{}}),/query_registry_changed/);assert.equal(f.project().document,document);});
// 连续两次B正文发布在下一次project前合并,renderer只解析B一次,A和edges引用保持。
test('continuous field publications rebuild only their dirty node, including several cuts before a frame',()=>{const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const initial=f.project(),reads=f.reads;for(const [id,text]of [['B','one'],['B','two']])assert.equal(f.editor.dispatch({type:'element.data',target:{id,incarnation:1},patch:{text}}).status,'applied');const next=f.project();assert.equal(f.reads-reads,1);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'two');assert.strictEqual(next.nodes.find(n=>n.id==='A'),initial.nodes.find(n=>n.id==='A'));assert.strictEqual(next.edges,initial.edges);stop();});
// 等价新文档不再resolve,nodes/edges/transforms整体引用复用,但结果携新document。
test('equivalent canonical clones retain every projected object and numeric transform',()=>{const f=fixture(),first=f.project(),reads=f.reads;const document=createDocument('p',[...first.document.canvases.values()],[...first.document.elements.values()]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.equal(f.reads,reads);assert.strictEqual(result.nodes,first.nodes);assert.strictEqual(result.edges,first.edges);assert.strictEqual(result.transforms,first.transforms);assert.strictEqual(result.document,document);});
// 先漏掉A的提交hint,再收到B后续hint,场景需检测基线不连续并找回两处正文,不信不完整dirty列表。
// advance 是优化提示而非数据事实源;最终 document 变化必须可被发现,不能只投影 hint 列出的目标。
test('a missed publication cannot let an incomplete change hint hide a newer document',()=>{const f=fixture(),initial=f.project();f.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'missed'}});const middle=f.editor.getSnapshot().document;let later;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(middle,next.document,commit.changes);later=next.document;});f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'seen'}});const result=f.project();assert.notStrictEqual(result,initial);assert.strictEqual(result.document,later);assert.equal(result.nodes.find(n=>n.id==='A').data.text,'missed');assert.equal(result.nodes.find(n=>n.id==='B').data.text,'seen');stop();});
// 20轮正文/几何/选择/跨view预览/折叠缩放,后续增删改边和undo/redo,每个cut都与完整遍历比较。
test('mixed commands, hierarchy changes, selection and gestures match full traversal at every cut',()=>{
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});
/** 每个cut都按当前文档/会话完整投影,深比较增量结果,避免只验证节点数量或少数字段。 */
const compare=()=>{const document=f.editor.getSnapshot().document,snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),expected=projectCanvas(document,view.canvasId,f.registry,view.selection,id=>snapshot.gesture?.preview.get(id),snapshot.connectionGesture,f.catalog,{queries:f.editor.getQueries(document)});assert.deepEqual(f.project(),expected);};
/** 要求命令applied后立即比较,失败信息包含命令及结果便于定位混合序列中的差异。 */
const dispatch=command=>{const result=f.editor.dispatch(command);assert.equal(result.status,'applied',JSON.stringify({command,result}));compare();};compare();
for(let i=0;i<20;i++){
dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'step'+i}});
dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:i*2,rotation:i*3}});
f.sessions.select('v',i%2?['B']:['A','edge']);compare();
f.sessions.begin('w',[{id:'A',incarnation:1}]);f.sessions.update('w',{id:'A',incarnation:1},{y:30+i});compare();f.sessions.cancel();compare();
dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'frame'+i}});
dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:i*5}});
dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:i%2===0,contentScale:1+i/10}}});
}
dispatch({type:'element.create',element:node('C')});dispatch({type:'connection.endpoints',target:{id:'edge',incarnation:1},destination:{elementId:'C'}});
dispatch({type:'element.move',target:{id:'C',incarnation:1},canvasId:'child'});dispatch({type:'element.remove',target:{id:'C',incarnation:1}});assert.equal(f.editor.history.undo().status,'applied');compare();assert.equal(f.editor.history.redo().status,'applied');compare();stop();
});
// 1000节点缓存建好后禁止表遍历,500正文更新只能resolve一个renderer,邻居引用不变。
test('1000-node continuous update performs no document enumeration and resolves only one renderer',()=>{
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let enumerate=true,resolves=0;
/** 首次允许遍历建立缓存,此后关闭枚举门,若局部更新退化成全表遍历则断言失败。 */
const protect=document=>({...document,elements:{size:document.elements.size,get:id=>document.elements.get(id),has:id=>document.elements.has(id),values(){assert.ok(enumerate,'unexpected whole-document values scan');return document.elements.values();},[Symbol.iterator](){assert.ok(enumerate,'unexpected whole-document iterator');return document.elements[Symbol.iterator]();}}});
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},firstDoc=protect(original),first=scene.project(owner,firstDoc,view,snapshot,registry,catalog);
const elements=[...original.elements.values()].map(record=>record.id==='500'?{...record,data:{...record.data,text:'only target'}}:record),after=protect(createDocument('p',[...original.canvases.values()],elements));
// 先交付连续变化提示,再禁止元素表遍历;这里只观测投影阶段的读取,不限制上面构造新文档的工作量。
scene.advance(firstDoc,after,[{kind:'field',target:{id:'500',incarnation:1},field:'data.text',before:'',after:'only target'}]);enumerate=false;resolves=0;
const next=scene.project(owner,after,view,snapshot,registry,catalog);assert.equal(resolves,1);assert.equal(next.nodes[500].data.text,'only target');assert.strictEqual(next.nodes[499],first.nodes[499]);assert.equal(next.document,after);
});
// 无hint的ACK替换仍发现B正文,仅重建B;成员顺序反转后与完整投影次序一致。
test('replacement without changes compares records and detects reordered membership',()=>{const f=fixture(),initial=f.project(),doc=initial.document,records=[...doc.elements.values()].map(record=>record.id==='B'?{...record,data:{...record.data,text:'remote'}}:record),next=createDocument('p',[...doc.canvases.values()],records),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),reads=f.reads;f.scene.advance(doc,next,[]);const updated=f.scene.project(f.owner,next,view,snapshot,f.registry,f.catalog);assert.equal(f.reads-reads,1);assert.equal(updated.nodes.find(node=>node.id==='B').data.text,'remote');assert.strictEqual(updated.nodes.find(node=>node.id==='A'),initial.nodes.find(node=>node.id==='A'));const reordered=createDocument('p',[...next.canvases.values()],records.reverse());f.scene.advance(next,reordered,[]);assert.deepEqual(f.scene.project(f.owner,reordered,view,snapshot,f.registry,f.catalog),projectCanvas(reordered,'root',f.registry,view.selection,()=>undefined,null,f.catalog));});
// 选A/B时B解析抛错,先前场景两者selected仍false;解除错误后再完整生成选中场景。
test('a failed local renderer never mutates or publishes a partially updated scene',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;let fail=false;const catalog={...f.catalog,resolve(record){if(fail&&record.id==='B')throw Error('renderer failed');return f.catalog.resolve(record);}},first=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,catalog);f.sessions.select('v',['A','B']);const selected=f.sessions.getSnapshot();fail=true;assert.throws(()=>f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog),/renderer failed/);assert.equal(first.nodes.find(node=>node.id==='A').selected,false);assert.equal(first.nodes.find(node=>node.id==='B').selected,false);fail=false;const result=f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog);assert.equal(result.nodes.find(node=>node.id==='A').selected,true);assert.equal(result.nodes.find(node=>node.id==='B').selected,true);});
// 未来版本F作为unsupported,边源重定向F;B局部改动仍保留完整占位/端口规则及opaque原内容。
test('unknown placeholders and redirected handles keep the complete projection rules during a neighbour edit',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),base=f.editor.getSnapshot().document,records=[...base.elements.values()].map(record=>record.id==='F'?{...record,dataVersion:99,data:{unreadable:{preserve:true}}}:record.id==='edge'?{...record,source:{elementId:'A',portId:'unknown-custom'},target:{elementId:'B'}}:record),document=createDocument('p',[...base.canvases.values()],records),first=f.scene.project(f.owner,document,view,snapshot,f.registry,f.catalog),changed=createDocument('p',[...document.canvases.values()],records.map(record=>record.id==='B'?{...record,data:{...record.data,text:'still editable'}}:record));assert.equal(first.nodes.find(node=>node.id==='F').type,'unsupported');assert.equal(first.edges[0].source,'F');f.scene.advance(document,changed,[{kind:'field',target:{id:'B',incarnation:1},field:'data.text',before:'',after:'still editable'}]);const result=f.scene.project(f.owner,changed,view,snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(changed,'root',f.registry,view.selection,()=>undefined,null,f.catalog));assert.deepEqual(result.document.elements.get('F').data,{unreadable:{preserve:true}});});
// 同批增root/child节点并改B,只resolve三项,嵌套按深度优先顺序插入,既有F/A/edges引用复用。
test('new ordinary cards reuse existing projections and preserve depth-first nested membership order',()=>{
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
assert.equal(f.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-root')});tx.dispatch({type:'element.create',element:node('new-child','child')});tx.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'same batch'}});}).status,'applied');
const next=f.project();assert.equal(f.reads-reads,3,'unrelated nodes/edges were reconstructed during insertion');assert.deepEqual(next.nodes.map(n=>n.id),['F','A','new-child','B','new-root']);assert.strictEqual(next.nodes[0],first.nodes[0]);assert.strictEqual(next.nodes[1],first.nodes[1]);assert.strictEqual(next.edges,first.edges);
const snapshot=f.sessions.getSnapshot();assert.deepEqual(next,projectCanvas(next.document,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog,{queries:f.editor.getQueries(next.document)}));
const beforeEdit=f.reads;f.editor.dispatch({type:'element.data',target:{id:'new-child',incarnation:1},patch:{text:'later edit'}});const edited=f.project();assert.equal(f.reads-beforeEdit,1);assert.equal(edited.nodes.find(n=>n.id==='new-child').data.text,'later edit');stop();
});
// 折叠child里新增节点只进document,不重建可见数组;展开F时它出现并与完整遍历一致。
test('new hidden cards do not reconstruct visible objects and appear correctly after expanding their entry',()=>{
const f=fixture();f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
f.editor.dispatch({type:'element.create',element:node('hidden-new','child')});const next=f.project();assert.equal(f.reads,reads);assert.strictEqual(next.nodes,first.nodes);assert.strictEqual(next.edges,first.edges);assert.ok(next.document.elements.has('hidden-new'));
f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:false}}});const expanded=f.project();assert.ok(expanded.nodes.some(n=>n.id==='hidden-new'));const snapshot=f.sessions.getSnapshot();assert.deepEqual(expanded,projectCanvas(expanded.document,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));stop();
});
// 提供members索引并禁文档枚举,增一张卡只resolve它,保留既有1000节点及edges引用。
test('inserting one card into 1000 nodes resolves only that renderer and never enumerates document records with a membership index',()=>{
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let resolves=0;
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},first=scene.project(owner,original,view,snapshot,registry,catalog),added=node('added'),raw=createDocument('p',[...original.canvases.values()],[...original.elements.values(),added]),document={...raw,elements:{size:raw.elements.size,get:id=>raw.elements.get(id),has:id=>raw.elements.has(id),values(){throw Error('whole-document enumeration');},[Symbol.iterator](){throw Error('whole-document enumeration');}}},queries={document,registry,members:()=>[...raw.elements.keys()]};
// members 仍可列出当前成员;禁止的是重新枚举文档记录,不能把该断言解读为整个插入过程完全 O(1)。
scene.advance(original,document,[{kind:'element',id:'added',before:null,after:added}]);resolves=0;const next=scene.project(owner,document,view,snapshot,registry,catalog,queries);assert.equal(resolves,1);assert.equal(next.nodes.length,1001);assert.equal(next.nodes.at(-1).id,'added');for(let i=0;i<1000;i++)assert.strictEqual(next.nodes[i],first.nodes[i]);assert.strictEqual(next.edges,first.edges);
});
// 分别新增已知/未知卡片及关联边,增量路径须与完整投影保持相同端点/未来端口占位规则。
test('mixed edge insertion and unknown new cards retain full endpoint and placeholder projection rules',()=>{
for(const unknown of [false,true]){const f=fixture(),before=f.editor.getSnapshot().document;f.project();const added=unknown?{...node('added'),componentId:'future',dataVersion:99,data:{opaque:{keep:true}}}:node('added'),edge={id:'new-edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'added',...(unknown?{portId:'future-port'}:{})},data:{},path:null},after=createDocument('p',[...before.canvases.values()],[...before.elements.values(),added,edge]);f.scene.advance(before,after,[{kind:'element',id:added.id,before:null,after:added},{kind:'element',id:edge.id,before:null,after:edge}]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,after,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(after,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));}
});