52 lines
14 KiB
JavaScript
52 lines
14 KiB
JavaScript
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';
|
|
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}});
|
|
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));}};}
|
|
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);});
|
|
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);});
|
|
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);});
|
|
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);});
|
|
|
|
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);});
|
|
|
|
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();});
|
|
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);});
|
|
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();});
|
|
|
|
|
|
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;});
|
|
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);};
|
|
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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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));});
|
|
|
|
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);});
|
|
|
|
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}});});
|