Files
Endoleap/tests/headless/reconcile-replay.test.mjs
T

51 lines
6.6 KiB
JavaScript

/**
* ACK 基线上的本地待处理操作重放测试。已被 ACK 包含的前缀应跳过,其余修改按顺序应用到私有草稿。
* 字段存在性、canvas 归属和 incarnation 都参与检查;后段冲突不能污染原 ACK 或操作账本。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges,replayChanges} from '../../src/lib/canvas-core/index.ts';
import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts';
import {replayPending} from '../../src/lib/canvas-runtime/reconcile.ts';
// 使用内置类型校验重放结果,避免以空 registry 绕过元素约束。
const registry=createRegistry(builtinComponents());
// 构造带独立身份和归属的文本元素,可按场景替换 ID、canvas 或 incarnation。
const node=(id='a',ownerCanvasId='canvas',incarnation=1)=>({id,ownerCanvasId,incarnation,componentId:'text',dataVersion:1,structure:'node',data:{title:'original',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
// 每次返回新的基线文档,避免不同用例复用被修改的实例。
const base=()=>createDocument('project',[{id:'canvas',parent:null}],[node()]);
// 生成带前后值的标题修改,字段目标固定到 a 的第一代身份。
const title=(before,after)=>({kind:'field',target:{id:'a',incarnation:1},field:'data.title',before,after});
// 把各组修改登记为有序 pending 操作,并根据创建或既有对象提供存在性基线;这里只搭建重放输入。
function ledgerFor(groups){const ledger=createLedger();groups.forEach((changes,index)=>{const presence={};for(const change of changes)if(change.kind!=='canvas')presence[change.kind==='field'?change.target.id:change.id]=change.kind==='element'&&change.before===null?'absent':'exists';ledger.add({id:`op-${index}`,sequence:index+1,origin:'user',operationType:'document.change',operationSchemaVersion:1,canvasIds:['canvas'],dependencies:[],changes},presence);});return ledger.getSnapshot();}
// 分别从原始 ACK 和已确认第一步的 ACK 重放同一序列,最终标题都到 third,且输入 ACK 保持原值。
test('pending replay preserves sequential field baselines and skips a prefix already present in ACK',()=>{
const original=base(),ledger=ledgerFor([[title('original','first'),title('first','second')],[title('second','third')]]);
for(const acknowledged of [original,applyChanges(original,[title('original','first')])]){const result=replayPending(acknowledged,ledger,registry);assert.equal(result.elements.get('a').data.title,'third');assert.equal(acknowledged.elements.get('a').data.title,acknowledged===original?'original':'first');}
});
// 显式区分字段缺失与值为 null;先创建 null 字段再删除,并允许已完成删除的重复重放。
test('replay distinguishes missing and null fields while applying and skipping repeated deletes',()=>{
const original=base(),insert={kind:'field',target:{id:'a',incarnation:1},field:'data.optional',before:null,after:null,beforePresent:false,afterPresent:true},remove={...insert,beforePresent:true,afterPresent:false};
const added=replayPending(original,ledgerFor([[insert]]),registry);assert.ok(Object.hasOwn(added.elements.get('a').data,'optional'));assert.equal(added.elements.get('a').data.optional,null);
// 删除后重放同一删除应保持缺失;beforePresent/afterPresent 才能区分“没有字段”和“字段值为 null”。
const removed=replayPending(original,ledgerFor([[insert],[remove],[remove]]),registry);assert.equal(Object.hasOwn(removed.elements.get('a').data,'optional'),false);
});
// 先迁移 owner 再修改子 canvas 内字段;即使值未变化,错误作用域和旧 incarnation 仍必须被拒绝。
test('a draft replays owner transitions before later scope checks and rejects stale identity even for equal values',()=>{
const frame={...node('frame'),componentId:'frame'},canvases=[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}}],original=createDocument('project',canvases,[frame,node()]);
const move={kind:'field',target:{id:'a',incarnation:1},field:'ownerCanvasId',before:'canvas',after:'child'};
const result=replayChanges(original,[{changes:[move],canvasIds:['canvas','child']},{changes:[title('original','moved')],canvasIds:['child']}]);assert.equal(result.elements.get('a').ownerCanvasId,'child');assert.equal(result.elements.get('a').data.title,'moved');
const relocated=createDocument('project',canvases,[frame,node('a','child')]);assert.throws(()=>replayPending(relocated,ledgerFor([[title('original','original')]]),registry),/operation_context_changed/);
const restored=createDocument('project',[canvases[0]],[node('a','canvas',2)]);assert.throws(()=>replayPending(restored,ledgerFor([[title('original','original')]]),registry),/stale_incarnation/);
});
// 前序重放成功、后序前值冲突时丢弃整个私有草稿;原文档与账本不变,修正前值后才得到完整结果。
test('late conflicts discard the entire private replay draft and preserve the ACK and ledger',()=>{
const original=base(),ledger=ledgerFor([[title('original','first')],[title('wrong','last')]]),before=JSON.stringify([...ledger.operations]);assert.throws(()=>replayPending(original,ledger,registry),/field_conflict/);assert.equal(original.elements.get('a').data.title,'original');assert.equal(JSON.stringify([...ledger.operations]),before);assert.equal(replayPending(original,ledgerFor([[title('original','first')],[title('first','last')]]),registry).elements.get('a').data.title,'last');
});
// 256 次创建后再删除并以新 incarnation 恢复一个对象,对比逐组 applyChanges 的元素和墓碑结果。
// 标题/成员数量不足以验证生命周期,因此还断言墓碑保存被删除的第一代,而当前记录是第二代。
test('large replay preserves create/delete/restore lifecycles and conditional failures across operation boundaries',()=>{
const original=base(),created=Array.from({length:256},(_,i)=>node(`new-${i}`)),create=created.map(after=>({kind:'element',id:after.id,before:null,after})),deleted=created[50],restored={...deleted,incarnation:2,data:{...deleted.data,title:'restored'}},groups=[create,[{kind:'element',id:deleted.id,before:deleted,after:null}],[{kind:'element',id:deleted.id,before:null,after:restored}]];
const result=replayPending(original,ledgerFor(groups),registry);let reference=original;for(const changes of groups)reference=applyChanges(reference,changes);assert.deepEqual([...result.elements],[...reference.elements]);assert.deepEqual([...result.tombstones],[...reference.tombstones]);assert.equal(result.tombstones.get(deleted.id),1);assert.equal(original.elements.size,1);
});