From bd4f1a34a06137dbcf435ebd78b624719c8fd425 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 10:53:33 +0800 Subject: [PATCH] docs(tests): explain engine recovery cuts conflict plans and lifecycle guards --- tests/headless/conflicts.test.mjs | 57 +++++++++++++++++++- tests/headless/crash-windows.test.mjs | 22 ++++++-- tests/headless/dissolve.test.mjs | 36 +++++++++++++ tests/headless/durability.test.mjs | 19 ++++++- tests/headless/engine.test.mjs | 64 ++++++++++++++++++++++- tests/headless/orphan-files.test.mjs | 39 +++++++++++++- tests/headless/publication-guard.test.mjs | 11 ++++ tests/headless/session-lifecycle.test.mjs | 15 ++++++ 8 files changed, 254 insertions(+), 9 deletions(-) diff --git a/tests/headless/conflicts.test.mjs b/tests/headless/conflicts.test.mjs index 0a075fe..007e440 100644 --- a/tests/headless/conflicts.test.mjs +++ b/tests/headless/conflicts.test.mjs @@ -1,3 +1,7 @@ +/** + * 显式冲突恢复测试:用户选择 keep-local 或 discard-local 后,基于已审阅的远程基线重建条件操作和撤销链。 + * 旧请求及拒绝证据保留在账本与恢复审计中;结果未知的操作不能靠用户选择直接退休,付费动作不能自动重启。 + */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts'; @@ -5,19 +9,56 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts'; import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts'; +// 共享类型定义和目标身份,保证冲突处理仍执行数据校验及 incarnation 校验。 const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1}; +// 单元素基线同时包含文字和几何,便于区分局部字段重放与整对象覆盖。 const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'canvas',data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}; +// 独立的初始项目文档;服务替身和新引擎都从此基线开始。 const original=createDocument('project',[{id:'canvas',parent:null}],[element]); +// 推进有限轮微任务,供恢复读取进入挂起点;不等待被故意阻塞的完整恢复会话。 const tick=async()=>{for(let i=0;i<40;i++)await Promise.resolve();}; +// 默认拒绝写入并允许外部修改服务基线;存储可注入 quota,支持验证恢复计划先发布后落盘失败的重试。 function fixture(){ let server=original,version=1,image,leader;const calls=[]; - const f={reject:true,broken:false,calls,storage:{async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},port:{compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions:new Map([['canvas',version]])};},async read(){return{document:server,versions:new Map([['canvas',version]])};}},remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},setServer(document){server=document;version++;},image:()=>structuredClone(image),async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}}; + const f={reject:true,broken:false,calls,storage:{ + // 拒绝并行 owner,释放后下一次 open 才能重新接管;这是内存互斥替身。 + async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};}, + // 返回最近已提交镜像的副本,重开复用相同存储但不共享可变镜像对象。 + async recover(){return image&&structuredClone(image);}, + // 故障发生时不推进镜像;成功时校验存储 owner 并返回实际保存修订。 + async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};}, + // 本组不读取本地文件;空方法仅补齐 StoragePort。 + async readFile(){},close(){}},port:{ + // 根据当前 ACK 版本编译条件请求,让恢复后的替代意图采用新基线。 + compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};}, + // 默认给出明确 not-applied;放行后先检查版本再改服务状态。此替身不实现未知结果或请求去重。 + async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};}, + // 这里不测试未知结果查询;若恢复误走该路径,立即失败。 + async query(){throw Error('unexpected query');}, + // 为已知成功请求提供当前服务模型和版本。 + canonical(){return{document:server,versions:new Map([['canvas',version]])};}, + // 恢复会话读取最新完整服务基线,不读取客户端 working。 + async read(){return{document:server,versions:new Map([['canvas',version]])};}}, + // 外部写入逐字段保留存在性,模拟其他客户端推进远程版本。 + remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;}, + // 外部几何只改变指定轴,恢复时应保留本地未触碰的那些轴。 + remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;}, + // 直接替换服务模型并推进版本,用来模拟目标删除等生命周期变化。 + setServer(document){server=document;version++;}, + // 提供恢复镜像副本,供审计篡改测试使用。 + image:()=>structuredClone(image), + // 重新装配引擎但保留 fixture 的服务端、回执观测和存储状态。 + async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}}; return f; } +// 只收集尚未解决的非上传操作,生成恢复选项时不把独立资源上传当作文档编辑。 const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload'); +// 为每个待处理操作显式指定选择;某些用例覆盖个别选择以验证依赖闭包和动作限制。 const choices=(engine,choice='keep-local')=>new Map(pending(engine).map(record=>[record.intent.id,choice])); +// 通过真实命令入口修改标题,可传 groupId 检查输入历史分组在恢复后如何拆分或保留。 const title=(engine,value,settings)=>engine.editor.dispatch({type:'element.data',target,patch:{title:value}},settings); +// keep-local 以远程字段前值创建新操作和新请求 key,保留旧拒绝;撤销回到审阅过的远程标题,未触碰文字不回退。 test('keep-local freezes a new conditional intent/key, retains the old rejection, and undo restores the reviewed remote value',async()=>{ const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote',text:'untouched remote'}); const before=engine.getSnapshot(),old=[...before.runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); @@ -34,6 +75,7 @@ test('keep-local freezes a new conditional intent/key, retains the old rejection await engine.close(); }); +// 丢弃中间一步后重连剩余操作依赖和 undo 链;旧历史及恢复前内容仍保存在审计备份。 test('discarding a middle edit preserves the remaining undo chain and archives the old history',async()=>{ const f=fixture(),engine=await f.open();title(engine,'one');await engine.settle();title(engine,'two');title(engine,'three');await engine.settle();f.remote({title:'remote'}); const oldHistory=engine.getSnapshot().history,ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),selected=new Map([[ids[0],'keep-local'],[ids[1],'discard-local'],[ids[2],'keep-local']]),plan=session.plan(selected); @@ -44,6 +86,7 @@ test('discarding a middle edit preserves the remaining undo chain and archives t assert.equal(oldHistory.undo.length,3);assert.equal(engine.getSnapshot().runtime.recoveries.get(plan.id).backup.document.elements[0].data.title,'three');await engine.close(); }); +// 同一输入组中的已确认前缀仍可撤销;若服务端改过同字段,则撤销基点改为审阅的远程值。 test('typing groups preserve an acknowledged prefix unless the server changed that field',async()=>{ for(const remote of [false,true]){ const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle(); @@ -53,24 +96,28 @@ test('typing groups preserve an acknowledged prefix unless the server changed th } }); +// 只丢弃输入组的 pending 后缀,不删除已确认前缀的撤销能力。 test('discarding only a pending group suffix retains applied edits and other fields',async()=>{ const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle(); const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,1); await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');await engine.close(); }); +// 局部改宽度时保留远程 x 和 rotation;撤销只恢复宽度,不回退远程几何。 test('geometry recovery changes only locally touched axes and undo retains remote geometry',async()=>{ const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();f.remoteGeometry({x:300,rotation:45}); const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.deepEqual(plan.document.elements.get('a').geometry,{x:300,y:0,width:200,height:100,rotation:45}); f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.deepEqual(engine.getSnapshot().document.elements.get('a').geometry,{x:300,y:0,width:100,height:100,rotation:45});await engine.close(); }); +// 远程已删除的目标只能丢弃本地修改,keep-local 不得暗中复活旧 incarnation;恢复保留墓碑。 test('a deleted target may be discarded but cannot be resurrected by keep-local',async()=>{ const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.setServer(applyChanges(original,[{kind:'element',id:'a',before:element,after:null}])); const session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/stale_incarnation/);const plan=session.plan(choices(engine,'discard-local')); assert.equal(plan.history.undo.length,0);await session.resolve(plan);assert.equal(engine.getSnapshot().document.elements.has('a'),false);assert.equal(engine.getSnapshot().document.tombstones.get('a'),1);await engine.close(); }); +// 恢复发布后 quota 失败仍保留同一个计划和发送屏障;不能复制计划对象替代原计划,持久化重试成功后只新增一次写入。 test('recovery publication failure keeps one plan and blocks sends until the exact cut becomes durable',async()=>{ const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); f.reject=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);const revision=engine.getSnapshot().rootRevision;assert.equal(f.calls.length,1); @@ -78,11 +125,13 @@ test('recovery publication failure keeps one plan and blocks sends until the exa f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close(); }); +// 漏选任何待处理操作都会拒绝计划;撤销产生的 traversal 也能进入恢复规划,取消会话不提交该计划。 test('incomplete choices reject while recorded user/traversal recovery can be planned',async()=>{ const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();const session=await engine.prepareRecovery();assert.throws(()=>session.plan(new Map()),/recovery_choices_incomplete/);await session.cancel(); assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.ok(undoSession.plan(choices(engine)).history.redo.length===1);await undoSession.cancel();await engine.close(); }); +// 只退休依赖链一部分会被拒绝;已 dispatch、结果未知的操作即使补全闭包仍不安全,失败必须保持账本原引用。 test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{ const ledger=createLedger(),intent=(id,dependencies=[])=>({id,operationType:'document.change',operationSchemaVersion:1,sequence:id==='a'?1:2,origin:'user',canvasIds:['canvas'],changes:[],dependencies}); ledger.add(intent('a'),{});ledger.add(intent('b',['a']),{});const before=ledger.getSnapshot();assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'}]),/recovery_dependency_closure/);assert.equal(ledger.getSnapshot(),before); @@ -90,6 +139,7 @@ test('retirement rejects unknown work and incomplete dependency closures without assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'},{operationId:'b',resolution:'discarded'}]),/recovery_operation_not_safe/);assert.equal(ledger.getSnapshot(),frozen); }); +// 旧 owner 的几何不能直接搬到新坐标系;pending run.start 必须显式丢弃并另发新命令,恢复不能自动重启付费动作。 test('owner changes reject old geometry; pending paid actions require an explicit discard and never auto-restart',async()=>{ const {planConflictRecovery}=await import('../../src/lib/canvas-runtime/conflicts.ts'); const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle(); @@ -100,6 +150,7 @@ test('owner changes reject old geometry; pending paid actions require an explici assert.equal(plan.ledger.operations.get('paid-start').resolution,'discarded');assert.ok(plan.operationIds.every(id=>plan.operations.get(id).operationType==='document.change'));await session.cancel();await engine.close(); }); +// 已发布计划即使脱离原会话也可完成持久化屏障;重开校验审计、资源 pin、替代操作链接和备份历史,任一篡改都拒绝。 test('recovery audits and replacement links are validated when reopening and the owner can finish a detached durable barrier',async()=>{ const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); f.broken=true;await assert.rejects(session.resolve(plan),/quota/);f.broken=false;f.reject=false;await engine.cancelRecovery();await engine.settle(); @@ -114,18 +165,21 @@ test('recovery audits and replacement links are validated when reopening and the await engine.close(); }); +// 远程已满足本地目标值时以 satisfied 退休旧操作,不发送新请求或新增 undo,也不把原拒绝改写成服务端成功。 test('a remote value already satisfying the local choice retires the intent without claiming a server write or adding undo',async()=>{ const f=fixture(),engine=await f.open();title(engine,'same');await engine.settle();f.remote({title:'same'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan); assert.equal(f.calls.length,1);assert.equal([...engine.getSnapshot().runtime.ledger.requests.values()][0].outcome,'not-applied');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,1);await engine.close(); }); +// 混合输入组丢弃待处理标题后,已确认文字仍可撤销;撤销不得覆盖远程标题。 test('discarding a field in a mixed typing group keeps the unrelated acknowledged field undoable',async()=>{ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target,patch:{text:'acknowledged text'}},{groupId:'mixed'});await engine.settle();f.reject=true;title(engine,'pending title',{groupId:'mixed'});await engine.settle();f.remote({title:'remote title'}); const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.history.undo.length,1);await session.resolve(plan);f.reject=false; assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote title');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'');await engine.close(); }); +// 取消挂起读取会 abort 且拒绝迟到结果;即使计划全是 discard-local,提交时仍须重新检查当前身份策略。 test('discard-only resolution rechecks current identity and a cancelled read cannot later create a session',async()=>{ const f=fixture();let allowed=true,release,signal;const engine=await f.open();title(engine,'local');await engine.settle(); const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));await session.cancel(); @@ -136,6 +190,7 @@ test('discard-only resolution rechecks current identity and a cancelled read can }); +// 较早拒绝编辑的 keep/discard 不丢失后来本地抵消的创建删除历史;恢复保留本地墓碑,撤销用新 incarnation,不把抵消操作塞进请求。 test('later locally elided history survives keep/discard of an earlier rejected edit and its recovery replay audit',async()=>{ for(const keep of [false,true]){ const f=fixture();let engine=await f.open(); diff --git a/tests/headless/crash-windows.test.mjs b/tests/headless/crash-windows.test.mjs index 77bed7b..1b09d79 100644 --- a/tests/headless/crash-windows.test.mjs +++ b/tests/headless/crash-windows.test.mjs @@ -1,3 +1,7 @@ +/** + * 从真实引擎持久化序列提取各个崩溃切点,再用匹配的服务端状态重新打开。 + * 比较文档、history、ledger、请求次数和冻结字节;这是镜像恢复模拟,不会终止真实进程或测试真实磁盘掉电。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts'; @@ -5,12 +9,18 @@ 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'; +// 固定项目作用域,便于服务端版本向量与恢复镜像始终指向同一根 canvas。 const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003'; +// 原始文本目标只存在第一代;所有恢复场景从同一内容基线出发。 const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1},original=createDocument(root,[{id:root,parent:null}],[{...target,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:'Original',text:''}}]); +// 用可选本地镜像、服务端文档和历史回执初始化一套独立环境,分别模拟本地与远程已经完成到哪一步。 function fixture(image,initialServer=original,initialReceipt){ let owner,saved=image&&structuredClone(image),server=initialServer,version=initialReceipt?2:1,now=1000,next=0; + // 每次 commit 捕获一个持久化切点;calls 用于区分恢复后的查询与实际写入。 const cuts=[],calls=[],timers=new Map(),receipts=new Map(initialReceipt?[[initialReceipt.request_id,structuredClone(initialReceipt)]]:[]); + // 从当前服务端状态生成局部规范模型及其版本,作为成功回执的一部分。 const model=()=>({documentFormatVersion:1,projectId:root,complete:false,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[root]:version},removedCanvasIds:[]}); + // 查询只查原请求回执;首次写入应用条件操作并递增版本,重复请求直接返回已有回执。 const transport=async input=>{ calls.push(input); if(input.method==='GET'){const receipt=receipts.get(input.path.split('/').at(-1));return receipt?{status:200,body:receipt}:{status:404,body:{status:'unknown',request_id:input.path.split('/').at(-1)}};} @@ -18,34 +28,38 @@ function fixture(image,initialServer=original,initialReceipt){ const body=JSON.parse(input.body);assert.equal(body.expected_versions[root],version);server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));version++; const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model()};receipts.set(input.requestId,receipt);return{status:200,body:receipt}; }; + // 内存 owner 保证当前引擎独占;每次保存克隆镜像,防止后续状态污染选定的崩溃切点。 const storage={async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;this.active=false;}};},async recover(){return saved&&structuredClone(saved);},async commit(leader,batch){assert.strictEqual(leader,owner);saved=structuredClone(batch.image);cuts.push(saved);return{durableRootRevision:saved.rootRevision};},async readFile(){},close(){}}; + // 接入真实 Foundry 文档适配器;假时钟只登记回调,由 drain 手动推进重试。 const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({registry,workspaceId:workspace,rootId:root,transport}),clock:{now:()=>now,setTimer(callback,delay){const id=++next;timers.set(id,{callback,due:now+delay});return id;},clearTimer:id=>timers.delete(id)}}; + // drain 有界推进收敛和到期重试;若八轮后仍有未解决操作即失败,不以无限等待掩盖恢复停滞。 return{cuts,calls,receipts,server:()=>server,open:()=>openProjectEngine(options),async drain(engine){for(let step=0;step<8;step++){await engine.settle();if([...engine.getSnapshot().runtime.ledger.operations.values()].every(operation=>operation.phase==='resolved'))return;now+=100000;for(const[id,timer]of[...timers])if(timer.due<=now){timers.delete(id);timer.callback();}}assert.fail('recovered operations did not settle');}}; } +// 先收集 pending、frozen、dispatch-marked、resolved 四种落盘状态,再用六个子场景分别核对恢复策略和已发生的外部副作用。 test('each durable crash cut restores its own document/history/ledger and external side-effect state',async t=>{ const producer=fixture(),engine=await producer.open();await engine.persist();const initial=structuredClone(producer.cuts.at(-1)); const edited=engine.editor.dispatch({type:'element.data',target,patch:{title:'Changed'}});assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'Changed');assert.ok(engine.getDurability().rootRevision>engine.getDurability().durableRootRevision); await producer.drain(engine);const decoded=producer.cuts.map(image=>({image,state:decodeRecovery(image,registry)})),request=[...engine.getSnapshot().runtime.ledger.requests.values()][0],receipt=producer.receipts.get(request.id),changed=producer.server(); const pending=decoded.find(({state})=>state.runtime.ledger.operations.size===1&&state.runtime.ledger.requests.size===0),frozen=decoded.find(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='frozen'),dispatch=decoded.find(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='dispatch-marked'),resolved=decoded.findLast(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='resolved'); assert.ok(pending&&frozen&&dispatch&&resolved,'producer did not expose every required durable transition');await engine.close(); + // 初始镜像没有尚未落盘的可见编辑;后续每个场景都明确指定应查询/写入的次数及服务端是否已执行。 for(const scenario of[ {name:'visible edit before durability is absent after crash and was never safe to leave',image:initial,edited:false,posts:0,queries:0}, {name:'durable pending intent dispatches once with its original logical identity',image:pending.image,edited:true,posts:1,queries:0}, - // Recovery conservatively queries this cut too; unknown permits the exact - // retry, not a new identity or a claim that no effect could have happened. + // frozen 切点也保守查询;unknown 仅允许原请求重试,不能改用新身份或宣称服务未发生副作用。 {name:'durable frozen request is reconciled then sends its original bytes only once',image:frozen.image,edited:true,posts:1,queries:1,exact:true}, {name:'dispatch-marked before server commit queries unknown then retries the exact request',image:dispatch.image,edited:true,posts:1,queries:1,exact:true}, {name:'server commit before local receipt queries without repeating the write',image:dispatch.image,edited:true,posts:0,queries:1,server:changed,receipt,exact:true}, {name:'durable receipt and baseline do not replay the acknowledged edit or history',image:resolved.image,edited:true,posts:0,queries:0,server:changed,receipt}, ])await t.test(scenario.name,async()=>{ - // The server state is matched to the cut: it is never rewound after a - // dispatched effect merely because an older local image was selected. + // 本地 dispatch 切点可对应服务执行前或执行后;若服务已经提交,不能因读取旧镜像就把服务状态回退。 const f=fixture(scenario.image,scenario.server,scenario.receipt),recovered=await f.open(); try{ await f.drain(recovered);const state=recovered.getSnapshot();assert.equal(state.document.elements.get('a').data.title,scenario.edited?'Changed':'Original');assert.equal(f.server().elements.get('a').data.title,scenario.edited?'Changed':'Original');assert.equal(state.history.undo.length,scenario.edited?1:0);assert.equal(state.operations.size,scenario.edited?1:0); assert.equal(f.calls.filter(call=>call.method==='POST').length,scenario.posts);assert.equal(f.calls.filter(call=>call.method==='GET').length,scenario.queries); if(scenario.edited)assert.ok(state.runtime.ledger.operations.has(edited.operationIds[0])); + // 身份核对覆盖 GET 路径和 POST requestId;实际重发还必须保留同一冻结 body。 if(scenario.exact)for(const call of f.calls){assert.equal(call.requestId??call.path.split('/').at(-1),request.id);if(call.method==='POST')assert.equal(call.body,request.body);} assert.equal(recovered.getDurability().rootRevision,recovered.getDurability().durableRootRevision);assert.equal(recovered.sessions.getSnapshot().views.size,0); }finally{await recovered.close();} diff --git a/tests/headless/dissolve.test.mjs b/tests/headless/dissolve.test.mjs index 8ac4a6d..da5e0d3 100644 --- a/tests/headless/dissolve.test.mjs +++ b/tests/headless/dissolve.test.mjs @@ -1,3 +1,8 @@ +/** + * 分区解散和重新挂接测试。解散只移除 owning canvas,将其直接成员提升到父 canvas,保留入口元素及其连线。 + * 几何换算必须保留世界姿态,子分区只改父绑定;声明的 canvas 引用跟随重映射,普通字符串不自动替换。 + * 最后一例用真实 Foundry 协议适配器和内存服务检查单请求、丢 ACK 恢复及跨重开的 undo/redo。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createEditor,createRegistry,builtinComponents,defineComponent,movable,editable,validateDocument,applyChanges,canvasTransform,toWorld,unitScale} from '../../src/lib/canvas-core/index.ts'; @@ -5,26 +10,42 @@ import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts'; import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts'; import {loadingCommand} from '../../src/lib/canvas-runtime/loading.ts'; +// 统一节点身份、owner 和几何;frame 复用该外壳,单独补充内容缩放及单位缩放。 const node=(id,owner='P',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:120,height:80,rotation:5},data:{title:id,text:'kept'}}); +// 已知 bezier-chain 路径包含锚点和手柄,解散时可以换算;未知路径类型则应原子拒绝。 const curve={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:60},in:{x:-5,y:0},out:{x:5,y:0}}]}; +// 连线用端点引用和 owner 表达图关系,data.label 供检查结构修改不会清空内容。 const edge=(id,source,target,owner='P',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,source:{elementId:source},target:{elementId:target},path,data:{label:'keep'}}); +// 自定义 references 类型只声明 views[*].canvas 为引用,literal 即使同值也不是引用;保留未知扩展字段。 const registry=createRegistry([...builtinComponents(),defineComponent({id:'references',dataVersion:1,structure:'node',validate:()=>true,features:[movable(),editable()],copy:{references:[{path:['views','*','canvas'],target:'canvas',external:'preserve'}],omit:[]}})]); +// 三层 P→S→T,包含跨层连线、入口连线和显式引用;父 frame 带旋转与缩放以暴露坐标换算错误。 const source=()=>createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G',future:'retain'}}],[ {...node('F','P','frame'),geometry:{x:100,y:70,width:500,height:400,rotation:33},data:{title:'F',text:'',presentation:{contentScale:2,unitScale:1.2},ports:[{id:'in',nodeId:'A'}]}}, {...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75,unitScale:.8}}},node('A','S'),node('B','T'),node('C'), {...node('R','P','references'),data:{views:[{canvas:'S',future:true}],literal:'S'}},edge('incoming','C','F'),edge('cross','C','B'),edge('inside','A','G','S',curve), ]); +// 目标 S 与其入口 F 的第一代身份必须共同匹配,不能仅凭 canvasId 解散任意分区。 const command={type:'canvas.dissolve',canvasId:'S',entry:{id:'F',incarnation:1}}; +// 浮点姿态用 1e-7 容差比较,避免旋转缩放的舍入误差干扰结构语义检查。 const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`); +// 把元素中心、大小、角度和单位比例换算到世界坐标,比较解散前后用户实际看到的姿态。 function pose(document,id){const element=document.elements.get(id),t=canvasTransform(document,element.ownerCanvasId),g=element.geometry;return{...toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width:g.width*t.scale,height:g.height*t.scale,rotation:(g.rotation+t.rotation)%360,units:unitScale(element.data.presentation)*t.scale};} +// 集中验证提升结果:S 消失但入口、元素数和身份保留,直接成员迁移,孙层成员与父扩展信息保留。 function checkPromoted(before,after){ validateDocument(after,registry);assert.equal(after.canvases.has('S'),false);assert.deepEqual(after.elements.get('F'),before.elements.get('F'));assert.deepEqual(after.elements.get('incoming'),before.elements.get('incoming')); assert.equal(after.elements.size,before.elements.size);assert.equal(after.elements.get('A').ownerCanvasId,'P');assert.equal(after.elements.get('G').ownerCanvasId,'P');assert.equal(after.elements.get('B').ownerCanvasId,'T');assert.deepEqual(after.elements.get('B'),before.elements.get('B'));assert.deepEqual(after.canvases.get('T').parent,{...before.canvases.get('T').parent,canvasId:'P'}); + + // 分别比较直接节点、子分区入口和孙层节点的世界姿态,防止只验证 owner 而漏掉嵌套坐标变化。 for(const id of['A','G','B']){const a=pose(before,id),b=pose(after,id);for(const key of Object.keys(a))near(a[key],b[key]);assert.equal(after.elements.get(id).incarnation,before.elements.get(id).incarnation);} + + // 内部连线提升到父 canvas 后,路径锚点需换算到同一世界位置;原跨层连线的 owner 本来就是 P。 assert.equal(after.elements.get('inside').ownerCanvasId,'P');const a=toWorld(canvasTransform(before,'S'),curve.knots[0].position),b=toWorld(canvasTransform(after,'P'),after.elements.get('inside').path.knots[0].position);near(a.x,b.x);near(a.y,b.y); + + // 仅重写注册表声明的 views.canvas;literal 字符串和 future 扩展值原样保留。 assert.equal(after.elements.get('cross').ownerCanvasId,'P');assert.deepEqual(after.elements.get('R').data,{views:[{canvas:'P',future:true}],literal:'S'}); } +// can 只查询能力且不发布;dispatch 一次通知、一个操作和一个 undo 单元,undo 精确还原,redo 再次得到相同世界姿态。 test('dissolve promotes direct members once, preserves world poses, nested ownership, entry lines and declared references',()=>{ const document=source(),editor=createEditor({document,registry}),before=editor.getSnapshot();let notifications=0;editor.subscribe(()=>notifications++); assert.equal(editor.can(command).enabled,true);assert.strictEqual(editor.getSnapshot(),before); @@ -33,6 +54,7 @@ test('dissolve promotes direct members once, preserves world poses, nested owner assert.equal(editor.history.redo().status,'applied');checkPromoted(document,editor.getSnapshot().document);assert.equal(editor.getSnapshot().history.undo.length,1); }); +// 根 canvas、旧入口身份、错误入口、成员/引用策略拒绝、未知元素和未知路径都不能留下半次提升;Root、query 和订阅次数保持不变。 test('dissolve rejects root, stale entry, member policy, unsupported member and opaque curve without partial publication',()=>{ const document=source();for(const options of[ {command:{...command,canvasId:'P'}}, @@ -48,17 +70,20 @@ test('dissolve rejects root, stale entry, member policy, unsupported member and } }); +// 空 canvas 解散只移除绑定,入口元素不删除、不分配替代元素、不产生元素墓碑;undo 恢复原 canvas 集合。 test('an empty owning canvas dissolves without deleting its entry or allocating replacement elements',()=>{ const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame')]),editor=createEditor({document,registry}); assert.equal(editor.dispatch(command).status,'applied');assert.deepEqual([...editor.getSnapshot().document.elements],[...document.elements]);assert.equal(editor.getSnapshot().document.tombstones.size,0);assert.equal(editor.history.undo().status,'applied');assert.deepEqual([...editor.getSnapshot().document.canvases],[...document.canvases]); }); +// reparent 只改 canvas 父入口绑定,不递归改子孙元素 owner;制造父子循环时整体拒绝。 test('reparent changes the canvas binding without recursively rewriting descendant owners and cycles remain atomic failures',()=>{ const original=source(),editor=createEditor({document:original,registry});assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'S',parent:{canvasId:'P',entryElementId:'C'}}).status,'applied'); const document=editor.getSnapshot().document;assert.deepEqual(document.canvases.get('S').parent,{canvasId:'P',entryElementId:'C'});for(const id of['A','G','B'])assert.equal(document.elements.get(id).ownerCanvasId,original.elements.get(id).ownerCanvasId);assert.equal(document.canvases.get('T').parent.canvasId,'S');validateDocument(document,registry); const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'P',parent:{canvasId:'T',entryElementId:'B'}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before); }); +// 渐进加载时,即使直接成员已到齐也不能忽略尚未加载的引用元素;完整捕获源可解散,旧 v3 加载描述不足以放行。 test('dissolve waits for the complete captured source instead of promoting a partially loaded membership',()=>{ const full=source(),prefix=createDocument('P',[...full.canvases.values()],[...full.elements.values()].filter(element=>element.id!=='R')),versions=new Map([...full.canvases.keys()].map(id=>[id,1])),state={version:5,sourceToken:'dissolve-source',declaredCanvasIds:[...full.canvases.keys()],sourceCanvases:[...full.canvases.values()],elementOwners:Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId])),canvasElementCounts:Object.fromEntries([...full.canvases.keys()].map(id=>[id,[...full.elements.values()].filter(element=>element.ownerCanvasId===id).length])),receivedElements:prefix.elements.size,totalElements:full.elements.size}; assert.equal(loadingCommand(command,prefix,registry,state,prefix,{document:prefix,versions}).enabled,false); @@ -66,11 +91,18 @@ test('dissolve waits for the complete captured source instead of promoting a par assert.equal(loadingCommand(command,full,registry,{...state,version:3},full,{document:full,versions}).enabled,false); }); +// 符号 ID 转 UUID 后走真实编译适配器;一次请求覆盖 P/S/T,丢 ACK 只查询原请求,重开保留 undo/redo,最终共三次文档写入。 test('dissolve uses one atomic protocol request, recovers a lost ACK by its original identity, then undoes and redoes after reopen',async()=>{ const symbolic=source(),ids=Object.fromEntries([...new Set([...symbolic.canvases.keys(),...symbolic.elements.keys()])].map((id,index)=>[id,`10000000-0000-4000-8000-${String(index+1).padStart(12,'0')}`])),inverse=Object.fromEntries(Object.entries(ids).map(([name,id])=>[id,name])); + + // 为协议测试双向重命名所有结构 ID:canvas、入口、owner、端点、端口、声明引用及墓碑;普通内容字符串不替换。 const rename=(document,ids)=>{const map=id=>ids[id]??id;return createDocument(map(document.projectId),[...document.canvases.values()].map(canvas=>({...canvas,id:map(canvas.id),parent:canvas.parent?{...canvas.parent,canvasId:map(canvas.parent.canvasId),entryElementId:map(canvas.parent.entryElementId)}:null})),[...document.elements.values()].map(element=>({...element,id:map(element.id),ownerCanvasId:map(element.ownerCanvasId),...(element.structure==='connection'?{source:{...element.source,elementId:map(element.source.elementId)},target:{...element.target,elementId:map(element.target.elementId)}}:{}),data:{...element.data,...(element.data.ports?{ports:element.data.ports.map(port=>({...port,nodeId:map(port.nodeId)}))}:{}),...(element.data.views?{views:element.data.views.map(view=>({...view,canvas:map(view.canvas)}))}:{})}})),new Map([...document.tombstones].map(([id,incarnation])=>[map(id),incarnation])));}; const original=rename(symbolic,ids),workspace='20000000-0000-4000-8000-000000000001';let server=original,image,owner,now=1000,lose=true;const versions=new Map([...original.canvases.keys()].map(id=>[id,1])),receipts=new Map(),calls=[]; + + // 为参与 canvas 生成局部回执,移除的 S 仍列入 removedCanvasIds 和版本向量。 const model=(participants,complete=false)=>({documentFormatVersion:1,projectId:original.projectId,complete,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)])),removedCanvasIds:participants.filter(id=>!server.canvases.has(id))}); + + // Foundry 编译器负责实际请求格式;内存 transport 检查每个参与版本,并在执行后故意丢一次响应。 const persistence=createFoundryPersistence({registry,workspaceId:workspace,rootId:ids.P,transport:async input=>{ calls.push(input);if(input.method==='GET')return{status:200,body:receipts.get(input.path.split('/').at(-1))}; const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],versions.get(id)??0); @@ -78,7 +110,11 @@ test('dissolve uses one atomic protocol request, recovers a lost ACK by its orig const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(participants)};receipts.set(input.requestId,receipt); if(lose){lose=false;throw Error('lost dissolve ACK');}return{status:200,body:receipt}; }}); + + // 保存完整恢复镜像和独占 owner;引擎重开时仍能取到原冻结请求与历史。 const storage={async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;this.active=false;}};},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}}; + + // 假时钟不自动执行定时器,重开前主动把 now 前移以触发未知请求的恢复查询。 const options={scope:{accountId:'account',workspaceId:workspace,projectId:ids.P},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage,persistence,clock:{now:()=>now,setTimer:()=>0,clearTimer(){}}};let engine=await openProjectEngine(options); try{ assert.equal(engine.editor.dispatch({...command,canvasId:ids.S,entry:{id:ids.F,incarnation:1}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'unresolved');assert.deepEqual([...request.canvasIds].map(id=>inverse[id]).sort(),['P','S','T']);assert.equal(calls.filter(call=>call.method==='POST').length,1);checkPromoted(symbolic,rename(server,inverse)); diff --git a/tests/headless/durability.test.mjs b/tests/headless/durability.test.mjs index 1063675..32db65c 100644 --- a/tests/headless/durability.test.mjs +++ b/tests/headless/durability.test.mjs @@ -1,6 +1,23 @@ +/** + * 持久化元数据订阅测试。durability 的脏状态、错误和关闭进度独立于业务 Root 发布。 + * 内存 commit 可注入 quota 故障,观察者异常由错误回调接收,不应破坏其他观察者或保存状态。 + */ import {test} from 'node:test';import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts'; import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts'; -async function fixture(){let broken=false;const errors=[],engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:'p'},registry:createRegistry(builtinComponents()),document:createDocument('p',[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage:{async claim(scope){return{scope,active:true,fence:1,async release(){this.active=false;}};},async recover(){return null;},async commit(_leader,batch){if(broken)throw{code:22,message:'quota'};return{durableRootRevision:batch.image.rootRevision};},async readFile(){return;},close(){}},persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};} +// 建立没有业务网络操作的空文档引擎,先保存初始状态;任何意外的编译、发送、查询都会直接失败。 +async function fixture(){let broken=false;const errors=[],engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:'p'},registry:createRegistry(builtinComponents()),document:createDocument('p',[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage:{ + // 最小存储 owner 替身;关闭会释放其 active 标记。 + async claim(scope){return{scope,active:true,fence:1,async release(){this.active=false;}};}, + // 无历史镜像,保证每个用例从新的持久化元数据起步。 + async recover(){return null;}, + // 开关启用时抛出数字 code 的 quota 对象;否则确认此次镜像实际声明的修订。 + async commit(_leader,batch){if(broken)throw{code:22,message:'quota'};return{durableRootRevision:batch.image.rootRevision};}, + // 本组不使用本地文件,不额外产生资源任务。 + async readFile(){return;},close(){}}, + // 全部网络入口都故意抛错,保证测试只观察本地元数据而不是悄悄依赖网络同步。 + persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};} +// runtime 修改先通知脏修订,保存后通知对应持久化修订;关闭也通知进度,但完成保存不再增加一次业务 Root。关闭后订阅为空清理函数。 test('durability subscribers observe dirty metadata, final durable cut and closure without another business Root',async()=>{const{engine}=await fixture(),states=[];const stop=engine.subscribeDurability(()=>states.push(engine.getDurability()));assert.strictEqual(engine.getDurability(),engine.getDurability());engine.editor.updateRuntime(previous=>({...previous}));const revision=engine.getSnapshot().rootRevision;assert.ok(states.some(state=>state.rootRevision>state.durableRootRevision));await engine.persist();assert.equal(states.at(-1).durableRootRevision,revision);assert.equal(engine.getSnapshot().rootRevision,revision);await engine.close();assert.ok(states.some(state=>state.closing&&!state.closed));assert.equal(states.at(-1).closed,true);stop();const noop=engine.subscribeDurability(()=>assert.fail('closed owner notified'));noop();}); +// 一个监听器抛错不妨碍其他监听器看到 quota;数字 DOM 风格错误归一成字符串,保存失败保持脏状态,重试成功后清除错误。 test('storage failures remain observable and normalize numeric DOM-style codes without listener exceptions changing durability',async()=>{const f=await fixture();const states=[];f.engine.subscribeDurability(()=>{throw Error('observer');});f.engine.subscribeDurability(()=>states.push(f.engine.getDurability()));f.fail(true);f.engine.editor.updateRuntime(previous=>({...previous}));await assert.rejects(f.engine.persist());assert.equal(states.at(-1).error,'quota');assert.equal(typeof states.at(-1).error,'string');assert.ok(states.at(-1).durableRootRevisionerror.message==='observer'));await f.engine.close();}); diff --git a/tests/headless/engine.test.mjs b/tests/headless/engine.test.mjs index 17be047..d05d771 100644 --- a/tests/headless/engine.test.mjs +++ b/tests/headless/engine.test.mjs @@ -1,48 +1,69 @@ +/** + * ProjectEngine 的组合状态测试:文档、history、操作账本、ACK 基线由统一发布边界协调。 + * 使用内存存储和持久化端口,验证保存顺序、未知结果、恢复会话、本地抵消与回执压缩;不等同于真实磁盘或服务端集成。 + * Root 是不可变业务切点,durability 另行记录已确认保存到哪里;可见修改不等于安全离开。 + */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createDocument, applyChanges, builtinComponents, createRegistry } from '../../src/lib/canvas-core/index.ts'; import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; import { encodeRecovery, decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts'; +// 内置类型参与全部模型校验,避免测试通过空注册表绕开数据约束。 const registry = createRegistry(builtinComponents()); +// 固定单 canvas、单文本元素的初始模型;后续场景只改变所需字段或生命周期。 const original = createDocument('project', [{ id: 'canvas', parent: null }], [{ id: 'a', incarnation: 1, structure: 'node', ownerCanvasId: 'canvas', componentId: 'text', dataVersion: 1, data: { title: 'a', text: '' }, geometry: { x: 0, y: 0, width: 100, height: 100, rotation: 0 } }]); +// 存储恢复作用域包含账户、workspace 和 project;目标引用还带 incarnation。 const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, target = { id: 'a', incarnation: 1 }; +// 用手动完成的 Promise 精确控制网络、编码或写盘何时越过屏障。 const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; }; +// 推进有限轮微任务;适用于保留在途请求而又要等调度器进入挂起点的测试。 const tick = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; +// 内存 storage 持有独占 owner 和递增 fence;每次 commit 保存克隆镜像,并要求修订连续或重试同一修订。 function storageFixture() { let image = null, owner = null, fence = 0; const saved = [], fixture = { broken: false, saved, + // 同一时刻只允许一个 owner;释放后再申请会获得新 fence,供重开测试辨别新存储所有者。 async claim(scope) { assert.equal(owner, null); const leader = { scope, fence: ++fence, active: true, async release() { owner = null; leader.active = false; } }; owner = leader; return leader; }, + // 恢复返回镜像副本,调用方无法修改上一次已保存的对象。 async recover() { return image && structuredClone(image); }, + // 先检查故障和当前 owner,再保存对应 Root;返回的 durableRootRevision 是引擎推进确认边界的证据。 async commit(leader, batch) { if (fixture.broken) throw new Error('quota'); assert.equal(owner, leader); assert.equal(leader.active, true); if (image) assert.ok(batch.image.rootRevision === image.rootRevision + 1 || batch.image.rootRevision === image.rootRevision); image = structuredClone(batch.image); saved.push(image); return { durableRootRevision: image.rootRevision }; }, async readFile() {}, close() {}, image: () => structuredClone(image), owner: () => owner, }; return fixture; } +// 服务替身按请求 ID 去重,只在首次 submit 应用修改;query 查询既有结果,overrides 可替换任一协议边界。 function persistenceFixture(overrides = {}) { let server = original, version = 1; const calls = [], receipts = new Map(); const fixture = { calls, receipts, + // 把逻辑意图与当前参与版本冻结进 payload;测试编译器不执行真实 Foundry wire 格式压缩。 compile(intent, requestId, snapshot) { return { payload: { requestId, intent }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])) }; }, + // 首次写入产生服务端快照并递增版本;重复同 ID 只返回该回执,不重复修改。 async submit(request) { calls.push(['submit', request]); if (!receipts.has(request.id)) { const { intent } = JSON.parse(request.body); server = applyChanges(server, intent.changes); version++; receipts.set(request.id, { document: server, versions: new Map([['canvas', version]]) }); } return { outcome: 'applied', canonical: { receipt: request.id } }; }, + // 记录查询并区分已知成功与 unresolved;没有回执不提供零写入保证。 async query(request) { calls.push(['query', request]); return receipts.has(request.id) ? { outcome: 'applied', canonical: { receipt: request.id } } : { outcome: 'unresolved' }; }, + // 由已保存回执取规范快照;可在个别测试中替换为冲突数据,检查重放失败边界。 canonical(request) { return receipts.get(request.id); }, ...overrides, }; return fixture; } +// 统一装配 engine 并收集异步错误;extra 可覆盖时钟、ID、文档、registry 等默认装配选项。 async function open(storage, persistence, extra = {}) { let next = 0; const errors = []; const engine = await openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, persistence, createId: () => `identity-${++next}`, onError: error => errors.push(error), ...extra }); return { engine, errors }; } +// 本地命令一次发布文档、历史和账本;首个请求在途时继续移动,ACK 后基于新版本重放移动,最终两次修改都持久化。 test('local document/history/ledger publish together; ACK replays edits made while a request is in flight', async () => { const storage = storageFixture(), port = persistenceFixture(), gate = deferred(), realSubmit = port.submit; port.submit = async request => { if (!port.calls.length) await gate.promise; return realSubmit(request); }; @@ -67,6 +88,7 @@ test('local document/history/ledger publish together; ACK replays edits made whi await engine.close(); }); +// detach 只关闭视图,后台请求继续;引擎关闭再打开后恢复历史并查询原未知请求,ID 和冻结字节保持不变。 test('closing a view keeps jobs alive; closing/reopening the engine restores history and queries the original unknown request', async () => { const storage = storageFixture(), port = persistenceFixture(), realSubmit = port.submit; port.submit = async request => { await realSubmit(request); return { outcome: 'unresolved' }; }; @@ -86,6 +108,7 @@ test('closing a view keeps jobs alive; closing/reopening the engine restores his await recovered.engine.close(); }); +// 创建实际已执行但 ACK 丢失,随后删除仍依赖原创建;恢复先查询再发送独立删除,最终服务端只留墓碑而无幽灵元素。 test('create applied with a lost ACK followed by delete recovers the original request and leaves no server element',async()=>{ const storage=storageFixture(),port=persistenceFixture(),submit=port.submit;let lost=true,now=1000,next=0; port.submit=async request=>{const status=await submit(request);if(lost){lost=false;return{outcome:'unresolved'};}return status;}; @@ -101,6 +124,7 @@ test('create applied with a lost ACK followed by delete recovers the original re }finally{await engine.close();} }); +// quota 失败保留可见编辑但阻止网络;失败 close 保留存储 owner 并封闭编辑入口,存储恢复后重试 close 才释放。 test('failed local durability preserves editing, prevents sends, and failed close retains the recovery leader', async () => { const storage = storageFixture(), port = persistenceFixture(), { engine, errors } = await open(storage, port); await engine.persist(); storage.broken = true; @@ -112,6 +136,7 @@ test('failed local durability preserves editing, prevents sends, and failed clos storage.broken = false; await engine.close(); assert.equal(storage.owner(), null); }); +// 已知成功回执与后续几何重放冲突时保留本地内容、原始回执和 waiting 占用,不发送下一请求。 test('canonical replay conflict preserves working content and the known receipt without releasing the writer', async () => { const storage = storageFixture(), port = persistenceFixture(), gate = deferred(), realSubmit = port.submit; port.submit = async request => { await gate.promise; return realSubmit(request); }; @@ -129,6 +154,7 @@ test('canonical replay conflict preserves working content and the known receipt await engine.close(); }); +// 编码解码恢复只读集合及历史;缺失历史引用和自依赖均被拒绝,另一个镜像副本的修改不改变已恢复内容。 test('recovery codec restores immutable maps and rejects broken history/dependency references', async () => { const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port); engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'encoded' } }); await engine.settle(); @@ -143,6 +169,7 @@ test('recovery codec restores immutable maps and rejects broken history/dependen await engine.close(); }); +// 无未决写入时可接纳更新规范基线并保留历史;旧版本刷新被拒绝,不回退新几何。 test('a fresh canonical read replaces a clean recovered base, keeps history and refuses older versions', async () => { const storage = storageFixture(), port = persistenceFixture(), first = await open(storage, port); first.engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'local' } }); await first.engine.settle(); await first.engine.close(); @@ -156,6 +183,7 @@ test('a fresh canonical read replaces a clean recovered base, keeps history and await reopened.engine.close(); }); +// 恢复先等已入场请求完成再读取完整基线;准备期间封闭编辑、生成和刷新,预览不发布,apply 才发布并落盘且不改原历史引用。 test('recovery waits for admitted writes, reads a complete base, fences edits and publishes without changing history', {timeout:5000}, async()=>{ const storage=storageFixture(),port=persistenceFixture(),response=deferred(),realSubmit=port.submit,readGate=deferred(),readEntered=deferred(); let reads=0;port.submit=async request=>{await response.promise;return realSubmit(request);}; @@ -173,6 +201,7 @@ test('recovery waits for admitted writes, reads a complete base, fences edits an await engine.close(); }); +// 未知回执不能靠全量读跳过确认;prepareRecovery 失败后保留原请求与本地状态,并解除临时编辑屏障。 test('unknown receipts prevent a recovery read and preserve the frozen request identity',async()=>{ const storage=storageFixture();let reads=0;const port=persistenceFixture({async submit(){return{outcome:'unresolved'};},async read(){reads++;return{document:original,versions:new Map([['canvas',1]])};}}); const{engine}=await open(storage,port);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle(); @@ -182,6 +211,7 @@ test('unknown receipts prevent a recovery read and preserve the frozen request i assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'allowed'}}).enabled,true);await engine.close(); }); +// 冲突预览不覆盖本地内容或历史,直接 apply 被拒绝;重复 cancel 安全,取消后恢复普通编辑。 test('recovery conflict previews preserve local data/history and cancel releases the edit fence',async()=>{ const remote=applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]); const storage=storageFixture(),port=persistenceFixture({async submit(){return{outcome:'not-applied',error:'version_conflict'};},async read(){return{document:remote,versions:new Map([['canvas',2]])};}}); @@ -191,6 +221,7 @@ test('recovery conflict previews preserve local data/history and cancel releases await session.cancel();await session.cancel();assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'more'}}).enabled,true);await engine.close(); }); +// 恢复 Root 已发布但保存失败时保留编辑屏障;重试保存同一 Root,不再创建一次恢复修订。 test('failed recovery durability retains its fence and retries the same published root',async()=>{ const storage=storageFixture(),port=persistenceFixture({async read(){return{document:original,versions:new Map([['canvas',1]])};}}),{engine}=await open(storage,port); const session=await engine.prepareRecovery();storage.broken=true;await assert.rejects(session.apply(),/quota/);const revision=engine.getSnapshot().rootRevision; @@ -199,6 +230,7 @@ test('failed recovery durability retains its fence and retries the same publishe await engine.close(); }); +// 关闭引擎 abort 恢复读取并释放 owner;端口仍返回迟到结果也必须拒绝,不能创建恢复会话。 test('closing the engine aborts a recovery read and rejects a late canonical response',async()=>{ const storage=storageFixture(),response=deferred();let signal; const port=persistenceFixture({async read(input){signal=input;return response.promise;}}),{engine}=await open(storage,port); @@ -206,6 +238,7 @@ test('closing the engine aborts a recovery read and rejects a late canonical res response.resolve({document:original,versions:new Map([['canvas',1]])});await assert.rejects(preparing,/engine_closing/);assert.equal(storage.owner(),null); }); +// 恢复读和普通 refresh 都拒绝同版本内容改写;另外直接检查墓碑回退与生命周期变化,不凭版本增加就接受旧生命期。 test('complete recovery reads reject same-version rewrites and lifecycle regression before creating a session',async()=>{ const remote=applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]); const storage=storageFixture(),port=persistenceFixture({async read(){return{document:remote,versions:new Map([['canvas',1]])};}}),{engine}=await open(storage,port); @@ -220,6 +253,7 @@ test('complete recovery reads reject same-version rewrites and lifecycle regress await engine.close(); }); +// 同一事务先创建再删除,basePresence 必须保留最初 absent;本地留下墓碑但无需发送,也不能伪造 ACK 墓碑。 test('one transaction captures the first presence baseline even when its created element is later deleted', async () => { const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port); try { @@ -239,6 +273,7 @@ test('one transaction captures the first presence baseline even when its created }); +// 完全未发送的创建删除在本地抵消,仍保留两个历史单元和墓碑;无关 ACK 与重开不丢它,撤销恢复为新 incarnation。 test('unsent create/delete retires locally, survives unrelated ACK and reopening, and undo restores a fresh incarnation', async () => { const storage = storageFixture(), port = persistenceFixture(); let next=0, engine; const reopen=async()=>{if(engine)await engine.close();engine=(await open(storage,port,{createId:()=>`elision-${++next}`})).engine;}; @@ -260,6 +295,7 @@ test('unsent create/delete retires locally, survives unrelated ACK and reopening }); +// 本地抵消证据、工作墓碑和历史内容必须相互匹配;伪造或缺失会拒绝,未来解释版本单独报告不可解释。 test('recovery rejects forged or unsupported local completion proofs and missing working lifecycle marks', async () => { const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port); try { @@ -277,6 +313,7 @@ test('recovery rejects forged or unsupported local completion proofs and missing }); +// 保存失败的本地抵消只在内存成立,不虚构远程回执;重试保存并重开后仍使用原抵消证据且无网络请求。 test('failed durability keeps a local elision in memory without inventing a network success, then persists the same proof', async () => { const storage=storageFixture(),port=persistenceFixture();let engine=(await open(storage,port)).engine; try{ @@ -290,6 +327,7 @@ test('failed durability keeps a local elision in memory without inventing a netw }); +// 反复未发送的 undo/redo 仍可本地抵消并保留历次墓碑;首次真正发布恢复时使用第四代,跳过已取消代次。 test('repeated unsent undo/redo pairs remain local and the first published restore skips all canceled incarnations',async()=>{ const storage=storageFixture(),port=persistenceFixture();let next=0,engine=(await open(storage,port,{createId:()=>`repeat-${++next}`})).engine; try{ @@ -303,6 +341,7 @@ test('repeated unsent undo/redo pairs remain local and the first published resto }); +// 即使端口支持批量编译,也先抵消后面的闭合创建删除;仅发送前面真实编辑,批量编译器若收到闭合生命期就使测试失败。 test('batch-capable persistence sends an earlier real edit separately and never freezes the later closed lifetime',async()=>{ const storage=storageFixture(),port=persistenceFixture({compileBatch(){throw Error('closed lifetime entered the wire batch');}}),{engine,errors}=await open(storage,port); try{ @@ -312,73 +351,96 @@ test('batch-capable persistence sends an earlier real edit separately and never }finally{await engine.close();} }); +// 不主动触发重试的假时钟;定时器仅返回 token,使持久化竞争用例集中观察显式保存和在途网络。 const idleClock=()=>({now:()=>0,setTimer:()=>Symbol('idle'),clearTimer(){}}); +// 八次编辑各保留业务 Root,保存只按确认前缀发布 durableGeneration 回声;派发前镜像必须已含 dispatch-marked 请求和原体。 test('a pending write train preserves every business root and publishes one durable-generation echo per captured prefix',async()=>{ const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let sends=0;port.submit=async request=>{sends++;const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));await network.promise;return{outcome:'unresolved'};};try{ await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;let held=false,markers=[],through=start.runtime.ledger.durableGeneration;const unsubscribe=engine.editor.subscribe((_,root)=>{if(root.runtime.ledger.durableGeneration>through){markers.push(root.runtime.ledger.durableGeneration);through=root.runtime.ledger.durableGeneration;}}); storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}return commit(...args);}; for(let i=0;i<8;i++)assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'train-'+i}}).status,'applied'); + + // 保存队列挂起时记录调用方目标 generation;调度器可能再发布冻结请求,因此还要核对后续捕获前缀的确认回声。 const requested=engine.getSnapshot().runtime.ledger.generation,saving=engine.persist();await entered.promise;await tick();const expected=engine.getSnapshot().runtime.ledger.generation,queued=engine.getSnapshot().rootRevision-start.rootRevision;assert.equal(sends,0);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);gate.resolve();await saving;unsubscribe(); assert.deepEqual(markers,[...new Set([requested,expected])]);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,expected);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);const rows=storage.saved.slice(saved);assert.equal(rows.length,queued+markers.length);assert.deepEqual(rows.map(image=>image.rootRevision),Array.from({length:queued+markers.length},(_,i)=>start.rootRevision+i+1));assert.deepEqual(rows.slice(0,8).map(image=>image.payload.document.elements[0].data.title),Array.from({length:8},(_,i)=>'train-'+i));assert.equal(decodeRecovery(storage.image(),registry).runtime.ledger.durableGeneration,expected);await tick();assert.equal(sends,1); }finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();} }); +// 连续保存第四次失败时只确认前三个切点,内存仍显示最后编辑;重试从完整未确认后缀继续,修订不得跳号且不能提前发送。 test('a later quota failure records only confirmed generation and retries the full continuous suffix',async()=>{ const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let sends=0;port.submit=async request=>{sends++;const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));await network.promise;return{outcome:'unresolved'};};try{ await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;let writes=0,broken=true;storage.commit=async(...args)=>{writes++;if(writes===1){entered.resolve();await gate.promise;}if(broken&&writes===4)throw Error('quota-later');return commit(...args);}; for(let i=0;i<8;i++)engine.editor.dispatch({type:'element.data',target,patch:{title:'partial-'+i}});const saving=engine.persist();await entered.promise;await tick();const expected=engine.getSnapshot().runtime.ledger.generation,queued=engine.getSnapshot().rootRevision-start.rootRevision;assert.equal(sends,0);gate.resolve();await assert.rejects(saving,/quota-later/);const confirmed=storage.saved.at(-1);assert.equal(storage.saved.length-saved,3);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,confirmed.payload.runtime.ledger.generation);assert.ok(engine.getSnapshot().runtime.ledger.durableGenerationimage.rootRevision),Array.from({length:rows.length},(_,i)=>start.rootRevision+i+1));assert.equal(decodeRecovery(storage.image(),registry).document.elements.get('a').data.title,'partial-7');assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision); }finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();} }); +// commit 返回超前修订是无效证据,即使内存存储已经写入也不能推进确认;重试必须提交与原切点完全相同的镜像。 test('an invalid receipt cannot advance confirmed generation and the original cut remains retryable',async()=>{ const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let bad=true,held=false,sends=0;port.submit=async()=>{sends++;await network.promise;return{outcome:'unresolved'};};try{ - await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}const receipt=await commit(...args);return bad?{durableRootRevision:receipt.durableRootRevision+1}:receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'receipt-test'}});const saving=engine.persist();await entered.promise;await tick();gate.resolve();await assert.rejects(saving,/invalid_durable_receipt/);assert.equal(engine.getDurability().durableRootRevision,start.rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);assert.equal(sends,0);const first=storage.saved[saved];bad=false;await engine.persist();const retry=storage.saved[saved+1];assert.deepEqual(retry,first);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,engine.getSnapshot().runtime.ledger.generation); + await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}const receipt=await commit(...args);return bad?{durableRootRevision:receipt.durableRootRevision+1}:receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'receipt-test'}});const saving=engine.persist();await entered.promise;await tick();gate.resolve();await assert.rejects(saving,/invalid_durable_receipt/);assert.equal(engine.getDurability().durableRootRevision,start.rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);assert.equal(sends,0); + // 底层实际写入与上层认可证据分开:坏回执未获认可,下一次必须重试同一镜像,而不是假设可以跳到下个 Root。 + const first=storage.saved[saved];bad=false;await engine.persist();const retry=storage.saved[saved+1];assert.deepEqual(retry,first);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,engine.getSnapshot().runtime.ledger.generation); }finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();} }); +// 保存成功后因 owner 失效而无法发布确认时保留已验证回执;恢复 owner 后只补确认 Root,监听器重入 persist 也不能并发写盘或重写旧切点。 test('a failed durability publication retains its verified receipt through retry without rewriting saved business cuts',async()=>{ const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),network=deferred(),commit=storage.commit;let lose=true;port.submit=async()=>{await network.promise;return{outcome:'unresolved'};};try{ await engine.persist();const leader=storage.owner(),saved=storage.saved.length;storage.commit=async(...args)=>{const receipt=await commit(...args);if(lose&&args[1].image.rootRevision===engine.getSnapshot().rootRevision){lose=false;leader.active=false;}return receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'publication-retry'}});await assert.rejects(engine.persist(),/storage_leader_lost/);const confirmed=storage.saved.at(-1);assert.equal(engine.getDurability().durableRootRevision,confirmed.rootRevision);assert.ok(confirmed.payload.runtime.ledger.generation>engine.getSnapshot().runtime.ledger.durableGeneration);const count=storage.saved.length;leader.active=true;let activeWrites=0,maxWrites=0;const nested=[];storage.commit=async(...args)=>{activeWrites++;maxWrites=Math.max(maxWrites,activeWrites);try{await tick();return await commit(...args);}finally{activeWrites--;}};const unsubscribe=engine.editor.subscribe((_,root)=>{if(root.runtime.ledger.durableGeneration===confirmed.payload.runtime.ledger.generation)nested.push(engine.persist());});await engine.persist();await Promise.all(nested);unsubscribe();assert.equal(maxWrites,1);assert.equal(storage.saved.length,count+1);assert.equal(storage.saved.at(-1).rootRevision,confirmed.rootRevision+1);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,confirmed.payload.runtime.ledger.generation);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.ok(storage.saved.length>saved); }finally{const leader=storage.owner();if(leader)leader.active=true;network.resolve();storage.commit=commit;await engine.close();} }); +// 空写入泵刚排空后再产生编辑,新的 persist 调用仍必须等自己的目标 Root,不能复用旧的已完成等待。 test('a caller arriving after an empty writer drain still waits for its own newly published Root', {timeout:5000},async()=>{ const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;port.submit=async()=>{await network.promise;return{outcome:'unresolved'};};try{ await engine.persist();storage.commit=async(...args)=>{entered.resolve();await gate.promise;return commit(...args);};const empty=engine.persist();let edited,required;await new Promise(resolve=>queueMicrotask(()=>{engine.editor.dispatch({type:'element.data',target,patch:{title:'after-empty-writer'}});required=engine.getSnapshot().rootRevision;edited=engine.persist();resolve();}));assert.equal(await Promise.race([entered.promise.then(()=>'write'),edited.then(()=>'returned')]),'write');assert.ok(engine.getDurability().durableRootRevision=required);assert.equal(storage.image().payload.document.elements[0].data.title,'after-empty-writer'); }finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();} }); +// 已保存的 dispatch 前缀可以发送,无需等待更晚的本地 Root;但显式 persist 仍等待它捕获的目标修订全部保存。 test('a durable dispatch prefix can send while a later local Root is still waiting for storage', {timeout:10000},async()=>{ const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),body=deferred(),bodyEntered=deferred(),later=deferred(),laterEntered=deferred(),sent=deferred(),network=deferred(),commit=storage.commit;let bodyCut,after=Infinity,flushed=false;port.submit=async request=>{assert.ok(storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));sent.resolve();await network.promise;return{outcome:'unresolved'};};try{ await engine.persist();storage.commit=async(...args)=>{const image=args[1].image;if(bodyCut===undefined&&image.payload.runtime.ledger.requests.some(([,row])=>row.phase==='dispatch-marked')){bodyCut=image.rootRevision;bodyEntered.resolve();await body.promise;}if(image.rootRevision>after){laterEntered.resolve();await later.promise;}return commit(...args);};engine.editor.dispatch({type:'element.data',target,patch:{title:'first'}});const saving=engine.persist().then(()=>{flushed=true;});await bodyEntered.promise;after=engine.getSnapshot().rootRevision;engine.editor.dispatch({type:'element.geometry',target,patch:{x:42}});body.resolve();await laterEntered.promise;await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('durable request waited for a later Root')),2000);sent.promise.then(()=>{clearTimeout(timer);resolve();},reject);});assert.equal(flushed,false);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,42);later.resolve();await saving;assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision); }finally{body.resolve();later.resolve();network.resolve();storage.commit=commit;await engine.close();} }); +// 可选成功回执摘要必须与 ACK、history、ledger 一起发布;每个可恢复切点保持一致,重开不重传且冻结 body 不变。 test('an opted-in resolved receipt summary publishes with ACK/history/ledger and reopens without retransmission',async()=>{ const storage=storageFixture(),port=persistenceFixture({compactResolvedReceipt(request,receipt,applied){assert.equal(request.phase,'resolved');assert.equal(request.outcome,'applied');assert.equal(receipt.receipt,request.id);assert.equal(applied.document.elements.get('a').data.title,'summary');return{localReceiptVersion:1,id:request.id,versions:[...applied.versions]};}});let{engine}=await open(storage,port); try{engine.editor.dispatch({type:'element.data',target,patch:{title:'summary'}});await engine.settle();const snapshot=engine.getSnapshot(),request=[...snapshot.runtime.ledger.requests.values()][0],body=request.body;assert.equal(request.phase,'resolved');assert.deepEqual(snapshot.runtime.receipts.get(request.id),{localReceiptVersion:1,id:request.id,versions:[['canvas',2]]});assert.equal(snapshot.history.undo.length,1);for(const image of storage.saved){const restored=decodeRecovery(image,registry),saved=restored.runtime.ledger.requests.get(request.id);if(restored.runtime.receipts.get(request.id)?.localReceiptVersion===1){assert.equal(saved.phase,'resolved');assert.equal(restored.document.elements.get('a').data.title,'summary');assert.equal(restored.history.undo.length,1);}if(saved)assert.equal(saved.body,body);}await engine.close();({engine}=await open(storage,port));await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,body);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'summary');assert.equal(port.calls.filter(([kind])=>kind==='submit').length,1); }finally{await engine.close();} }); +// 摘要准备后最终模型校验失败,必须保留原始成功回执和 waiting 占用;校验恢复后查询修复,不第二次 submit。 test('a failed final publication keeps the raw receipt and waiting claim instead of the prepared summary',async()=>{ let blocked=false,compactions=0;const custom=createRegistry(builtinComponents().map(definition=>definition.id==='text'?{...definition,validate:data=>typeof data.title==='string'&&typeof data.text==='string'&&(!blocked||data.title!=='local')}:definition)),storage=storageFixture(),port=persistenceFixture({compactResolvedReceipt(request){compactions++;if(compactions===1)blocked=true;return{summary:request.id};}});let{engine}=await open(storage,port,{registry:custom}); try{engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();const snapshot=engine.getSnapshot(),request=[...snapshot.runtime.ledger.requests.values()][0];assert.equal(compactions,1);assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');assert.deepEqual(snapshot.runtime.receipts.get(request.id),{receipt:request.id});assert.equal(snapshot.document.elements.get('a').data.title,'local');assert.equal(snapshot.history.undo.length,1);blocked=false;await engine.close();({engine}=await open(storage,port,{registry:custom}));await engine.retry();await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.deepEqual(engine.getSnapshot().runtime.receipts.get(request.id),{summary:request.id});assert.equal(port.calls.filter(([kind])=>kind==='submit').length,1);assert.ok(port.calls.some(([kind])=>kind==='query')); }finally{blocked=false;await engine.close();} }); +// 未提供、抛错或返回空值的压缩器不影响合法 ACK;保留原始回执,只有失败压缩器通过错误回调报告。 test('receipt compaction is optional and invalid codecs cannot block a valid ACK or discard its raw payload',async()=>{ for(const compactResolvedReceipt of [undefined,()=>{throw Error('compact failed');},()=>undefined]){const storage=storageFixture(),port=persistenceFixture(compactResolvedReceipt?{compactResolvedReceipt}:{}),{engine,errors}=await open(storage,port);try{engine.editor.dispatch({type:'element.data',target,patch:{title:'keep raw'}});await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.phase,'resolved');assert.deepEqual(engine.getSnapshot().runtime.receipts.get(request.id),{receipt:request.id});assert.equal(engine.getSnapshot().runtime.conflicts.size,0);assert.equal(errors.length,compactResolvedReceipt?1:0);}finally{await engine.close();}} }); +// 大模型编码在 scheduler.yield 挂起期间继续编辑,首个镜像仍对应捕获的旧 Root;随后镜像按修订连续保存,冻结请求未落盘前不发送。 test('encoding yields preserve the captured Root, ordered durable prefix and send barrier', {timeout:15000},async()=>{ const previous=Object.getOwnPropertyDescriptor(globalThis,'scheduler'),entered=deferred(),resume=deferred();let hold=false,paused=false,yields=0; Object.defineProperty(globalThis,'scheduler',{configurable:true,value:{async yield(){yields++;if(hold&&!paused){paused=true;entered.resolve();await resume.promise;}}}}); let engine; try{ + + // 超过编码让出阈值的大模型;每个记录合法且 ID 独立,避免用编码失败替代调度竞争。 const rows=Array.from({length:17000},(_,i)=>({...original.elements.get('a'),id:i===0?'a':`encoding-${i}`})); const document=createDocument('project',[{id:'canvas',parent:null}],rows),storage=storageFixture(),port=persistenceFixture({async submit(request){port.calls.push(['submit',request]);const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));return{outcome:'unresolved'};}}); ({engine}=await open(storage,port,{document,clock:idleClock()}));await engine.persist(); const start=engine.getSnapshot().rootRevision,saved=storage.saved.length;hold=true; assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'captured-first'}}).status,'applied'); + + // 首个 persist 捕获旧 Root 后暂停编码,再允许第二次编辑产生新 Root;两次保存必须各自对应其捕获内容。 const captured=engine.getSnapshot(),first=engine.persist();await entered.promise; assert.equal(storage.saved.length,saved);assert.equal(port.calls.length,0); assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'edited-during-yield'}}).status,'applied'); const second=engine.persist();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'edited-during-yield'); resume.resolve();await Promise.all([first,second]); + + // 比较完整首镜像与同步 encodeRecovery(captured),验证让出主线程后没有混入后来编辑;同时检查修订序列无空洞。 const cuts=storage.saved.slice(saved);assert.ok(yields>0); assert.deepEqual(cuts.map(cut=>cut.rootRevision),Array.from({length:cuts.length},(_,i)=>start+i+1)); assert.deepEqual(cuts[0],encodeRecovery(captured)); diff --git a/tests/headless/orphan-files.test.mjs b/tests/headless/orphan-files.test.mjs index 4e4e546..bc2abbe 100644 --- a/tests/headless/orphan-files.test.mjs +++ b/tests/headless/orphan-files.test.mjs @@ -1,46 +1,81 @@ +/** + * 本地文件盘点、保留期和回收的引擎测试。是否孤立由所有已知引用及保守覆盖判断,不能仅看资源登记表。 + * 内存端口声明 atomic-v1 文件删除能力,随镜像保存模拟文件增加/删除;不验证真实文件系统的原子性。 + * 清理不改变业务文档和历史,退休身份则必须持久保留,防止稍后误用同 ID 的已删除字节。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,applyChanges} 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 {orphanCoverage} from '../../src/lib/canvas-runtime/orphan-files.ts'; +// 图片目标默认没有 assetId,各场景自行添加引用;文件 Map 可包含尚未登记但仍被元素引用的字节。 const registry=createRegistry(builtinComponents()),scope={accountId:'a',workspaceId:'w',projectId:'p'},node=(id,data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:'C',componentId:'image',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...data}}),original=createDocument('p',[{id:'C',parent:null}],[node('A')]); +// 用可控 Promise 挂起读字节,模拟显示借用、取消和 Root 变化与回收扫描交错。 const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};}; +// 将文件 Map、业务模型、内存镜像和服务替身组合,保留批次与请求记录用于检查回收是否越界。 function fixture(files=new Map(),document=original){ let image,owner,next=0,server=document,version=1,now=1000;const batches=[],requests=[],receipts=new Map(); - const storage={fileDeletion:'atomic-v1',async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;},async recover(){return image?structuredClone(image):null;},async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};},async readFile(leader,id){assert.equal(leader,owner);return files.get(id);},async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>ab?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}}; - const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}),async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};},async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},canonical:request=>receipts.get(request.id)}; + // commit 在同一替身调用中处理文件和镜像;scanFiles 必须匹配调用者捕获的 rootRevision,并按文件 ID 分页。 + const storage={fileDeletion:'atomic-v1', + // 只允许当前引擎持有文件仓库;释放时同时清除 owner 和 active 标记。 + async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;}, + // 返回克隆恢复镜像,文件字节由独立 files 仓库保留。 + async recover(){return image?structuredClone(image):null;}, + // 校验 owner,按批次写入/删除字节后保存镜像;记录整个 batch 供清理断言观察。 + async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};}, + // 读取本地 Blob;用例可包装这个方法在真实返回前挂起。 + async readFile(leader,id){assert.equal(leader,owner);return files.get(id);}, + // 只在指定持久化修订上盘点;排序后取最多 limit 项,用 nextAfter 表示尚有后页。 + async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>ab?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}}; + // 普通文档编辑通过文档端口发送并处理规范回执;清理本身不应该产生任何网络文档请求。 + const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}), + // 应用普通文档修改并保存该请求的规范模型;资源清理场景通过 requests 检查未误入此路径。 + async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};}, + // 只返回此替身保存过的请求结果;没有记录则保持 unresolved。 + async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};}, + // 根据请求取 ACK 基线,供无关编辑的正常同步使用。 + canonical:request=>receipts.get(request.id)}; + // 复用同一文件仓库与存储镜像以支持重开;advance 只推进保留期墙上时间,网络重试仍使用真实定时器。 return{files,batches,requests,storage,advance:ms=>{now+=ms;},image:()=>image,open:()=>openProjectEngine({scope,registry,document,versions:new Map([['C',1]]),storage,persistence:port,createId:()=>`id-${++next}`,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}})}; } +// 只读盘点按 limit 分页且保持 Root 引用;未登记但被元素引用的文件受保护,只有无引用文件经标记和保留期后删除,文档/history 不变。 test('inventory is bounded and read-only; unregistered but referenced files stay protected',async()=>{ const doc=createDocument('p',[{id:'C',parent:null}],[node('A',{assetId:'used'})]),f=fixture(new Map([['old',new Blob(['old'])],['used',new Blob(['used'])]]),doc),engine=await f.open();try{await engine.persist();const root=engine.getSnapshot(),first=await engine.inventoryLocalFiles({limit:1});assert.equal(engine.getSnapshot(),root);assert.equal(first.files[0].id,'old');assert.equal(first.files[0].ownership,'unregistered');assert.equal(first.nextAfter,'old');const second=await engine.inventoryLocalFiles({after:first.nextAfter,limit:1});assert.equal(second.files[0].id,'used');assert.ok(second.files[0].pins.some(owner=>owner.startsWith('element:')));assert.equal(second.nextAfter,undefined);assert.equal(f.requests.length,0); const marked=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(marked.markedIds,['old']);assert.deepEqual(marked.purgedIds,[]);assert.equal(f.image().formatVersion,5);f.advance(100);const result=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(result.purgedIds,['old']);assert.equal(f.files.has('old'),false);assert.equal(f.files.has('used'),true);assert.equal(engine.getSnapshot().document,root.document);assert.equal(engine.getSnapshot().history,root.history);assert.equal(f.requests.length,0);decodeRecovery(f.image(),registry); }finally{await engine.close();} }); +// 退休文件及 v5 镜像重开后仍禁止显示或新增引用,也不能通过清空 orphanFiles 抹去退休证据;无关编辑继续可用,静默降级镜像被拒绝。 test('retired orphan identity and v5 recovery survive reopening and reject new references or silent downgrade',async()=>{ const f=fixture(new Map([['old',new Blob(['old'])]]));let engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:0});const file=engine.getSnapshot().runtime.orphanFiles.get('old');assert.ok(file.retirement.purgedAt!==undefined);for(const formatVersion of [1,2,3,4])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);await engine.close();engine=await f.open();await assert.rejects(engine.displayResource('old'),/local_file_retired/);assert.equal(engine.editor.dispatch({type:'element.create',element:node('bad',{assetId:'old'})}).code,'local_file_retired');assert.equal(engine.editor.updateRuntime(previous=>({...previous,orphanFiles:new Map()})).code,'local_file_retired');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.files.has('old'),false); }finally{await engine.close();} }); +// 文件大小与 MIME 相同也可能换了字节;digest 改变必须重置无引用起点,重新等待完整保留期才能删除。 test('changed orphan bytes reset the retention interval even when size and MIME stay equal',async()=>{ const f=fixture(new Map([['old',new Blob(['aa'])]])),engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:100});const digest=engine.getSnapshot().runtime.orphanFiles.get('old').digest;f.advance(100);f.files.set('old',new Blob(['bb']));const changed=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(changed.changedIds,['old']);assert.deepEqual(changed.purgedIds,[]);const record=engine.getSnapshot().runtime.orphanFiles.get('old');assert.notEqual(record.digest,digest);assert.equal(record.retirement.unreferencedAt,1100);f.advance(100);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,['old']);}finally{await engine.close();} }); +// 显示读取一开始即借用并清除软退休标记,不必等 URL 返回;释放后可重新计时,显式 resourcePins 再次阻止删除。 test('display borrowing and explicit owners reset soft markers before collection',async()=>{ const f=fixture(new Map([['old',new Blob(['data'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const opening=engine.displayResource('old');await entered.promise;assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await opening;handle.release();await engine.collectOrphanFiles({retentionMs:100});engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['export',['old']]])}));assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);}finally{gate.resolve();await engine.close();} }); +// 哈希字节预算耗尽要保留 nextAfter 继续扫描;读字节期间 Root 改变会使验证过期,此轮不能删除任何文件。 test('hash budgets preserve pagination progress and a changed Root invalidates read-only verification',async()=>{ const f=fixture(new Map([['a',new Blob(['a'])],['b',new Blob(['bb'])],['c',new Blob(['c'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{const first=await engine.collectOrphanFiles({retentionMs:100,maxHashBytes:1});assert.deepEqual(first.markedIds,['a']);assert.equal(first.nextAfter,'a');assert.equal(first.hashBudgetExhausted,true);assert.equal(first.scanFinished,false);const second=await engine.collectOrphanFiles({retentionMs:100,after:first.nextAfter,maxHashBytes:3});assert.deepEqual(second.markedIds,['b','c']);assert.equal(second.scanFinished,true); f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const collecting=engine.collectOrphanFiles({retentionMs:0});await entered.promise;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'changed while hashing'}}).status,'applied');gate.resolve();await assert.rejects(collecting,/stale_file_inventory/);assert.equal(f.files.size,3); }finally{gate.resolve();await engine.close();} }); +// abort 后迟到字节不得发布盘点结果;删除批次保存失败时保留原清理切点及资源访问屏障,persist 重试完成同一次删除。 test('cancelling verification never publishes late byte results and failures retain the original purge cut',async()=>{ const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile,cancel=new AbortController();let fail=false;const commit=f.storage.commit;try{f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const task=engine.collectOrphanFiles({retentionMs:0,signal:cancel.signal});await entered.promise;cancel.abort();await assert.rejects(task,/file_inventory_aborted/);gate.resolve();await Promise.resolve();assert.equal(engine.getSnapshot().runtime.orphanFiles?.size??0,0);f.storage.readFile=read;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectOrphanFiles({retentionMs:0}),/disk failure/);assert.ok(f.files.has('old'));await assert.rejects(engine.displayResource('old'),/resource_collection_in_progress/);fail=false;await engine.persist();assert.equal(f.files.has('old'),false);assert.ok(decodeRecovery(f.image(),registry).runtime.orphanFiles.get('old').retirement.purgedAt!==undefined); }finally{fail=false;gate.resolve();await engine.close();} }); +// 运行任务、恢复审计、未完成导入和复制准入都是不透明生产者;即使已知资源表为空,引用覆盖仍不完整,不能据此回收。 test('opaque producers block orphan collection even when the known local resource map is empty',async()=>{ const f=fixture(),engine=await f.open();try{const root=engine.getSnapshot(),coverage=runtime=>orphanCoverage({...root,runtime},registry,new Map(),new Map());assert.equal(coverage({...root.runtime,runs:new Map([['run',{id:'run'}]])}).complete,false);assert.ok(coverage({...root.runtime,recoveries:new Map([['audit',{}]])}).blockers.includes('recovery_audits'));assert.ok(coverage({...root.runtime,imports:new Map([['job',{id:'job',status:'paused',source:{fileId:'source'}}]])}).blockers.includes('unfinished_imports'));assert.ok(orphanCoverage(root,registry,new Map(),new Map([['copy-admission',[]]])).blockers.includes('copy_admission')); }finally{await engine.close();} }); +// 复制准入在 ImportJob 出现前已构成保护;即使调用带已取消 signal,也保守清除软退休标记,下一次扫描从当前时间重新计时。 test('a cancelled copy admission resets orphan retention before any ImportJob is published',async()=>{ const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),cancel=new AbortController();try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);cancel.abort();await assert.rejects(engine.imports.duplicate([{id:'A',incarnation:1}],{signal:cancel.signal}),/copy_aborted/);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,[]);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement.unreferencedAt,1100);}finally{await engine.close();} }); diff --git a/tests/headless/publication-guard.test.mjs b/tests/headless/publication-guard.test.mjs index 32d3096..b6e4d15 100644 --- a/tests/headless/publication-guard.test.mjs +++ b/tests/headless/publication-guard.test.mjs @@ -1,13 +1,24 @@ +/** + * 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); }); diff --git a/tests/headless/session-lifecycle.test.mjs b/tests/headless/session-lifecycle.test.mjs index 95730a0..b02a612 100644 --- a/tests/headless/session-lifecycle.test.mjs +++ b/tests/headless/session-lifecycle.test.mjs @@ -1,16 +1,31 @@ +/** + * ViewSession 的身份、工具、导航和释放边界测试。交互预览属于会话,不能借切换工具或清理旧视图修改业务文档。 + * 多个 view 共享 editor,但 mode 和 tool 各自保存;手势取消与失效 mode 清理必须作为一次最终状态通知。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts'; +// 构造带固定 incarnation 和完整几何的节点;owner 和 component 可按分区或类型场景替换。 const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); +// 根 canvas 中有两个文本目标、连线与子分区入口;v、w 两个视图用于检查会话间隔离及过期 detach。 function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),detach=sessions.attach('v');sessions.attach('w');return{editor,sessions,detach};} +// 同 ID 原子替换为第二代元素后,旧 resize mode 不得继承到新身份。 test('an atomic restored incarnation cannot inherit the old element editing mode',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);}); +// 手势所属视图切到 pan 时一次发布新工具和取消后的预览;pan 不接受新变换或连线手势,业务 Root 保持原引用。 test('switching tools clears the owned preview atomically and blocks fresh transform/connection gestures in pan',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot();const observed=[];sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.tool('v','pan');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').tool,'pan');assert.equal(observed[0].gesture,null);assert.equal(observed[0].lastCancelReason,'tool_changed');assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'tool_not_select');assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).code,'tool_not_select');assert.strictEqual(editor.getSnapshot(),before);}); +// 同名视图重新 attach 后,旧的 detach 回调再次调用也不能移除新实例。 test('old detach cleanup cannot remove a freshly attached view with the same ID',()=>{const{sessions,detach}=fixture();detach();sessions.attach('v');detach();assert.equal(sessions.getSnapshot().views.has('v'),true);}); +// 普通文字修改保留两个视图的 mode,owner 迁移则清空;不存在的元素不能进入 mode,清理其空 mode 是无变化操作。 test('mode lifetime follows identity and placement while ordinary content edits preserve it',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','editing');sessions.mode('w','A','resize');editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'new content'}});assert.equal(sessions.getSnapshot().views.get('v').modes.get('A'),'editing');assert.equal(sessions.getSnapshot().views.get('w').modes.get('A'),'resize');assert.equal(editor.dispatch({type:'element.move',target:{id:'A',incarnation:1},canvasId:'child'}).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.size,0);assert.equal(sessions.getSnapshot().views.get('w').modes.size,0);assert.throws(()=>sessions.mode('v','missing','editing'),/element_missing/);const before=sessions.getSnapshot();sessions.mode('v','missing',null);assert.strictEqual(sessions.getSnapshot(),before);}); +// 目标换代时只通知一次,同时清除 mode 和变换预览,并报告 target_changed。 test('target replacement publishes mode invalidation and gesture cancellation together',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('v').modes.has('A'),false);assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'target_changed');}); +// 其他视图切工具不影响当前手势,重复选同工具不发布;未知工具被拒绝,连线手势也受所属视图工具约束。 test('another view tool change preserves the gesture, and same-tool selection is idempotent',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;sessions.tool('w','pan');assert.strictEqual(sessions.getSnapshot().gesture,gesture);const before=sessions.getSnapshot();sessions.tool('v','select');assert.strictEqual(sessions.getSnapshot(),before);assert.throws(()=>sessions.tool('v','unknown'),/invalid_tool/);assert.strictEqual(sessions.getSnapshot(),before);sessions.cancel();sessions.beginConnection('w',{id:'edge',incarnation:1});assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.tool('w','select');assert.equal(sessions.beginConnection('w',{id:'edge',incarnation:1}).enabled,true);sessions.tool('w','pan');assert.equal(sessions.getSnapshot().connectionGesture,null);}); +// 导航和 detach 都只暴露最终会话状态;不能出现目标 canvas 已换但旧手势仍在的中间通知。 test('canvas navigation and detach notify only a final session, never a new target with the old gesture',()=>{const{sessions,detach}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let observed=[];const stop=sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.canvas('v','child');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').canvasId,'child');assert.equal(observed[0].gesture,null);sessions.canvas('v','root');sessions.begin('v',[{id:'A',incarnation:1}]);observed=[];detach();assert.equal(observed.length,1);assert.equal(observed[0].views.has('v'),false);assert.equal(observed[0].gesture,null);stop();}); +// dispose 先封闭入口再通知清理,因此监听器重入不能创建手势;反复 dispose 不再通知或留下视图。 test('dispose fences reentrant admission and cannot leave a gesture or accept repeated cleanup',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let attempt,calls=0;sessions.subscribe(()=>{calls++;attempt=sessions.begin('v',[{id:'A',incarnation:1}]);});sessions.dispose();assert.equal(attempt.enabled,false);assert.equal(attempt.code,'view_detached');assert.equal(calls,1);assert.equal(sessions.getSnapshot().views.size,0);assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.dispose();assert.equal(calls,1);}); +// 类型或 dataVersion 改变都会使原 mode 失效;非字符串 mode 被拒绝且不替换会话快照。 test('component/version changes invalidate modes while invalid tags never enter a view',()=>{for(const patch of [{componentId:'plain-text'},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.mode('v','A','editing');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);const snapshot=sessions.getSnapshot();assert.throws(()=>sessions.mode('v','B',{}),/invalid_mode/);assert.strictEqual(sessions.getSnapshot(),snapshot);}});