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

637 lines
96 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 结构冲突恢复与历史重建的集成测试。以明确零写入的版本冲突开始,从新的服务端基线重规划保留的意图。
* 覆盖分区树/连线共同归属、世界坐标与样式单位、创建删除的身份代次,以及混合已确认/待处理历史的 undo/redo。
* 恢复预览先生成私有计划,resolve 才提交;重复恢复还需重放 adoption 证据,不能重发已确认内容或凭空移动历史栈。
* fixture 是内存存储和简化服务端,不代表真实浏览器、数据库或完整 Foundry wire 协议测试。
*/
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';
// 使用内置类型定义和共享基础几何;单个测试另建 editor/engine,避免把状态串到其他场景。
const registry=createRegistry(builtinComponents()),geometry={x:100,y:60,width:100,height:80,rotation:0};
// 构造第一代文本元素,extra 可覆盖组件、几何及数据,支持 frame 和未来类型样本。
const node=(id,owner='root',extra={})=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry,...extra});
// 旋转且带 contentScale 的入口,使坐标测试同时涉及平移、旋转和子 canvas 缩放。
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}}});
// 连线用端点引用和 owner 描述所属 canvas;显式 path 与保留字段用来发现重规划时的意外覆盖。
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});
// 建立 root/child 两层文档:f 是 child 的入口,a/b 在 root,c 在 child。
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'}]};
// 简写第一代目标身份;恢复出新一代对象时,相关用例会显式给出新的 incarnation。
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');
// 测试默认对所有待恢复文档操作选择 keep-local;个别用例再覆盖为 discard-local。
// 这是 fixture 的决策辅助器,不是产品自动替用户处理冲突;keep-local 仍需在新基线上重新生成合法变化,不能覆盖整份旧 working。
const choices=engine=>new Map(pending(engine).map(record=>[record.intent.id,'keep-local']));
// 建立独立的 server、版本表、持久化 image 和 leader;默认所有提交明确 not-applied,便于触发可重规划冲突。
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:{
// 同一 fixture 只允许一个活动存储 owner;fence 固定为 1,不模拟多标签竞争或租约续期。
async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,
// 释放内存 owner,让关闭后可以再次打开引擎。
async release(){owner=undefined;}};},
// 返回 image 的克隆,模拟重新打开后从持久层读取,避免复用同一内存对象。
async recover(){return image&&structuredClone(image);},
// 先核对 owner,再克隆并保存整份 image;这里不实现真实事务日志、文件写入或配额。
async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},
// 这些结构测试不读取资源字节,保留空文件端口满足存储契约。
async readFile(){},
// 存储无外部句柄;关闭只需由引擎释放 claim,端口 close 无额外动作。
close(){}},port:{
// 编译单个文档意图;已有 canvas 使用当前基线版本,新 canvas 使用零,payload 直接保存 intent。
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]))};},
// 记录每次尝试;默认明确拒绝且零写入。放行时校验版本、应用变化,并检查请求覆盖所有旧/新 owner。
async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};
// 只有明确放行才解析冻结 body;每个参与 canvas 的 expected version 都必须与假服务端当前值一致。
const {intent}=JSON.parse(request.body);for(const[id,version]of Object.entries(request.expectedVersions))assert.equal(version,versions.get(id)??0);
// 先构造并校验新文档,再检查作用域覆盖;任何检查失败都发生在替换 server 引用之前。
const after=applyChanges(server,intent.changes);validateDocument(after,registry);
// canvas 变化需要该 canvas 参与;元素变化需要旧/新 owner,字段变化则从修改前后文档查询 owner。
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,并为请求内全部参与 canvas 增加版本;简化回执只携带请求 ID。
server=after;for(const id of request.canvasIds)versions.set(id,(versions.get(id)??0)+1);return{outcome:'applied',canonical:{id:request.id}};},
// 本 fixture 的结果只有明确成功或失败;若引擎意外进入未知结果查询,立即报错暴露错误路径。
async query(){throw Error('unexpected query');},
// 成功回执接纳时直接返回当前 server 及版本表;不是从回执反推一份旧文档。
canonical(){return{document:server,versions};},
// 恢复预览读取此刻 server 基线;remote 修改会影响下一次读取。
async read(){return{document:server,versions};}},
// 模拟其他客户端:应用并校验文档,再提高所有已知或新增 canvas 的版本,故意让旧请求失配。
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 给 decodeRecovery 断言,验证实际保存的数据。
image:()=>structuredClone(image),
// 只读获取当前服务端文档,供状态和几何比较;用例不能借此替换 server。
server:()=>server,
// 使用同一作用域及存储打开新引擎;extra 用于测试 historyLimit 等引擎配置。
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;
}
// 浮点几何比较使用 1e-7 容差,避免旋转/缩放往返产生的舍入差异造成误报。
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
// 创建后移动,再收到默认尺寸结果;恢复保留尺寸补偿,但仍只有创建和移动两个撤销单元,redo 创建也使用最终尺寸。
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 } });
// 尺寸结果回写创建历史,但 rootRevision 仍要匹配当前快照,不能套用到过时草稿。
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);
// 只放弃独立尺寸效果,创建与移动的决策继续保持 keep-local。
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(); }
});
// 子 canvas 的尺寸结果已确认,而 root 的独立编辑待恢复;重建历史必须保留尺寸事实,之后仍可撤销创建。
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(); }
});
// 待确认的创建/改名以 absent 基线重新规划;两次 undo/redo 后使用第二代身份,持久化解码也保留该身份。
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();
});
// 入口、canvas、成员作为一个本地事务一起保留或放弃,不留孤儿;新 canvas 首次版本为零,恢复后仍共用一个撤销单元。
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();
}
});
// 删除意图针对新基线重新规划,包含远端新加的关联边;undo 恢复远端新正文及连线,并分配新的元素代次。
// 新 undo 的 before 值来自审阅过的当前服务端内容,而非最初点击删除时的旧对象,否则会丢掉后来新增的正文或引用。
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();
});
// 删除分区以当前子树和跨子 canvas 连线为准,并覆盖相关 canvas 版本;undo 恢复成员身份、入口绑定及端点引用。
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();
});
// 原入口在远端移动/旋转后,保留本地移动的目标世界位置;连线改归共同 canvas 时曲线换坐标,扩展字段保持。
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();
});
// 重连以端点当前归属决定连线 owner,并保留远端标签;端点被删除再恢复成新一代时拒绝绑定旧身份。
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();
}
});
// 放弃创建却保留其改名会缺少目标身份;远端同 ID 创建也不能被冒认成本地对象,计划失败后取消预览。
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();
});
// 核心层独立验证创建/改名的 redo 生命周期,并用 root/child 坐标往返逐字段核对几何。
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]);
});
// 远端把旧 child 改绑到 h、另建 child 绑定 f 后,移动 f 只能带走当前新绑定,不能抢回旧 canvas。
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();
});
// 需变换的未知路径导致命令整体拒绝、快照不变;可处理的容器移动则保持子 canvas 变换且不重写成员。
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);
// 移动入口补偿它的外层变换,因此子 canvas 的世界变换及成员引用都不需要改写。
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'));
});
// reparent 只改变选定父绑定,同时保留新基线 parent 上的未知字段,不能恢复整个旧 envelope。
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();
});
// 创建多层旋转/缩放分区,混入未来节点、未知路径和 presentation 扩展,验证只改必要外层、不改不透明子内容。
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}})]));
}
// 把节点几何换算到单位世界坐标,并把 canvas 缩放与元素 unitScale 相乘,比较实际视觉尺度。
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]);}
// 带缩放容器跨分区移动后,后代世界几何/视觉单位及 canvas 身份保持;可复用成员引用,undo/redo 保留原 presentation。
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'));
});
// 远端同时改尺寸、文字、折叠、缩放和曲线后再恢复移动;以新内容换单位,不覆盖远端改动,undo 也保留它们。
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();
});
// 显式目标几何可移动容器及后代位置,但后代物理尺寸、视觉单位和原 fontSize 不随之缩放。
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);
});
// 普通节点跨缩放分区保持投影宽度与 viewScale;自定义 resize 下限按视觉单位换算到目标 canvas 的局部宽度。
// target 的 contentScale=2.5,视觉最小宽度 80 对应局部宽度 32;不能把局部 32 误判为违反组件的 80 下限。
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);
});
// 移动后继续折叠的本地编辑应合并新基线属性和换算后 unitScale;逐步撤销也不丢远端扩展。
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();
});
// 父 contentScale 在远端改变后,待创建节点仍按原意图保留物理几何及字体,通过 unitScale 补偿新的坐标基准。
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();
});
// 同一事务显式修改 contentScale 的意图优先于远端同字段,但远端未知属性仍保留,移动单位换算照常完成。
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();
});
// unitScale 必须正且有限,非法值拒绝且快照不变;极小合法值不被近似归一,默认单位一可省略。
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);
});
// 同组已确认创建加待恢复改名,不论保留或放弃改名都保留一个创建历史;undo/redo 不影响远端无关字段。
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();
}
});
// 入口先确认、canvas 和成员随后待恢复时,同组历史仍是一个撤销单元,重做给入口及成员新身份。
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();
});
// 同组先确认删除、后恢复兄弟编辑;undo 恢复新一代被删对象并撤销兄弟编辑,redo 重新删除及编辑。
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();
});
// 同组已确认 owner 移动与待恢复改名一起撤销/重做,但远端正文不属于该历史贡献,必须保留。
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();
});
// 已确认结构前缀若出现远端新成员或新 incarnation,不能静默生成会删除外来内容的逆操作;失败保持快照不变。
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();
}
});
// 先拒绝删除 a、再确认同组的 c 编辑;前者保留或放弃都不重发后者,并维持一个可撤销分组及正确身份。
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();
}
});
// 分别对被拒的 undo/redo 选择保留或放弃,核对服务端内容、两栈位置和原操作 superseded/discarded 状态,保留无关远端正文。
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();
}
});
// 保留被拒 undo 时逆操作基于已审阅的远端标题生成;之后 redo 应回到远端值,而非过时的 edited。
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();
});
// 已确认删除分区的 undo 被拒后,只能恢复下一代入口和成员;后续再删再恢复则递增到第三代。
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();
});
// 已确认创建又撤销后,恢复被拒 redo 需沿用归档的 canvas 版本而非误当全新 canvas,成员身份递增。
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();
});
// 连续两个被拒 undo 不能跳过栈顶 traversal 而只保留后一个;全部保留后按正确顺序重建 redo 栈。
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();
});
// 远端已实现所保留 undo 的目标值时,只解决其空贡献并记录 satisfied 审计,不生成新操作或残留空历史。
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();
});
// 放弃被拒 traversal 也不能把旧逆操作扩张到远端冲突值;无法安全重建历史时拒绝而不发布。
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();
});
// 混合编辑、undo/redo 循环及 undo 后新分支分别重放历史证据,检查清空 redo 和后续可撤销行为符合原事件顺序。
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();
}
});
// 先 undo 再 redo 的待恢复链在新远端基线上重建,最终及之后的 redo 都使用审阅过的远端值。
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();
});
// 放弃待恢复 undo 但保留其后的新分支,应留下原编辑加分支两个合法 undo 条目,之后按栈顺序撤销。
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();
});
// 创建、撤销、重做及第二代改名一起恢复后保留身份依赖;再次完整 undo/redo 才进入第三代,不能复用旧身份。
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();
});
// history:false 的编辑携带显式不移动栈的证据;恢复其正文修改时不能把先前 undo 形成的 redo 分支清空。
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();
});
// 后来的新分支已经确认时,仅重放它的历史事件;前序 undo 保留或放弃都不能再次写入分支正文。
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();
}
});
// 分别伪造未来、缺失及未证明的旧式 historyTransition,恢复必须明确拒绝且不能发布到实时状态。
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();
});
// 引擎 historyLimit=2 时,放弃 undo 会多保留旧项;恢复仍按原容量淘汰最早条目,不能临时扩大历史。
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();
});
// 同组第一步可保留、放弃或已满足,再组合第二步保留/放弃,检查分组诞生点、空组清理及 undo 基线重建。
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();
}
});
// 放弃分组后缀时,只剪掉已被远端覆盖的标题历史贡献;仍保留正文的可撤销贡献,undo 不覆盖远端标题。
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();
});
// 历史几何前缀的 owner 已在远端改变时,旧坐标没有安全新基准,必须报告前缀冲突并保持实时快照。
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();
});
// 为需要乱序确认的场景安装简化成功证明:验证请求 ID、递增版本和已创建/修改值,允许只重放历史而不重写内容。
// 该证明辅助器不覆盖元素删除、缺失字段与所有 wire 类型;仅用于下面确认过的 traversal/字段场景。
function proveFixtureReceipt(f){
// 回执解释入口校验请求身份,再依据该请求的期望版本建立各 canvas 的成功版本下界。
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,
// 恢复读取可以更新,但每个版本不能早于回执;本辅助器检查创建对象和字段 after 值。
assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);
// 创建比较完整元素;字段比较区分 data 子字段和元素顶层字段,缺失 data 值按本 fixture 的 null 约定比较。
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);}}}};};}
// 先拒绝一个 traversal、再确认后续 undo/redo:恢复不能跳过前者,后者仅重放历史、不得再次发写请求。
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();
}
});
// 后续已确认 undo 把对象恢复到第二代时,旧历史需映射到新身份,但旧 operations 的第一代证据不能被篡改。
// 历史条目描述下一次可撤销目标,可以随恢复重建;旧操作描述当时已经发生的事实,必须保留原 incarnation,二者不是同一份记录。
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();
});
// 已确认 redo 创建第二代对象后,剩余 redo 的字段目标需随之映射;再完整遍历历史会创建第三代且内容保持。
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();
});
// 远端覆盖导致的历史前缀剪除需作为 adjustment 保存在 adoption 证明中,重放证据应精确得到计划历史并可持久化解码。
// adoption 记录本轮“哪些意图保留、哪些历史贡献剪除”的接纳结果;下一轮需重放这些选择,不能只凭最终两个 stack 猜它们如何形成。
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();
});
// 连续三次恢复同组替代操作,再加入原生编辑;每轮从最新审阅基线重规划,旧冻结请求体不变,undo 回到最新远端值。
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();
// 前两轮继续让新替代请求冲突,第三轮放行,迫使证明链跨过多个 adoption 和新原生编辑。
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();
});
// 先恢复结构创建,再编辑其中成员并再次恢复;跨两次 adoption 后仍能整体撤销/重做结构,并保留远端无关改名。
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();
});
// undo/redo 的替代操作再次恢复时,使用最新远端基线;后续 redo 不能复活第一次恢复时的旧值。
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();
});
// 替代分组前缀已经确认、兄弟项仍冲突时,再次恢复只重规划兄弟项;确认前缀发送一次,undo 使用新远端基线。
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();
});
// 第一次恢复形成的已结算剪枝证据,在第二次放弃替代创建及其 undo 后仍可安全重放;不增加写请求或留下空历史。
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();
});
// 编辑及其 undo 全部放弃,或已确认编辑后的被拒 undo/redo 全部放弃,都不应捏造一次栈移动;两栈按原事实保留。
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();
}
});
// 篡改 adoption 重放证据为缺失、未来版本、错误 anchor 或缺少后续原生转换时,计划拒绝且实时快照不变。
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);
// 只改私有复制的证据表,直接调用规划器;每个非法变体都必须拒绝且不影响运行中的 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();
});
// 给 adoption 操作伪造额外原生 no-op 事件,会重复解释同一操作的历史作用,必须按事件顺序冲突拒绝。
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();
});