/** * 完整复制快照及准备期任务检查点测试。正文、canvas、墓碑和未知字段需完整往返,并与来源项目绑定。 * 准备期尚未发布内容,任务不能同时带运行游标或 committedSourceKeys;格式/解释器不匹配必须拒绝。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts'; import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts'; import {createImportJob,validateImportJob,createImportRunner} from '../../src/lib/canvas-runtime/imports.ts'; // 第二代节点、未知嵌套数据与第三代墓碑组成基线,避免只验证最简单创建对象的往返。 const registry=createRegistry(builtinComponents()),node={id:'A',incarnation:2,structure:'node',ownerCanvasId:'P',componentId:'text',dataVersion:1,data:{title:'原始',text:'内容',unknown:{preserved:true}},geometry:{x:0,y:0,width:100,height:100,rotation:0}},document=createDocument('project',[{id:'P',parent:null}],[node],new Map([['deleted',3]])); // 每次创建独立未取消信号;取消场景显式提供已 abort 的 controller。 const signal=()=>new AbortController().signal; // 完整快照往返保持元素/canvas/墓碑及未知数据,输出元素数据冻结;错误项目 ID 拒绝。 test('copy snapshot roundtrip preserves the complete source, identity lifecycles and unknown data',async()=>{ const bytes=await encodeCopySnapshot(document,signal()),decoded=await decodeCopySnapshot(bytes,'project',registry,signal());assert.deepEqual([...decoded.elements],[...document.elements]);assert.deepEqual([...decoded.canvases],[...document.canvases]);assert.deepEqual([...decoded.tombstones],[...document.tombstones]);assert.ok(Object.isFrozen(decoded.elements.get('A').data));await assert.rejects(decodeCopySnapshot(bytes,'different-project',registry,signal()),/invalid_copy_snapshot/); }); // 空值、错误 JSON、未来版本、未知行、重复记录和取消都不能生成文档;已取消编码也必须立即拒绝。 test('snapshot decoding rejects malformed, future, duplicate, invalid and truncated sources without inventing a document',async()=>{ for(const text of ['','{}','{bad}',JSON.stringify({kind:'header',version:2,projectId:'project'}),JSON.stringify({kind:'header',version:1,projectId:'project'})+'\n'+JSON.stringify({kind:'other',record:{}})])await assert.rejects(decodeCopySnapshot(new Blob([text]),'project',registry,signal())); const original=await(await encodeCopySnapshot(document,signal())).text(),row=original.trim().split('\n').at(-1);await assert.rejects(decodeCopySnapshot(new Blob([original+row]),'project',registry,signal()),/invalid_copy_snapshot/); const controller=new AbortController();controller.abort();await assert.rejects(encodeCopySnapshot(document,controller.signal),/copy_aborted/);await assert.rejects(decodeCopySnapshot(new Blob([original]),'project',registry,controller.signal),/copy_aborted/); }); // 准备检查点需有效选择、解释器版本、唯一已分配 ID 且没有发布进度;不具备对应解释器的 runner 不能运行。 test('preparation checkpoints require a matching interpreter, valid selection, unique allocations and no published progress',()=>{ const base={...createImportJob({id:'job',projectId:'project',sourceFingerprint:'snapshot',parserVersion:1,source:{kind:'duplicate-snapshot',sha256:'a'.repeat(64),fileId:'file',byteLength:10,targetCanvasId:'P',copy:{sourceRevision:0,roots:[]}}}),status:'preparing',preparation:{version:1,registrySignature:'registry-v1',selection:[{id:'A',incarnation:2}],worldOffset:{x:32,y:32},allocationIds:['new-A'],steps:512}};validateImportJob(base); for(const patch of [{preparation:undefined},{preparation:{...base.preparation,version:5}},{preparation:{...base.preparation,allocationIds:['same','same']}},{preparation:{...base.preparation,selection:[]}},{cursor:1},{committedSourceKeys:['element:A']},{status:'running'},{source:{...base.source,snapshotVersion:3}},{source:{...base.source,snapshotVersion:2}}])assert.throws(()=>validateImportJob({...base,...patch})); assert.throws(()=>createImportRunner({getJob:()=>base}),/import_interpreter_unavailable/); });