300 lines
45 KiB
JavaScript
300 lines
45 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry,defineComponent,movable,resizable,rotatable,unitScale,withUnitScale } from '../../src/lib/canvas-core/index.ts';
|
|
import { projectCanvas } from '../../src/lib/canvas-bindings/projection.ts';
|
|
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
|
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
|
const registry=createRegistry(builtinComponents()),geometry={x:100,y:60,width:100,height:80,rotation:0};
|
|
const node=(id,owner='root',extra={})=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry,...extra});
|
|
const frame=node('f','root',{componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Frame',text:'',presentation:{contentScale:1.5}}});
|
|
const edge=(id,source,target,owner='root',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,data:{label:'preserve'},source:{elementId:source},target:{elementId:target},path});
|
|
const base=()=>createDocument('project',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[frame,node('a'),node('b'),node('c','child')]);
|
|
const path={type:'bezier-chain',extension:{keep:true},knots:[{id:'k',mode:'corner',position:{x:200,y:100},in:{x:-10,y:0},out:{x:10,y:0},extra:'keep'}]};
|
|
const target=id=>({id,incarnation:1});
|
|
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
|
|
const choices=engine=>new Map(pending(engine).map(record=>[record.intent.id,'keep-local']));
|
|
function fixture(original=base()){
|
|
let server=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),image,owner;const calls=[];
|
|
const f={reject:true,calls,storage:{async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},port:{compile(intent,id,snapshot){return{payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const {intent}=JSON.parse(request.body);for(const[id,version]of Object.entries(request.expectedVersions))assert.equal(version,versions.get(id)??0);const after=applyChanges(server,intent.changes);validateDocument(after,registry);for(const change of intent.changes){const ids=change.kind==='canvas'?[change.id]:change.kind==='element'?[change.before?.ownerCanvasId,change.after?.ownerCanvasId]:[server.elements.get(change.target.id)?.ownerCanvasId,after.elements.get(change.target.id)?.ownerCanvasId];for(const id of ids)if(id)assert.ok(request.canvasIds.includes(id));}server=after;for(const id of request.canvasIds)versions.set(id,(versions.get(id)??0)+1);return{outcome:'applied',canonical:{id:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions};},async read(){return{document:server,versions};}},remote(changes){server=applyChanges(server,changes);validateDocument(server,registry);for(const id of new Set([...versions.keys(),...server.canvases.keys()]))versions.set(id,(versions.get(id)??0)+1);},image:()=>structuredClone(image),server:()=>server,async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port});}};return f;
|
|
}
|
|
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
|
|
|
|
test('pending create and later edits recover with absent baselines, new keys and lifecycle-safe undo/redo',async()=>{
|
|
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
|
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
|
assert.equal(plan.ledger.operations.get(plan.operationIds[0]).basePresence.new,'absent');f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('new').data.title,'edited');assert.equal(engine.getSnapshot().history.undo.length,2);
|
|
for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().elements.has('new'),false);
|
|
for(let i=0;i<2;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,'edited');
|
|
assert.equal(decodeRecovery(f.image(),registry).document.elements.get('new').incarnation,2);await engine.close();
|
|
});
|
|
|
|
test('an atomic frame/canvas/member creation can be kept or discarded without orphan records',async()=>{
|
|
for(const keep of [true,false]){
|
|
const f=fixture(),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-frame','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'new-canvas',parent:{canvasId:'root',entryElementId:'new-frame'}}});tx.dispatch({type:'element.create',element:node('inside','new-canvas')});});await engine.settle();
|
|
const session=await engine.prepareRecovery(),selected=choices(engine);if(!keep)for(const id of selected.keys())selected.set(id,'discard-local');const plan=session.plan(selected);f.reject=false;await session.resolve(plan);await engine.settle();
|
|
assert.equal(engine.getSnapshot().document.canvases.has('new-canvas'),keep);assert.equal(engine.getSnapshot().document.elements.has('inside'),keep);assert.equal(engine.getSnapshot().history.undo.length,keep?1:0);
|
|
if(keep){assert.equal(f.calls.at(-1).expectedVersions['new-canvas'],0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('new-canvas'),false);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('inside').incarnation,2);}
|
|
await engine.close();
|
|
}
|
|
});
|
|
|
|
test('deletion is re-planned from current content and includes a new remote incident edge in its undo unit',async()=>{
|
|
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();
|
|
f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote content',beforePresent:true,afterPresent:true},{kind:'element',id:'new-edge',before:null,after:edge('new-edge','a','b')}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)),replacement=plan.operations.get(plan.operationIds[0]);assert.ok(replacement.changes.some(change=>change.kind==='element'&&change.id==='new-edge'&&change.after===null));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.has('new-edge'),false);
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.text,'remote content');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(f.server().elements.get('new-edge').source.elementId,'a');await engine.close();
|
|
});
|
|
|
|
test('deleting a partition uses its current subtree and cross-scope edges; undo restores all lifecycle identities',async()=>{
|
|
const original=base(),f=fixture(original),engine=await f.open();assert.equal(engine.editor.dispatch({type:'element.remove',target:target('f')}).status,'applied');await engine.settle();
|
|
f.remote([{kind:'element',id:'remote-child',before:null,after:node('remote-child','child')},{kind:'element',id:'cross',before:null,after:edge('cross','a','remote-child')}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.has('child'),false);assert.equal(f.server().elements.has('cross'),false);assert.ok(f.calls.at(-1).canvasIds.includes('child'));
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();validateDocument(f.server(),registry);assert.equal(f.server().elements.get('remote-child').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');assert.equal(f.server().elements.get('cross').target.elementId,'remote-child');await engine.close();
|
|
});
|
|
|
|
test('owner moves preserve the intended world position and relocate edge curves with the common canvas',async()=>{
|
|
const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','c','root',path)]),f=fixture(original),engine=await f.open();
|
|
const world=toWorld(canvasTransform(original,'root'),{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2});assert.equal(engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).status,'applied');await engine.settle();
|
|
f.remote([{kind:'field',target:target('f'),field:'geometry',before:frame.geometry,after:{...frame.geometry,x:400,rotation:70}}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();const current=f.server(),a=current.elements.get('a'),actual=toWorld(canvasTransform(current,'child'),{x:a.geometry.x+a.geometry.width/2,y:a.geometry.y+a.geometry.height/2});near(actual.x,world.x);near(actual.y,world.y);
|
|
const connection=current.elements.get('e');assert.equal(connection.ownerCanvasId,'child');const knot=toWorld(canvasTransform(current,'child'),connection.path.knots[0].position);near(knot.x,200);near(knot.y,100);assert.deepEqual(connection.path.extension,{keep:true});
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.x,400);assert.equal(f.server().elements.get('e').ownerCanvasId,'root');await engine.close();
|
|
});
|
|
|
|
test('reconnection uses current endpoint scope, preserves unrelated data, and refuses a restored endpoint lifecycle',async()=>{
|
|
const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','b','root',path)]);
|
|
for(const restored of [false,true]){
|
|
const f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'connection.endpoints',target:target('e'),source:{elementId:'c'}});await engine.settle();
|
|
if(restored)f.remote([{kind:'element',id:'c',before:original.elements.get('c'),after:null},{kind:'element',id:'c',before:null,after:{...original.elements.get('c'),incarnation:2}}]);
|
|
else f.remote([{kind:'field',target:target('b'),field:'ownerCanvasId',before:'root',after:'child'},{kind:'field',target:target('e'),field:'data.label',before:'preserve',after:'remote label',beforePresent:true,afterPresent:true}]);
|
|
const session=await engine.prepareRecovery();if(restored){assert.throws(()=>session.plan(choices(engine)),/stale_endpoint/);await session.cancel();}else{const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('e').ownerCanvasId,'child');assert.equal(f.server().elements.get('e').data.label,'remote label');assert.ok(f.calls.at(-1).canvasIds.includes('child'));assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('e').source.elementId,'a');}
|
|
await engine.close();
|
|
}
|
|
});
|
|
|
|
test('discarded creations cannot be used by kept dependents, and colliding/tombstoned identities never bind foreign content',async()=>{
|
|
const f=fixture(),engine=await f.open();const create=engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
|
|
let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(create.operationIds[0],'discard-local');assert.throws(()=>session.plan(selected),/stale_incarnation/);await session.cancel();
|
|
f.remote([{kind:'element',id:'new',before:null,after:node('new')}]);session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);await session.cancel();await engine.close();
|
|
});
|
|
|
|
test('core world/local geometry conversion and history redo preserve user-owned lifecycles',()=>{
|
|
const document=base(),editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:node('new')});editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});
|
|
editor.history.undo();editor.history.undo();assert.equal(editor.history.redo().status,'applied');assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('new').data.title,'edited');
|
|
const from=canvasTransform(document,'root'),to=canvasTransform(document,'child'),converted=convertGeometry(geometry,from,to),round=convertGeometry(converted,to,from);for(const key of ['x','y','width','height','rotation'])near(round[key],geometry[key]);
|
|
});
|
|
|
|
test('moving an entry follows its current child binding rather than stealing its former canvas',async()=>{
|
|
const g=node('g','root',{componentId:'frame'}),h=node('h','root',{componentId:'frame'}),original=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,h]),f=fixture(original),engine=await f.open();
|
|
engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'});await engine.settle();
|
|
f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{id:'child',parent:{canvasId:'root',entryElementId:'h'}}},{kind:'canvas',id:'new-child',before:null,after:{id:'new-child',parent:{canvasId:'root',entryElementId:'f'}}}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.equal(f.server().canvases.get('child').parent.canvasId,'root');assert.equal(f.server().canvases.get('new-child').parent.canvasId,'outer');await engine.close();
|
|
});
|
|
|
|
test('unknown curve formats remain atomic errors while scaled containers preserve their child transform',()=>{
|
|
const g=node('g','root',{componentId:'frame',data:{title:'g',text:'',presentation:{contentScale:2}}}),document=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,edge('e','a','c','root',{type:'future-path',keep:true})]),editor=createEditor({registry,document}),before=editor.getSnapshot();
|
|
assert.equal(editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).code,'unsupported_coordinate_path');assert.equal(editor.getSnapshot(),before);
|
|
const originalTransform=canvasTransform(document,'child');assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'}).status,'applied');const currentTransform=canvasTransform(editor.getSnapshot().document,'child');for(const key of ['x','y','scale','rotation'])near(currentTransform[key],originalTransform[key]);assert.equal(editor.getSnapshot().document.elements.get('c'),before.document.elements.get('c'));
|
|
});
|
|
|
|
test('reparenting preserves fresh unknown parent metadata instead of restoring an older envelope',async()=>{
|
|
const h={...frame,id:'h'},original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),h]),f=fixture(original),engine=await f.open();
|
|
engine.editor.dispatch({type:'canvas.reparent',canvasId:'child',parent:{canvasId:'root',entryElementId:'h'}});await engine.settle();f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{...original.canvases.get('child'),parent:{...original.canvases.get('child').parent,future:{keep:'remote'}}}}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.deepEqual(f.server().canvases.get('child').parent.future,{keep:'remote'});await engine.close();
|
|
});
|
|
|
|
function scaledTree(){
|
|
const outer=node('g','root',{componentId:'frame',geometry:{x:700,y:250,width:800,height:700,rotation:75},data:{title:'Target',text:'',presentation:{contentScale:2.5}}}),nested=node('nested','child',{componentId:'frame',geometry:{x:20,y:30,width:250,height:180,rotation:340},data:{title:'Nested',text:'',presentation:{contentScale:.8,collapsed:true}}});
|
|
const original=base(),f={...frame,data:{...frame.data,presentation:{contentScale:.6,future:{keep:'metadata'}}}},inside={...node('inside','child'),data:{title:'Inside',text:'Text',fontSize:19,presentation:{unitScale:.75}}};
|
|
return createDocument('project',[...original.canvases.values(),{id:'target',parent:{canvasId:'root',entryElementId:'g'}},{id:'deep',parent:{canvasId:'child',entryElementId:'nested'}}],[...original.elements.values()].filter(element=>element.id!=='f').concat([f,outer,nested,inside,node('future','deep',{componentId:'future-node',dataVersion:9,data:{opaque:{keep:true}}}),node('q','target'),edge('cross','inside','q','root',path),edge('opaque','inside','nested','child',{type:'future-path',payload:{keep:7}})]));
|
|
}
|
|
const worldNode=(document,id)=>{const element=document.elements.get(id),transform=canvasTransform(document,element.ownerCanvasId),g=convertGeometry(element.geometry,transform,{x:0,y:0,scale:1,rotation:0});return{...g,visualScale:transform.scale*((element.data.presentation?.unitScale)??1)};};
|
|
function nearNode(a,b){for(const key of ['x','y','width','height','rotation','visualScale'])near(a[key],b[key]);}
|
|
|
|
test('a scaled container move preserves nested world geometry, visual units, opaque children and all canvas identities',()=>{
|
|
const original=scaledTree(),editor=createEditor({registry,document:original}),before=editor.getSnapshot();
|
|
assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'}).status,'applied');const after=editor.getSnapshot().document;
|
|
for(const id of ['f','c','inside','nested','future'])nearNode(worldNode(after,id),worldNode(before.document,id));
|
|
for(const id of ['child','deep']){const a=canvasTransform(after,id),b=canvasTransform(before.document,id);for(const key of ['x','y','scale','rotation'])near(a[key],b[key]);}
|
|
for(const id of ['c','inside','nested','future','opaque'])assert.equal(after.elements.get(id),before.document.elements.get(id));
|
|
near(after.elements.get('f').data.presentation.unitScale,.4);assert.equal(after.elements.get('f').data.presentation.contentScale,.6);assert.equal(after.elements.get('cross').ownerCanvasId,'target');
|
|
assert.equal(editor.history.undo().status,'applied');assert.deepEqual(editor.getSnapshot().document.elements.get('f').data,before.document.elements.get('f').data);assert.equal(editor.history.redo().status,'applied');nearNode(worldNode(editor.getSnapshot().document,'inside'),worldNode(before.document,'inside'));
|
|
});
|
|
|
|
test('container recovery preserves fresh size, typography, overview, opacity and remote curve edits while changing units',async()=>{
|
|
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'});await engine.settle();const oldFrame=original.elements.get('f'),oldTarget=original.elements.get('g'),remotePath={...path,knots:[{...path.knots[0],position:{x:320,y:170}}]};
|
|
f.remote([{kind:'field',target:target('f'),field:'geometry',before:oldFrame.geometry,after:{...oldFrame.geometry,width:700}},{kind:'field',target:target('f'),field:'data.presentation',before:oldFrame.data.presentation,after:{...oldFrame.data.presentation,contentScale:.9,collapsed:true,future:{keep:'fresh'}}},{kind:'field',target:target('g'),field:'data.presentation',before:oldTarget.data.presentation,after:{contentScale:4}},{kind:'field',target:target('inside'),field:'data.fontSize',before:19,after:27,beforePresent:true,afterPresent:true},{kind:'field',target:target('cross'),field:'path',before:path,after:remotePath}]);
|
|
const fresh=f.server(),session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();const after=f.server();
|
|
for(const id of ['f','inside','nested','future'])nearNode(worldNode(after,id),worldNode(fresh,id));
|
|
near(after.elements.get('f').data.presentation.unitScale,.25);assert.equal(after.elements.get('f').data.presentation.contentScale,.9);assert.deepEqual(after.elements.get('f').data.presentation.future,{keep:'fresh'});assert.equal(after.elements.get('inside').data.fontSize,27);assert.equal(after.elements.get('opaque'),fresh.elements.get('opaque'));
|
|
const {transformPath}=await import('../../src/lib/canvas-core/coordinates.ts'),actual=transformPath(after.elements.get('cross').path,canvasTransform(after,'target'));near(actual.knots[0].position.x,320);near(actual.knots[0].position.y,170);
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.width,700);assert.equal(f.server().elements.get('inside').data.fontSize,27);await engine.close();
|
|
});
|
|
|
|
test('an explicit end position changes the container pose while preserving intrinsic text size and descendant units',()=>{
|
|
const document=scaledTree(),editor=createEditor({registry,document}),source=document.elements.get('f'),destination=canvasTransform(document,'target'),desired=convertGeometry(source.geometry,canvasTransform(document,'root'),destination);
|
|
desired.x+=50;assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'target',geometry:desired}).status,'applied');const after=editor.getSnapshot().document,a=worldNode(document,'inside'),b=worldNode(after,'inside');near(a.width,b.width);near(a.height,b.height);near(a.visualScale,b.visualScale);assert.ok(Math.abs(a.x-b.x)>1);assert.equal(after.elements.get('inside').data.fontSize,19);
|
|
});
|
|
|
|
test('ordinary nodes keep visual style scale across moves, and resize bounds use intrinsic visual units',()=>{
|
|
const document=scaledTree(),editor=createEditor({registry,document}),before=projectCanvas(editor.getSnapshot().document,'root',registry,new Set(),()=>undefined).nodes.find(node=>node.id==='a');
|
|
editor.dispatch({type:'element.move',target:target('a'),canvasId:'target'});const current=editor.getSnapshot().document,after=projectCanvas(current,'root',registry,new Set(),()=>undefined).nodes.find(node=>node.id==='a');
|
|
near(before.data.viewScale,after.data.viewScale);near(before.width,after.width);near(current.elements.get('a').data.presentation.unitScale,.4);
|
|
const definition=defineComponent({id:'box',dataVersion:1,structure:'node',features:[movable(),resizable({min:80,max:400}),rotatable()],validate:data=>typeof data.title==='string'}),custom=createRegistry([...builtinComponents(),definition]);
|
|
const boxes=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('box','root',{componentId:'box'})]),box=createEditor({registry:custom,document:boxes});box.dispatch({type:'element.move',target:target('box'),canvasId:'target'});assert.equal(box.dispatch({type:'element.geometry',target:target('box'),patch:{width:1}}).status,'applied');near(box.getSnapshot().document.elements.get('box').geometry.width,32);
|
|
});
|
|
|
|
test('later presentation edits retain rebased units and fresh properties after a pending move',async()=>{
|
|
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'});await engine.settle();const moved=engine.getSnapshot().document.elements.get('f');engine.editor.dispatch({type:'element.data',target:target('f'),patch:{presentation:{...moved.data.presentation,collapsed:true}}});await engine.settle();
|
|
const g=original.elements.get('g'),frame=original.elements.get('f');f.remote([{kind:'field',target:target('g'),field:'data.presentation',before:g.data.presentation,after:{contentScale:4}},{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:.9,remoteFlag:'keep'}}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();const result=f.server().elements.get('f').data.presentation;near(result.unitScale,.25);assert.equal(result.contentScale,.9);assert.equal(result.remoteFlag,'keep');assert.equal(result.collapsed,true);
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();near(f.server().elements.get('f').data.presentation.unitScale,.25);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').data.presentation.unitScale,undefined);assert.equal(f.server().elements.get('f').data.presentation.remoteFlag,'keep');await engine.close();
|
|
});
|
|
|
|
test('a creation under a changed parent basis retains its intended physical geometry and typography',async()=>{
|
|
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new','child',{data:{title:'New',text:'Body',fontSize:25}})});await engine.settle();const desired=worldNode(engine.getSnapshot().document,'new'),frame=original.elements.get('f');
|
|
f.remote([{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:1.2}}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();nearNode(worldNode(f.server(),'new'),desired);assert.equal(f.server().elements.get('new').data.fontSize,25);near(f.server().elements.get('new').data.presentation.unitScale,.5);await engine.close();
|
|
});
|
|
|
|
test('explicit presentation changes in a move survive unit conversion without dropping fresh unknown properties',async()=>{
|
|
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.move',target:target('f'),canvasId:'target'});const moved=tx.getDocument().elements.get('f');tx.dispatch({type:'element.data',target:target('f'),patch:{presentation:{...moved.data.presentation,contentScale:.8,collapsed:false}}});});await engine.settle();const frame=original.elements.get('f');f.remote([{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:.9,remoteFlag:'keep'}}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').data.presentation.contentScale,.8);assert.equal(f.server().elements.get('f').data.presentation.remoteFlag,'keep');near(f.server().elements.get('f').data.presentation.unitScale,.4);await engine.close();
|
|
});
|
|
|
|
test('unit metadata has a positive finite schema and tiny valid scales do not disappear during normalization',()=>{
|
|
const editor=createEditor({registry,document:base()}),before=editor.getSnapshot();for(const value of [0,-1,Infinity,NaN,'invalid']){assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{presentation:{unitScale:value}}}).status,'rejected');assert.equal(editor.getSnapshot(),before);}
|
|
const next=withUnitScale({unitScale:1e-20,future:{keep:true}},1e-30);assert.equal(unitScale(next),1e-30);assert.deepEqual(next.future,{keep:true});assert.equal(withUnitScale({unitScale:.5},1),undefined);
|
|
});
|
|
|
|
|
|
test('mixed creation history retains its acknowledged prefix when its pending edit is kept or discarded',async()=>{
|
|
for(const keep of [true,false]){
|
|
const f=fixture(),engine=await f.open();f.reject=false;
|
|
engine.editor.dispatch({type:'element.create',element:node('new')},{groupId:'mixed'});await engine.settle();
|
|
f.reject=true;engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'pending'}},{groupId:'mixed'});await engine.settle();
|
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);
|
|
const session=await engine.prepareRecovery(),selected=choices(engine);if(!keep)for(const id of selected.keys())selected.set(id,'discard-local');
|
|
const plan=session.plan(selected);assert.equal(plan.history.undo.length,1);f.reject=false;await session.resolve(plan);await engine.settle();
|
|
assert.equal(f.server().elements.get('new').data.title,keep?'pending':'new');
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('new'),false);assert.equal(f.server().elements.get('a').data.title,'remote');
|
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,keep?'pending':'new');
|
|
await engine.close();
|
|
}
|
|
});
|
|
|
|
test('mixed acknowledged entry creation and pending canvas/member creation stay one undo unit',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})},{groupId:'partition'});await engine.settle();
|
|
f.reject=true;engine.editor.transaction(tx=>{tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});},{groupId:'partition'});await engine.settle();
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.getSnapshot().history.undo.length,1);
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('inside'),false);assert.equal(f.server().elements.has('entry'),false);
|
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('entry').incarnation,2);assert.equal(f.server().elements.get('member').incarnation,2);await engine.close();
|
|
});
|
|
|
|
test('mixed acknowledged edit and pending deletion undo against fresh data without losing the prefix',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'remove'});await engine.settle();
|
|
f.reject=true;engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'remove'});await engine.settle();
|
|
f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote text',beforePresent:true,afterPresent:true}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const restored=f.server().elements.get('a');assert.equal(restored.incarnation,2);assert.equal(restored.data.title,'a');assert.equal(restored.data.text,'remote text');
|
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),false);await engine.close();
|
|
});
|
|
|
|
test('an acknowledged deletion prefix can restore a new lifecycle after recovery of a pending sibling edit',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'mixed-delete'});await engine.settle();
|
|
f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}},{groupId:'mixed-delete'});await engine.settle();
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(f.server().elements.get('b').data.title,'b');
|
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),false);assert.equal(f.server().elements.get('b').data.title,'pending');await engine.close();
|
|
});
|
|
|
|
test('mixed owner movement and a pending edit preserve fresh unrelated fields through undo and redo',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'},{groupId:'move'});await engine.settle();
|
|
f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'move'});await engine.settle();
|
|
f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote',beforePresent:true,afterPresent:true}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();
|
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'root');assert.equal(f.server().elements.get('a').data.text,'remote');
|
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'child');assert.equal(f.server().elements.get('a').data.title,'pending');await engine.close();
|
|
});
|
|
|
|
test('mixed structural history cannot silently delete remote additions or claim a newer lifecycle',async()=>{
|
|
for(const change of ['remote-member','new-life']){
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})},{groupId:'conflict'});await engine.settle();
|
|
f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}},{groupId:'conflict'});await engine.settle();
|
|
const current=f.server().elements.get('entry');if(change==='remote-member')f.remote([{kind:'canvas',id:'remote-canvas',before:null,after:{id:'remote-canvas',parent:{canvasId:'root',entryElementId:'entry'}}},{kind:'element',id:'remote-member',before:null,after:node('remote-member','remote-canvas')}]);
|
|
else f.remote([{kind:'element',id:'entry',before:current,after:null},{kind:'element',id:'entry',before:null,after:{...current,incarnation:2}}]);
|
|
const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
|
}
|
|
});
|
|
|
|
|
|
test('a later confirmed group operation cannot be moved before an earlier rejected structural operation',async()=>{
|
|
const f=fixture(),submit=f.port.submit;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);};
|
|
const engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'out-of-order'});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'confirmed later'}},{groupId:'out-of-order'});await engine.settle();
|
|
assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(pending(engine).length,1);
|
|
const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_nonprefix_ack/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
|
});
|
|
|
|
|
|
test('rejected undo and redo can be explicitly kept or discarded with the correct history stack',async()=>{
|
|
for(const direction of ['undo','redo'])for(const keep of [true,false]){
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();
|
|
if(direction==='redo'){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}
|
|
f.reject=true;assert.equal(engine.editor.history[direction]().status,'applied');await engine.settle();const old=pending(engine)[0].intent;
|
|
f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote text',beforePresent:true,afterPresent:true}]);
|
|
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[old.id,keep?'keep-local':'discard-local']]));f.reject=false;await session.resolve(plan);await engine.settle();
|
|
const undone=direction==='undo'?keep:!keep;assert.equal(f.server().elements.get('a').data.title,undone?'a':'edited');assert.equal(engine.getSnapshot().history.redo.length,undone?1:0);assert.equal(engine.getSnapshot().history.undo.length,undone?0:1);
|
|
assert.equal(engine.getSnapshot().runtime.ledger.operations.get(old.id).resolution,keep?'superseded':'discarded');assert.equal(f.server().elements.get('a').data.text,'remote text');
|
|
assert.equal(engine.editor.history[undone?'redo':'undo']().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,undone?'edited':'a');await engine.close();
|
|
}
|
|
});
|
|
|
|
test('a kept rejected undo uses the reviewed remote value as its redo baseline',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');
|
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');await engine.close();
|
|
});
|
|
|
|
test('a rejected undo of a confirmed partition deletion restores only the proved next lifecycle',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('f')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').incarnation,2);assert.equal(f.server().elements.get('c').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');
|
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('child'),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').incarnation,3);await engine.close();
|
|
});
|
|
|
|
test('a rejected redo after confirmed undo can restore creation with the archived canvas version',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});});await engine.settle();engine.editor.history.undo();await engine.settle();f.reject=true;engine.editor.history.redo();await engine.settle();
|
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').incarnation,2);assert.ok(f.calls.at(-1).expectedVersions.inside>0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('inside'),false);await engine.close();
|
|
});
|
|
|
|
test('multiple rejected undos retain stack order and cannot skip a discarded top traversal',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.undo();await engine.settle();
|
|
const ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map([[ids[0],'discard-local'],[ids[1],'keep-local']])),/recovery_history_traversal_order/);assert.equal(engine.getSnapshot(),before);
|
|
const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.getSnapshot().history.redo.length,2);
|
|
engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'second');await engine.close();
|
|
});
|
|
|
|
test('a failed restoration cannot claim a remotely advanced tombstone and leaves its original history intact',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
|
f.remote([{kind:'element',id:'a',before:null,after:{...node('a'),incarnation:2}},{kind:'element',id:'a',before:{...node('a'),incarnation:2},after:null}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
|
});
|
|
|
|
|
|
test('an already satisfied kept undo retires only its empty contribution with an audit',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();const original=pending(engine)[0].intent;
|
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'a',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.equal(plan.history.entries.size,0);assert.equal(plan.operationIds.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);assert.equal(engine.getSnapshot().runtime.ledger.operations.get(original.id).resolution,'satisfied');await engine.close();
|
|
});
|
|
|
|
test('discarding a rejected traversal never broadens its old inverse over a remote field conflict',async()=>{
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local']))),/recovery_history_traversal_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
|
});
|
|
|
|
test('mixed user and traversal recovery and repeated traversals require additional history evidence',async()=>{
|
|
for(const mode of ['mixed','repeat','orphan']){
|
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;
|
|
if(mode==='mixed')engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}});
|
|
engine.editor.history.undo();if(mode==='repeat')engine.editor.history.redo();
|
|
if(mode==='orphan'){engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'later confirmed'}});}
|
|
await engine.settle();const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
|
}
|
|
});
|