163 lines
22 KiB
JavaScript
163 lines
22 KiB
JavaScript
/**
|
||
* 历史栈变更证据的回放与恢复测试。验证每个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));
|
||
});
|