Files
Endoleap/tests/headless/structure-recovery.test.mjs
T

501 lines
81 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,describeHistoryTransition } 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 { replayHistoryEvidence,sameHistory } from '../../src/lib/canvas-runtime/history-replay.ts';
import { planConflictRecovery } from '../../src/lib/canvas-runtime/conflicts.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(extra={}){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,...extra});}};return f;
}
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
test('pending default size effects recover with preceding creation and movement, preserving one creation undo unit', async () => {
const f = fixture(), engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized') });
const entryId = engine.getSnapshot().history.undo.at(-1);
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
const root = engine.getSnapshot();
assert.equal(engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision).status, 'applied');
await engine.settle();
const session = await engine.prepareRecovery(), plan = session.plan(choices(engine));
assert.equal(plan.history.undo.length, 2); assert.equal(plan.document.elements.get('sized').geometry.height, 40);
f.reject = false; await session.resolve(plan); await engine.settle(); decodeRecovery(f.image(), registry);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(f.server().elements.get('sized').geometry.x, 100); assert.equal(f.server().elements.get('sized').geometry.height, 40);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
assert.equal(engine.editor.history.redo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.get('sized').geometry.height, 40);
} finally { await engine.close(); }
});
test('discarding a pending size effect retains the uncorrected creation and movement histories', async () => {
const f = fixture(), engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized') }); const entryId = engine.getSnapshot().history.undo.at(-1);
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
const root = engine.getSnapshot(), result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision);
await engine.settle(); const selected = choices(engine); selected.set(result.operationIds[0], 'discard-local');
const session = await engine.prepareRecovery(), plan = session.plan(selected);
assert.equal(plan.document.elements.get('sized').geometry.height, 80); assert.equal(plan.history.undo.length, 2);
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('sized').geometry.height, 80);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
} finally { await engine.close(); }
});
test('a confirmed size effect is retained while an earlier independent pending edit is recovered', async () => {
const f = fixture(); f.reject = false; const engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized', 'child') }); await engine.settle();
const entryId = engine.getSnapshot().history.undo.at(-1); f.reject = true;
engine.editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'Pending root title' } }); await engine.settle();
f.reject = false; const root = engine.getSnapshot();
const result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision);
await engine.settle(); assert.equal(engine.getSnapshot().runtime.ledger.operations.get(result.operationIds[0]).resolution, 'applied');
const session = await engine.prepareRecovery(), plan = session.plan(choices(engine));
await session.resolve(plan); await engine.settle(); decodeRecovery(f.image(), registry);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
} finally { await engine.close(); }
});
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 retains its fact while a rejected structural predecessor is kept or discarded',async()=>{
for(const keep of [true,false]){
const f=fixture(),submit=f.port.submit;let deny=true;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=deny&&intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);};
const engine=await f.open(),remove=engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'out-of-order'});await engine.settle();const confirmed=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(),plan=session.plan(new Map([[remove.operationIds[0],keep?'keep-local':'discard-local']]));assert.equal(plan.history.undo.length,1);assert.ok(plan.history.entries.values().next().value.operationIds.includes(confirmed.operationIds[0]));deny=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.has('a'),!keep);assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.changes.some(change=>change.kind==='field'&&change.target.id==='c')).length,1);
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').incarnation,keep?2:1);assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),!keep);assert.equal(f.server().elements.get('c').data.title,'confirmed later');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('history evidence recovers mixed edits, repeated traversal and a cleared redo branch',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:'branch'}});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('a').data.title,mode==='orphan'?'a':'edited');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(f.server().elements.get('c').data.title,mode==='orphan'?'branch':'c');
if(mode==='mixed'){assert.equal(engine.getSnapshot().history.redo.length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('b').data.title,'pending');}
else if(mode==='repeat'){assert.equal(engine.getSnapshot().history.redo.length,0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');}
else{assert.equal(engine.getSnapshot().history.redo.length,0);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');}
await engine.close();
}
});
test('repeated undo-redo uses the reviewed remote baseline instead of resurrecting the old after value',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();engine.editor.history.redo();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,'remote');assert.equal(engine.editor.history.undo().status,'applied');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('discarding a pending undo then keeping a new branch preserves the original edit and a legal two-entry undo stack',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;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');assert.equal(f.server().elements.get('b').data.title,'branch');assert.equal(engine.getSnapshot().history.undo.length,2);
engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');await engine.close();
});
test('pending create-undo-redo and later edits preserve generations and all history dependencies',async()=>{
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});engine.editor.history.undo();engine.editor.history.redo();engine.editor.dispatch({type:'element.data',target:{id:'new',incarnation:2},patch:{title:'edited second life'}});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('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,'edited second life');
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,3);assert.equal(f.server().elements.get('new').data.title,'edited second life');await engine.close();
});
test('history-disabled user edits carry explicit no-op evidence and do not clear a recovered redo stack',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();const change=engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'untracked'}},{history:false});await engine.settle();assert.equal(engine.getSnapshot().operations.get(change.operationIds[0]).historyTransition.entryId,undefined);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('b').data.title,'untracked');assert.equal(engine.getSnapshot().history.redo.length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');await engine.close();
});
test('a later confirmed branch is replayed as a history event without applying its content twice',async()=>{
for(const keep of [true,false]){
const f=fixture(),engine=await f.open();f.port.verifyApplied=(request,receipt)=>{assert.equal(receipt.id,request.id);const versions=new Map(request.canvasIds.map(id=>[id,request.expectedVersions[id]+1]));return{versions,assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);assert.equal(proposal.document.elements.get('c').data.title,'confirmed branch');}};};
f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();await engine.settle();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'confirmed branch'}});await engine.settle();
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],keep?'keep-local':'discard-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,keep?'a':'edited');assert.equal(f.server().elements.get('c').data.title,'confirmed branch');assert.equal(engine.getSnapshot().history.undo.length,keep?1:2);assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.changes.some(change=>change.kind==='field'&&change.target.id==='c')).length,1);
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');await engine.close();
}
});
test('native event recovery refuses future, missing or unproved legacy transitions without publishing',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();engine.editor.history.redo();await engine.settle();const source=engine.getSnapshot(),canonical=await f.port.read(),selected=choices(engine);
for(const mode of ['future','missing','legacy']){
const operations=new Map(source.operations),records=new Map(source.runtime.ledger.operations);for(const[id,record]of records){if(!selected.has(id))continue;const intent={...record.intent};if(mode==='future')intent.historyTransition={version:2,opaque:'keep'};else if(mode==='legacy'||id===[...selected.keys()].at(-1))delete intent.historyTransition;operations.set(id,{...operations.get(id),historyTransition:intent.historyTransition});records.set(id,{...record,intent});}
const snapshot={...source,operations,runtime:{...source.runtime,ledger:{...source.runtime.ledger,operations:records}}};assert.throws(()=>planConflictRecovery(snapshot,canonical,selected,registry,()=>crypto.randomUUID()),mode==='future'?/history_transition_interpreter_unavailable/:/recovery_history/);assert.equal(engine.getSnapshot(),source);
}
await engine.close();
});
test('history event replay uses the engine capacity when discarding undo retains an extra prior entry',async()=>{
const f=fixture(),engine=await f.open({historyLimit:2});f.reject=false;const first=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;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);assert.equal(plan.history.undo.length,2);assert.equal(plan.history.entries.has(first.historyEntryId),false);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.editor.history.undo().code,'history_empty');await engine.close();
});
test('grouped user edits rebuild after discarding or satisfying the original entry birth',async()=>{
for(const firstChoice of ['keep-local','discard-local','satisfied'])for(const secondKeep of [true,false]){
const f=fixture(),engine=await f.open(),first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'typing'});await engine.settle();const second=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'typing'});await engine.settle();if(firstChoice==='satisfied')f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'one',beforePresent:true,afterPresent:true}]);
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],firstChoice==='discard-local'?'discard-local':'keep-local'],[second.operationIds[0],secondKeep?'keep-local':'discard-local']]));f.reject=false;await session.resolve(plan);await engine.settle();const retained=secondKeep||firstChoice==='keep-local';assert.equal(engine.getSnapshot().history.undo.length,retained?1:0);assert.equal(f.server().elements.get('a').data.title,secondKeep?'two':firstChoice==='discard-local'?'a':'one');
if(retained){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,firstChoice==='satisfied'?'one':'a');}await engine.close();
}
});
test('discarding a group suffix removes only the acknowledged field contribution already overwritten remotely',async()=>{
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack',text:'keep history'}},{groupId:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]);
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');assert.equal(f.server().elements.get('a').data.text,'');await engine.close();
});
test('a historical geometry prefix cannot silently use coordinates from an externally changed owner',async()=>{
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.geometry',target:target('a'),patch:{width:200}},{groupId:'geometry'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'geometry'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'ownerCanvasId',before:'root',after:'child'}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_prefix_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
});
function proveFixtureReceipt(f){f.port.verifyApplied=(request,receipt)=>{assert.equal(receipt.id,request.id);const intent=JSON.parse(request.body).intent,versions=new Map(request.canvasIds.map(id=>[id,request.expectedVersions[id]+1]));return{versions,assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);for(const change of intent.changes){if(change.kind==='element'&&change.after)assert.deepEqual(proposal.document.elements.get(change.id),change.after);else if(change.kind==='field'){const record=proposal.document.elements.get(change.target.id),field=change.field.startsWith('data.')?change.field.slice(5):undefined;assert.deepEqual(field===undefined?record[change.field]:record.data[field]??null,change.after);}}}};};}
test('a later confirmed undo or redo changes only history during recovery and cannot skip a discarded earlier traversal',async()=>{
for(const direction of ['undo','redo']){
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited a'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'edited c'}});await engine.settle();
if(direction==='redo'){engine.editor.history.undo();await engine.settle();engine.editor.history.undo();await engine.settle();}
f.reject=true;const first=engine.editor.history[direction]();await engine.settle();f.reject=false;const confirmed=engine.editor.history[direction]();await engine.settle();const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map([[first.operationIds[0],'discard-local']])),/recovery_history_traversal_order/);assert.equal(engine.getSnapshot(),before);
const plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,direction==='undo'?'a':'edited a');assert.equal(f.server().elements.get('c').data.title,direction==='undo'?'c':'edited c');assert.equal(engine.getSnapshot().history[direction==='undo'?'redo':'undo'].length,2);const targetId=direction==='undo'?'a':'c';assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.getSnapshot().runtime.ledger.operations.get(confirmed.operationIds[0]).resolution,'applied');
assert.ok([...engine.getSnapshot().history.entries.values()].some(entry=>entry.operationIds.includes(confirmed.operationIds[0])));for(let i=0;i<2;i++){assert.equal(engine.editor.history[direction==='undo'?'redo':'undo']().status,'applied');await engine.settle();}assert.equal(f.server().elements.get(targetId).data.title,direction==='undo'?'edited a':'c');await engine.close();
}
});
test('a later confirmed undo restoration remaps older history without changing old operation incarnations',async()=>{
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;const old=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'edited c'}});await engine.settle();f.reject=true;const pendingUndo=engine.editor.history.undo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('a').incarnation,2);
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[pendingUndo.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().operations.get(old.operationIds[0]).changes[0].target.incarnation,1);assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');await engine.close();
});
test('a later confirmed redo restoration remaps a remaining redo entry into its applied lifecycle',async()=>{
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited a'}});await engine.settle();engine.editor.dispatch({type:'element.create',element:node('new','child')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited new'}});await engine.settle();for(let i=0;i<3;i++){engine.editor.history.undo();await engine.settle();}
f.reject=true;const first=engine.editor.history.redo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('new').data.title,'edited new');
for(let i=0;i<3;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}for(let i=0;i<3;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('new').incarnation,3);assert.equal(f.server().elements.get('new').data.title,'edited new');await engine.close();
});
test('settled history adjustments in an adoption proof preserve the explicit removal of a remote-overwritten 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:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]);const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));assert.equal(plan.history.entries.size,0);assert.ok(plan.audit.historyReplay.events.some(event=>event.kind==='adjustment'));assert.ok(sameHistory(replayHistoryEvidence(before.history,plan.audit.historyReplay),plan.history));await session.resolve(plan);assert.equal(decodeRecovery(f.image(),registry).history.entries.size,0);await engine.close();
});
test('repeated adoption re-plans replacement groups plus a later native edit from the newest reviewed baseline',async()=>{
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'chain'});engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'chain'});await engine.settle();const bodies=new Map();
for(let round=0;round<3;round++){
if(round)f.remote([{kind:'field',target:target('a'),field:'data.title',before:round===1?'a':'remote 1',after:'remote '+round,beforePresent:true,afterPresent:true}]);
if(round===2){engine.editor.dispatch({type:'element.data',target:target('a'),patch:{text:'native after adoption'}},{groupId:'chain'});await engine.settle();}
const session=await engine.prepareRecovery(),selected=choices(engine);if(round===1)selected.set([...selected.keys()][0],'discard-local');const plan=session.plan(selected);if(round===2)f.reject=false;await session.resolve(plan);await engine.settle();for(const[id,request]of engine.getSnapshot().runtime.ledger.requests){if(bodies.has(id))assert.equal(request.body,bodies.get(id));else bodies.set(id,request.body);}assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,round+1);
}
assert.equal(f.server().elements.get('a').data.title,'two');assert.equal(f.server().elements.get('a').data.text,'native after adoption');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote 2');assert.equal(f.server().elements.get('a').data.text,'');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'two');await engine.close();
});
test('a structural replacement and later native member edit recover across adoption with lifecycle-safe undo/redo',async()=>{
const f=fixture(),engine=await f.open();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();let session=await engine.prepareRecovery();await session.resolve(session.plan(choices(engine)));await engine.settle();engine.editor.dispatch({type:'element.data',target:target('member'),patch:{title:'edited after adoption'}});await engine.settle();f.remote([{kind:'field',target:target('b'),field:'data.title',before:'b',after:'remote b',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').data.title,'edited after adoption');for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().canvases.has('inside'),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('member').incarnation,2);assert.equal(f.server().elements.get('member').data.title,'edited after adoption');assert.equal(f.server().elements.get('b').data.title,'remote b');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,2);await engine.close();
});
test('undo-redo replacements recover across an adoption without resurrecting a stale remote value',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();engine.editor.history.redo();await engine.settle();let session=await engine.prepareRecovery();await session.resolve(session.plan(choices(engine)));await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'reviewed remote',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();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,'reviewed remote');assert.equal(engine.editor.history.undo().status,'applied');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,'reviewed remote');await engine.close();
});
test('an applied replacement prefix is reconstructed from the adoption once while its sibling is replanned',async()=>{
const f=fixture(),submit=f.port.submit;let mode='all';f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=mode==='all'||mode==='child'&&intent.changes.some(change=>change.kind==='field'&&change.target.id==='c');return submit(request);};const engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}},{groupId:'shared'});engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'second'}},{groupId:'shared'});await engine.settle();let session=await engine.prepareRecovery(),firstPlan=session.plan(choices(engine));mode='child';await session.resolve(firstPlan);await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.operations.get(firstPlan.operationIds[0]).resolution,'applied');f.remote([{kind:'field',target:target('c'),field:'data.title',before:'c',after:'remote c',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const secondPlan=session.plan(choices(engine));mode='none';await session.resolve(secondPlan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===firstPlan.operationIds[0]).length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('c').data.title,'remote c');await engine.close();
});
test('settled pruning is replayed safely when earlier replacement creation and its undo are both discarded',async()=>{
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'a'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'b pending'}});engine.editor.history.undo();const suffix=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'a pending'}},{groupId:'a'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote a',beforePresent:true,afterPresent:true}]);let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(suffix.operationIds[0],'discard-local');const first=session.plan(selected);assert.ok(first.audit.historyReplay.events.some(event=>event.kind==='adjustment'));await session.resolve(first);await engine.settle();const count=f.calls.length;session=await engine.prepareRecovery();const plan=session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local'])));await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,count);assert.equal(engine.getSnapshot().history.entries.size,0);assert.equal(f.server().elements.get('a').data.title,'remote a');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,2);await engine.close();
});
test('discarding both an edit and its undo, or both a rejected undo and redo, does not invent a stack move',async()=>{
for(const confirmed of [false,true]){
const f=fixture(),engine=await f.open();if(confirmed)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();if(confirmed)engine.editor.history.redo();await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local'])));await session.resolve(plan);assert.equal(engine.getSnapshot().history.redo.length,0);assert.equal(engine.getSnapshot().history.undo.length,confirmed?1:0);assert.equal(f.server().elements.get('a').data.title,confirmed?'edited':'a');await engine.close();
}
});
test('adoption replay refuses missing, future, incomplete or divergent evidence without changing live state',async()=>{
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}});await engine.settle();const first=await engine.prepareRecovery(),plan=first.plan(choices(engine));await first.resolve(plan);await engine.settle();const native=engine.editor.history.undo();await engine.settle();const original=engine.getSnapshot(),canonical=await f.port.read(),selected=choices(engine);
for(const mode of ['missing','future','macro-future','anchor','native-missing']){
const audit=structuredClone(original.runtime.recoveries.get(plan.id)),recoveries=new Map(original.runtime.recoveries),operations=new Map(original.operations),records=new Map(original.runtime.ledger.operations);
if(mode==='missing')delete audit.historyReplay;else if(mode==='future')audit.historyReplay={version:2,opaque:'keep'};else if(mode==='macro-future')audit.historyTransition={version:2,opaque:'keep'};else if(mode==='anchor')audit.historyReplay.anchor.before.undo.push('unrelated');
else{const id=native.operationIds[0],intent={...records.get(id).intent};delete intent.historyTransition;records.set(id,{...records.get(id),intent});const core={...operations.get(id)};delete core.historyTransition;operations.set(id,core);}
recoveries.set(plan.id,audit);const snapshot={...original,operations,runtime:{...original.runtime,recoveries,ledger:{...original.runtime.ledger,operations:records}}};assert.throws(()=>planConflictRecovery(snapshot,canonical,selected,registry,()=>crypto.randomUUID()),mode==='future'?/history_replay_interpreter_unavailable/:mode==='macro-future'?/history_transition_interpreter_unavailable/:/recovery_history/);assert.equal(engine.getSnapshot(),original);
}
await engine.close();
});
test('an adoption operation cannot also masquerade as a later native no-op event',async()=>{
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{history:false});await engine.settle();const first=await engine.prepareRecovery(),plan=first.plan(choices(engine));await first.resolve(plan);await engine.settle();const source=engine.getSnapshot(),id=plan.operationIds[0],transition=describeHistoryTransition(source.history,source.history,source.rootRevision,undefined,true),operations=new Map(source.operations),records=new Map(source.runtime.ledger.operations),intent={...records.get(id).intent,historyTransition:transition};operations.set(id,{...operations.get(id),historyTransition:transition});records.set(id,{...records.get(id),intent});const forged={...source,operations,runtime:{...source.runtime,ledger:{...source.runtime.ledger,operations:records}}},awaitedCanonical=await f.port.read();assert.throws(()=>planConflictRecovery(forged,awaitedCanonical,choices(engine),registry,()=>crypto.randomUUID()),/recovery_history_event_order/);
await engine.close();
});