Files
Endoleap/tests/headless/publication-guard.test.mjs

25 lines
3.5 KiB
JavaScript

/**
* CommitCoordinator 唯一发布边界测试。业务候选 Root 和 prepareRuntime 的结果必须一起校验、一起发布。
* 拒绝准备或返回 Promise 都不能留下半更新的文档、history、runtime 或查询索引。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
// 固定单元素文档供命令、撤销、重做和规范替换共用,以比较发布前后的引用和订阅次数。
const registry=createRegistry(builtinComponents()),record={id:'A',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'P',data:{title:'A',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}},document=createDocument('project',[{id:'P',parent:null}],[record]);
// 五类合法发布都会先准备 runtime;准备函数随后抛错时,命令、undo、元数据修改和 reconcile 均被拒绝,原 Root 与查询对象不变。
test('coordinator prepares runtime atomically for commands, history, metadata and canonical replacement',()=>{
let nextId=0,reject=false,calls=0;const editor=createEditor({document,registry,runtime:{prepared:0},createId:()=>`op${++nextId}`,prepareRuntime:(previous,next)=>{calls++;assert.equal(previous,editor.getSnapshot());assert.ok(Object.isFrozen(next));if(reject)throw Error('reserved resource');return Object.freeze({...next.runtime,prepared:previous.runtime.prepared+1});}});
// 订阅回调同时检查 runtime 准备序号和 query.document,确保订阅者观察到的是同一发布切点。
const snapshots=[];editor.subscribe((_,snapshot)=>{assert.equal(snapshot.runtime.prepared,snapshots.length+1);assert.equal(editor.getQueries().document,snapshot.document);snapshots.push(snapshot);});
assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'new'}}).status,'applied');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.history.redo().status,'applied');assert.equal(editor.updateRuntime(previous=>({...previous,extra:true})).status,'applied');const source=editor.getSnapshot();assert.equal(editor.reconcile(document,source.runtime,source.rootRevision).status,'applied');assert.equal(calls,5);
// 先保留 Root 和查询引用,再让各入口触发准备失败;不仅比较字段值,还要求未发布任何新对象。
reject=true;const root=editor.getSnapshot(),query=editor.getQueries();for(const operation of [()=>editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'blocked'}}),()=>editor.history.undo(),()=>editor.updateRuntime(()=>({prepared:100})),()=>editor.reconcile(source.document,source.runtime,root.rootRevision)]){assert.equal(operation().status,'rejected');assert.equal(editor.getSnapshot(),root);assert.equal(editor.getQueries(),query);}assert.equal(snapshots.length,5);
});
// 本地发布要求同步完成,Promise 形式的 prepareRuntime 返回 async_transaction,三个入口都不得改变原 Root。
test('an async coordinator cannot publish a partially prepared root',()=>{
const editor=createEditor({document,registry,runtime:0,prepareRuntime:()=>Promise.resolve(1)}),root=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'blocked'}}).code,'async_transaction');assert.equal(editor.updateRuntime(()=>1).code,'async_transaction');assert.equal(editor.reconcile(document,1,root.rootRevision).code,'async_transaction');assert.equal(editor.getSnapshot(),root);
});