Files
Endoleap/tests/headless/history-transition.test.mjs

163 lines
22 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.
/**
* 历史栈变更证据的回放与恢复测试。验证每个transition可正反应用、只存增量范围,并在本地恢复/冲突方案中保留可验证顺序。
* 内存storage及固定拒绝的persistence制造可重复的冲突环境,不代表真实磁盘崩溃或服务端集成验证。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createEditor,createDocument,createRegistry,builtinComponents,applyHistoryTransition,validateHistoryTransition,historyTransitionChanges,describeHistoryTransition,readMap} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
import {replayHistoryEvidence,sameHistory,validateHistoryReplay} from '../../src/lib/canvas-runtime/history-replay.ts';
import {resourcePins} from '../../src/lib/canvas-runtime/resources.ts';
// 共用只读registry和原始文档,每个editor/engine另建状态,避免测试相互继承历史。
const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'root',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument('project',[{id:'root',parent:null}],[node]);
/** 历史转成可深比较的栈与排序entries数组,忽略Map迭代次序差异,保留条目实际内容。 */
const value=h=>({undo:h.undo,redo:h.redo,entries:[...h.entries].sort(([a],[b])=>a.localeCompare(b))});
/** 对同一第一生命周期节点改正文,settings控制分组或不记历史。 */
const edit=(editor,text,settings)=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text}},settings);
// 同组输入、undo/redo、切分支及容量淘汰逐项记证据,再按反序退空、正序恢复最终历史。
test('history transition evidence exactly rewinds and replays typing, undo/redo, redo invalidation and capacity eviction',()=>{
const editor=createEditor({registry,document,historyLimit:2}),events=[];
/** 每次动作捕获前后历史,检查transition版本/修订/entry身份,并双向重放;events随后用于整段回卷。 */
function run(action){const before=editor.getSnapshot(),result=action();assert.equal(result.status,'applied');const after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]),transition=intent.historyTransition;assert.equal(transition.version,1);assert.equal(transition.rootRevision,after.rootRevision);assert.equal(transition.entryId,result.historyEntryId);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));events.push(transition);}
run(()=>edit(editor,'one',{groupId:'typing'}));run(()=>edit(editor,'two',{groupId:'typing'}));run(()=>editor.history.undo());run(()=>editor.history.redo());run(()=>editor.history.undo());run(()=>edit(editor,'branch'));run(()=>edit(editor,'third'));run(()=>edit(editor,'fourth'));
let history=editor.getSnapshot().history;for(const event of [...events].reverse())history=applyHistoryTransition(history,event,'reverse');assert.equal(history.entries.size,0);for(const event of events)history=applyHistoryTransition(history,event,'forward');assert.deepEqual(value(history),value(editor.getSnapshot().history));
});
// 同组80次编辑后检查最后transition偏移79且只追加一项,防止每次都复制全部累计历史。
test('typing transitions append only new ranges rather than embedding the accumulated entry',()=>{
const editor=createEditor({registry,document});for(let i=0;i<80;i++)edit(editor,'text '+i,{groupId:'typing'});const intent=[...editor.getSnapshot().operations.values()].at(-1),patch=intent.historyTransition.entries[0];assert.equal(patch.changes.offset,79);assert.equal(patch.changes.before.length,0);assert.equal(patch.changes.after.length,1);assert.equal(patch.operationIds.after.length,1);assert.equal(historyTransitionChanges(intent.historyTransition).length,1);
});
// 篡改栈/条目内容制造回放冲突,非法偏移或条目ID也拒绝;未来解释版本走明确不可解释错误。
test('transition replay is immutable and rejects changed stack or changed replaced range',()=>{
const editor=createEditor({registry,document}),result=edit(editor,'one'),snapshot=editor.getSnapshot(),transition=snapshot.operations.get(result.operationIds[0]).historyTransition,entry=snapshot.history.entries.values().next().value;
assert.throws(()=>applyHistoryTransition({...snapshot.history,undo:[]},transition,'reverse'),/history_transition_conflict/);
const changed={...entry,changes:[{...entry.changes[0],after:'foreign'}]},history={...snapshot.history,entries:readMap(new Map([[entry.id,changed]]))};assert.throws(()=>applyHistoryTransition(history,transition,'reverse'),/history_transition_conflict/);assert.equal(snapshot.history.entries.get(entry.id),entry);
const invalid=structuredClone(transition);invalid.entries[0].changes.offset=999;assert.throws(()=>validateHistoryTransition(invalid),/invalid_history_transition/);const reserved=structuredClone(transition);reserved.entries[0].after.id='foreign';assert.throws(()=>validateHistoryTransition(reserved),/invalid_history_transition/);assert.throws(()=>applyHistoryTransition(snapshot.history,{...transition,version:2},'reverse'),/history_transition_interpreter_unavailable/);
});
// 传输准备层伪造空entries不得通过,editor根保持原对象。
test('prepareIntent cannot substitute forged local history evidence',()=>{
const editor=createEditor({registry,document,prepareIntent:intent=>({...intent,historyTransition:{...intent.historyTransition,entries:[]}})}),before=editor.getSnapshot();assert.equal(edit(editor,'forged').code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
});
/** 内存镜像clone隔离,提交只核对leader;服务端固定not-applied/version_conflict,read返回原始基准。
* 返回image读取器用于破坏副本测试decodeRecovery,调用方finally关闭engine。 */
async function fixture(){
let image,leader;const storage={async claim(scope){return leader={scope,active:true,fence:1,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},close(){}};
const persistence={compile(intent){return{payload:{changes:intent.changes},canvasIds:intent.canvasIds,expectedVersions:{root:1}};},async submit(){return{outcome:'not-applied',error:'version_conflict'};},async query(){throw Error('unexpected');},async read(){return{document,versions:new Map([['root',1]])};}};
const engine=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document,versions:new Map([['root',1]]),storage,persistence});return{engine,image:()=>structuredClone(image)};
}
// 从恢复镜像反推分支前历史;破坏操作证据或修订被判损坏,未来版本需两份同步保留且使资源扫描不完整。
test('durable transition metadata survives orphaned redo entries, validates both copies and keeps future versions opaque',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first');await engine.settle();engine.editor.history.undo();await engine.settle();const before=engine.getSnapshot().history;edit(engine.editor,'branch');await engine.settle();const recovered=decodeRecovery(f.image(),registry),last=[...recovered.operations.values()].at(-1);assert.deepEqual(value(applyHistoryTransition(recovered.history,last.historyTransition,'reverse')),value(before));
const bad=f.image(),id=last.id;bad.payload.operations.find(([key])=>key===id)[1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
const inconsistent=f.image();inconsistent.payload.operations.find(([key])=>key===id)[1].historyTransition.rootRevision++;assert.throws(()=>decodeRecovery(inconsistent,registry),/corrupt_recovery_image/);
const future=f.image();for(const[key,intent]of future.payload.operations)if(key===id)intent.historyTransition={version:2,preserved:{opaque:true}};for(const[key,record]of future.payload.runtime.ledger.operations)if(key===id)record.intent.historyTransition={version:2,preserved:{opaque:true}};const next=decodeRecovery(future,registry);assert.deepEqual(next.operations.get(id).historyTransition,{version:2,preserved:{opaque:true}});assert.equal(resourcePins(next,next.runtime.ledger,registry,new Map()).complete,false);
}finally{await engine.close();}
});
// 用不会联网的transport只测试compile,服务操作仅有changes/id,不发送本地historyTransition。
test('local transition evidence does not enter the protocol 2 service payload',()=>{
const root='10000000-0000-4000-8000-000000000001',project=createDocument(root,[{id:root,parent:null}],[{...node,ownerCanvasId:root}]),editor=createEditor({registry,document:project}),result=edit(editor,'new'),intent=editor.getSnapshot().operations.get(result.operationIds[0]);const port=createFoundryPersistence({workspaceId:'10000000-0000-4000-8000-000000000002',rootId:root,registry,transport:async()=>{throw Error('unexpected network');}}),request=port.compile(intent,'10000000-0000-4000-8000-000000000003',{...editor.getSnapshot(),runtime:{versions:new Map([[root,1]])}});assert.deepEqual(Object.keys(request.payload.operations[0]).sort(),['changes','id']);assert.equal(JSON.stringify(request.payload).includes('historyTransition'),false);
});
// 图片已从文档和现有history移除,早期undo未解决时仍由后续transition pin资源;全部确认后才解除。
test('cleared redo resource pins survive a later confirmed branch while an earlier text undo is unresolved',()=>{
const editor=createEditor({registry,document});edit(editor,'edited');editor.dispatch({type:'element.create',element:{...node,id:'image',componentId:'image',data:{title:'Image',text:'',src:'https://assets.example/image',assetId:'asset'}}});editor.history.undo();const pending=editor.history.undo();edit(editor,'branch');const snapshot=editor.getSnapshot(),last=[...snapshot.operations.values()].at(-1),operations=new Map([...snapshot.operations].map(([id,intent])=>[id,{intent,phase:id===pending.operationIds[0]?'pending':'resolved',blockers:[],basePresence:{}}]));
assert.equal(snapshot.document.elements.has('image'),false);assert.equal([...snapshot.history.entries.values()].some(entry=>entry.changes.some(change=>change.kind==='element'&&change.id==='image')),false);
const pins=resourcePins(snapshot,{operations},registry,new Map());assert.deepEqual([...pins.pins.get('asset')],[`history-transition:${last.id}`]);operations.set(pending.operationIds[0],{...operations.get(pending.operationIds[0]),phase:'resolved'});assert.equal(resourcePins(snapshot,{operations},registry,new Map()).pins.has('asset'),false);
});
// keep-local方案带采用证据,新replacement操作不继承旧transition;保存后解码保留证据,篡改范围被拒绝。
test('conflict recovery records one adoption transition and never copies stale evidence to replacement requests',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'pending');await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));assert.ok(plan.audit.historyTransition);assert.deepEqual(value(applyHistoryTransition(plan.history,plan.audit.historyTransition,'reverse')),value(before.history));assert.equal(plan.operations.get(plan.operationIds[0]).historyTransition,undefined);await session.resolve(plan);await engine.settle();const recovered=decodeRecovery(f.image(),registry);assert.deepEqual(recovered.runtime.recoveries.get(plan.id).historyTransition,plan.audit.historyTransition);
const bad=f.image();bad.payload.runtime.recoveries[0][1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
}finally{await engine.close();}
});
// 改label时保留未来嵌套元数据,反向应用仍精确恢复before。
test('transition patches preserve opaque history-entry metadata alongside changed fields',()=>{
const editor=createEditor({registry,document});edit(editor,'one');const history=editor.getSnapshot().history,id=history.undo[0],original={...history.entries.get(id),future:{nested:{keep:true}}},before={...history,entries:new Map([[id,original]])},after={...history,entries:new Map([[id,{...original,label:'New label'}]])},transition=describeHistoryTransition(before,after,10);
const result=applyHistoryTransition(before,transition,'forward');assert.deepEqual(result.entries.get(id).future,{nested:{keep:true}});assert.deepEqual(value(applyHistoryTransition(result,transition,'reverse')),value(before));
});
// 创建、改名、删除再恢复导致incarnation=2,证据须覆盖多个旧条目并能双向还原映射。
test('restoration evidence reverses incarnation remapping across earlier history entries',()=>{
const editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:{...node,id:'new'}});editor.dispatch({type:'element.data',target:{id:'new',incarnation:1},patch:{title:'edited'}});editor.dispatch({type:'element.remove',target:{id:'new',incarnation:1}});const before=editor.getSnapshot(),result=editor.history.undo(),after=editor.getSnapshot(),transition=after.operations.get(result.operationIds[0]).historyTransition;
assert.equal(after.document.elements.get('new').incarnation,2);assert.ok(transition.entries.length>=3);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));
});
// 分别测几何与同组文本:采用锚点+replacement序列重放应等于plan.history;缺事件/缺操作拒绝,未来版本保留opaque。
test('adoption evidence composes the anchor and replacement events into the exact planned history and survives decoding',async()=>{
for(const geometry of [false,true]){
const f=await fixture(),engine=f.engine;try{if(geometry)engine.editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});else edit(engine.editor,'first',{groupId:'typing'});await engine.settle();if(!geometry)edit(engine.editor,'second',{groupId:'typing'});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),selected=new Map([...before.runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local'])),plan=session.plan(selected),proof=plan.audit.historyReplay;assert.ok(proof);validateHistoryReplay(proof,before.rootRevision+1);assert.ok(sameHistory(replayHistoryEvidence(before.history,proof,id=>plan.operations.has(id)),plan.history));assert.deepEqual(proof.events.filter(event=>event.kind==='operation').map(event=>event.operationId),plan.operationIds);await session.resolve(plan);await engine.settle();const image=f.image(),decoded=decodeRecovery(image,registry);assert.deepEqual(decoded.runtime.recoveries.get(plan.id).historyReplay,proof);
const bad=f.image();bad.payload.runtime.recoveries[0][1].historyReplay.events.pop();assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
const missing=f.image();missing.payload.runtime.recoveries[0][1].historyReplay.events[0].operationId='missing';assert.throws(()=>decodeRecovery(missing,registry),/corrupt_recovery_image/);
const future=f.image();future.payload.runtime.recoveries[0][1].historyReplay={version:2,opaque:{keep:true}};assert.deepEqual(decodeRecovery(future,registry).runtime.recoveries.get(plan.id).historyReplay,{version:2,opaque:{keep:true}});
}finally{await engine.close();}
}
});
// 几何不是可选data字段,undo/redo不得额外引入presence标记;证据JSON往返及正向回放仍一致。
test('geometry undo and redo keep optional presence markers absent and history transitions serializable',()=>{
const editor=createEditor({registry,document});editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});for(const direction of ['undo','redo']){const before=editor.getSnapshot(),result=editor.history[direction](),after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]);assert.equal(Object.hasOwn(intent.changes[0],'beforePresent'),false);assert.equal(Object.hasOwn(intent.changes[0],'afterPresent'),false);assert.deepEqual(JSON.parse(JSON.stringify(intent.historyTransition)),intent.historyTransition);assert.ok(sameHistory(applyHistoryTransition(before.history,intent.historyTransition,'forward'),after.history));}
});
// history=false让最终栈为空,仍必须有replacement事件;删掉证据后恢复解码拒绝,不能只凭栈相等接受。
test('history-disabled replacement evidence cannot be omitted merely because the final stacks are unchanged',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'untracked',{history:false});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.history.entries.length,0);assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,1);image.payload.runtime.recoveries[0][1].historyReplay.events=[];assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
});
// 两次不记历史操作的证据仍有顺序,反转events必须判损坏。
test('adoption evidence preserves replacement sequence even when all history transitions are no-ops',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first',{history:false});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}},{history:false});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map([...engine.getSnapshot().runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,2);image.payload.runtime.recoveries[0][1].historyReplay.events.reverse();assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
});
// 内部不可变transition复用冻结范围数组,但调用方validator仍逐条执行并可拒绝当前schema。
test('resource scans reuse validated immutable transition ranges without skipping caller validation',()=>{
const editor=createEditor({registry,document});edit(editor,'cached');
const transition=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
const first=historyTransitionChanges(transition),second=historyTransitionChanges(transition);
assert.equal(first,second);assert.ok(Object.isFrozen(first));assert.throws(()=>first.push(first[0]),TypeError);
let checked=0;validateHistoryTransition(transition,()=>{checked++;});assert.equal(checked,2*transition.entries.length);
assert.throws(()=>validateHistoryTransition(transition,()=>{throw Error('current schema rejected');}),/current schema rejected/);
});
// 改变可变/浅冻结/getter输入后扫描须读新值,非法偏移须拒绝,不能套用内部证明缓存。
test('mutable, shallow frozen and accessor transition evidence remains live across resource scans',()=>{
const editor=createEditor({registry,document});edit(editor,'source');
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
const mutable=structuredClone(original),shallow=Object.freeze(structuredClone(original));
for(const transition of [mutable,shallow]){
const first=historyTransitionChanges(transition);transition.entries[0].changes.after[0]={...first[0],after:'changed'};
const second=historyTransitionChanges(transition);assert.notEqual(first,second);assert.equal(second[0].after,'changed');
transition.entries[0].changes.offset=999;assert.throws(()=>historyTransitionChanges(transition),/invalid_history_transition/);
}
let current=structuredClone(original).entries;
const accessor=Object.freeze({...original,get entries(){return current;}});
const first=historyTransitionChanges(accessor);current=structuredClone(original).entries;current[0].changes.after[0]={...first[0],after:'getter changed'};
assert.equal(historyTransitionChanges(accessor)[0].after,'getter changed');
});
// 非枚举隐藏循环和稀疏数组不能递归死循环,也不能取得可复用的范围缓存资格。
test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{
const editor=createEditor({registry,document});edit(editor,'source');
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
const cycle={};Object.defineProperty(cycle,'hidden',{value:cycle});Object.freeze(cycle);
// Non-enumerable metadata is outside JSON validation but must not recurse forever.
const hidden={...original};Object.defineProperty(hidden,'hidden',{value:cycle});Object.freeze(hidden);
assert.notEqual(historyTransitionChanges(hidden),historyTransitionChanges(hidden));
const sparse=Object.freeze([,]);const withSparse={...original};Object.defineProperty(withSparse,'hidden',{value:sparse});Object.freeze(withSparse);
assert.notEqual(historyTransitionChanges(withSparse),historyTransitionChanges(withSparse));
});