diff --git a/tests/headless/copy-budget.test.mjs b/tests/headless/copy-budget.test.mjs index b74fdf0..e9cd8b7 100644 --- a/tests/headless/copy-budget.test.mjs +++ b/tests/headless/copy-budget.test.mjs @@ -16,6 +16,7 @@ const node=i=>({id:`n-${i}`,incarnation:1,ownerCanvasId:'canvas',componentId:'te // 512 个节点及一个墓碑组成统一快照,既覆盖批量处理,也覆盖删除身份信息。 const document=createDocument('project',[{id:'canvas',parent:null}],Array.from({length:512},(_,i)=>node(i)),new Map([['retired',2]])); // cheap 时钟固定为零,costly 每次前进 10;记录 yield 次数,区分按预算让出与固定条数休眠。 +// yieldTask 只累计次数,不经过真实浏览器帧;cheap/costly 是可重复的调度输入,不是运行耗时基准。 function scheduling(slow=false){let yields=0,tick=0;return{options:{now:()=>slow?(++tick)*10:0,yieldTask:async()=>{yields++;}},get yields(){return yields;}};} // 普通/索引格式在便宜和昂贵时间切片下编码完全相同;解码保留 512 节点及墓碑,索引格式还读取末项核对内容。 @@ -38,6 +39,7 @@ test('large records consume work budget and coarse clocks still yield without ch // 拒绝非正或非有限 frameBudgetMs;yieldTask 的宿主错误保留原对象,不转换成伪造的复制取消。 test('copy budget rejects invalid scheduling and preserves host scheduling failures',async()=>{ for(const frameBudgetMs of [0,-1,NaN,Infinity])await assert.rejects(encodeIndexedCopySnapshot(document,signal(),{frameBudgetMs}),error=>error.code==='invalid_copy_budget'); + // 用对象身份比较宿主异常,确保调度器错误没有被包装成 copy_aborted 或丢失具体原因。 const failure=Error('host unavailable');let time=0;await assert.rejects(encodeCopySnapshot(document,signal(),{now:()=>time+=10,yieldTask:async()=>{throw failure;}}),error=>error===failure); }); // 旧式 prepared duplicate 源在不同切片下保持相同字节;预算边界取消时拒绝而非返回部分输出。 diff --git a/tests/headless/copy-reference-source.test.mjs b/tests/headless/copy-reference-source.test.mjs index e9ebeed..5786627 100644 --- a/tests/headless/copy-reference-source.test.mjs +++ b/tests/headless/copy-reference-source.test.mjs @@ -17,8 +17,10 @@ test('durable copy source includes mapped port dependencies and refuses an old s const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame',{ports:[{nodeId:'A',id:'in',direction:'input',handle:'left'}]}),node('A','S')]);let next=0; const plan=planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=>`new-${++next}`}),source=await encodeDuplicateSource(document,registry,plan,new AbortController().signal),rows=(await source.text()).trim().split('\n').map(row=>JSON.parse(row)),value=rows.find(row=>row.key==='element:F'); assert.ok(value.dependencies.includes('element:A'));assert.equal(value.change.after.data.ports[0].nodeId,plan.elementIds.A); + // 稳定映射表与源行依赖分别校验:一个决定新身份,另一个决定这些身份何时可安全加入。 const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([id,mapped])=>[`element:${id}`,mapped]),...Object.entries(plan.canvasIds).map(([id,mapped])=>[`canvas:${id}`,mapped])]),job={source:{kind:'duplicate-ndjson'},stableIds}; const line={value};assert.equal(prepareDuplicateImport(line,job,key=>stableIds[key],registry).key,'element:F'); + // 两次篡改各从合法行复制:stale 回指原节点,missing 保持新引用但删依赖,分别隔离两种拒绝原因。 const stale=structuredClone(value);stale.change.after.data.ports[0].nodeId='A';stale.dependencies=stale.dependencies.filter(key=>key!=='element:A');assert.throws(()=>prepareDuplicateImport({value:stale},job,key=>stableIds[key],registry),/copy_external_reference/); const missing=structuredClone(value);missing.dependencies=missing.dependencies.filter(key=>key!=='element:A');assert.throws(()=>prepareDuplicateImport({value:missing},job,key=>stableIds[key],registry),/invalid_duplicate_source/); }); diff --git a/tests/headless/copy-snapshot-index.test.mjs b/tests/headless/copy-snapshot-index.test.mjs index 589ec4e..23ff10f 100644 --- a/tests/headless/copy-snapshot-index.test.mjs +++ b/tests/headless/copy-snapshot-index.test.mjs @@ -19,6 +19,7 @@ const lines=async bytes=>(await bytes.text()).trimEnd().split('\n'); // 索引只保留拓扑所需单位信息,不含正文或 ports;按需只读 edge 也能生成正确边,全部记录最终与同步计划一致。 test('indexed snapshots read topology before payload, preserve raw records on demand and reproduce exact mapped output',async()=>{ const bytes=await encodeIndexedCopySnapshot(document,signal()),snapshot=await openIndexedCopySnapshot(bytes,'project',registry,signal());assert.equal(JSON.stringify([...snapshot.document.elements]).includes('unparsed-payload'),false);assert.equal(snapshot.document.elements.get('F').data.ports,undefined);assert.deepEqual(snapshot.document.elements.get('F').data.presentation,{unitScale:2,contentScale:.75});assert.deepEqual([...snapshot.document.tombstones],[...document.tombstones]);assert.throws(()=>validateDocument(snapshot.document,registry)); + // loaded 只记录懒正文回调:拓扑规划无需调用它;先读末尾边再读全量,验证非顺序访问的正确性。 const loaded=[];let next=0;const state=await prepareDuplicateStream(snapshot.document,registry,[{id:'F',incarnation:2}],{createId:()=>`copy-${++next}`},{sourceToken:'a'.repeat(64),schemaToken:copyRegistrySignature(snapshot.document,registry),topologyOnly:true});assert.equal(loaded.length,0);const source=createDuplicatePlanSource(snapshot.document,registry,state,'indexed',async(id,abort)=>{loaded.push(id);return snapshot.readElement(id,abort);});let reference=0;const expected=planDuplicate(document,registry,[{id:'F',incarnation:2}],{createId:()=>`copy-${++reference}`}); const last=await source.read(expected.changes.length-1,signal());assert.deepEqual(last.value.change,expected.changes.at(-1));assert.deepEqual(loaded,['edge'],'edge geometry decoded unrelated node payload'); for(let i=0;i{ const rows=await lines(await encodeIndexedCopySnapshot(document,signal())),last=rows.length-1;rows[last]='!'+rows[last].slice(1);const corrupt=new Blob([rows.join('\n')+'\n']),snapshot=await openIndexedCopySnapshot(corrupt,'project',registry,signal());assert.equal((await snapshot.readElement('A',signal())).id,'A');await assert.rejects(snapshot.readElement('edge',signal()),/invalid_copy_snapshot/); + // A→Z 的字符串等长,因此正文身份变化不改变后续偏移;该断言隔离正文 ID 与索引不一致。 const clean=await lines(await encodeIndexedCopySnapshot(document,signal())),i=clean.findIndex(row=>row.startsWith('{"kind":"element","record":{"id":"A"'));clean[i]=clean[i].replace('"id":"A"','"id":"Z"');const mismatch=await openIndexedCopySnapshot(new Blob([clean.join('\n')+'\n']),'project',registry,signal());await assert.rejects(mismatch.readElement('A',signal()),/invalid_copy_snapshot/); }); diff --git a/tests/headless/copy-snapshot.test.mjs b/tests/headless/copy-snapshot.test.mjs index 50f45d4..e8f5db8 100644 --- a/tests/headless/copy-snapshot.test.mjs +++ b/tests/headless/copy-snapshot.test.mjs @@ -23,6 +23,7 @@ test('snapshot decoding rejects malformed, future, duplicate, invalid and trunca }); // 准备检查点需有效选择、解释器版本、唯一已分配 ID 且没有发布进度;不具备对应解释器的 runner 不能运行。 test('preparation checkpoints require a matching interpreter, valid selection, unique allocations and no published progress',()=>{ + // validateImportJob 检查持久结构和绑定,不运行规划;未配置解释器的 runner 另行拒绝启动。 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/); diff --git a/tests/headless/duplicate-checkpoint.test.mjs b/tests/headless/duplicate-checkpoint.test.mjs index 6d5d2f2..e07b276 100644 --- a/tests/headless/duplicate-checkpoint.test.mjs +++ b/tests/headless/duplicate-checkpoint.test.mjs @@ -20,6 +20,7 @@ test('each durable planning phase resumes with the same deep-copy plan and no re const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done']; for(const phase of phases){let next=0,planner=createDuplicatePlanner(document,registry,targets(),{createId:()=>`new-${++next}`},binding),checkpoint; for(let i=0;i<10000;i++){const done=planner.step(),state=planner.checkpoint();if(state.phase===phase&&(phase!=='emit'||state.emitted>0)&&(phase!=='emit-edges'||state.emittedEdges>0)){checkpoint=state;break;}if(done)break;} + // 从 JSON 重建检查点及新的来源包络,沿用分配计数器;重复分配会让最终映射或总计数偏离同步对照。 assert.ok(checkpoint,`missing ${phase} checkpoint`);validateDuplicateCheckpoint(JSON.parse(JSON.stringify(checkpoint)));const before=JSON.stringify(checkpoint),allocated=next,source=createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()]),resumed=createDuplicatePlanner(source,registry,targets(),{createId:()=>`new-${++next}`},{...binding,checkpoint:JSON.parse(before)}),actual=drain(resumed);let reference=0;const expected=planDuplicate(document,registry,targets(),{createId:()=>`new-${++reference}`});assert.deepEqual(actual,expected,phase);assert.equal(next,reference,`identity replay in ${phase}`);assert.equal(JSON.stringify(checkpoint),before);assert.ok(next>=allocated);assert.ok(!before.includes('literal-data-not-in-checkpoint')); } }); @@ -49,6 +50,7 @@ test('a first planning checkpoint encodes cooperatively and is isolated from lat const {hasDetachedJsonData}=await import('../../src/lib/canvas-core/json-data.ts'); const rows=Array.from({length:1000},(_,i)=>({...document.elements.get('A'),id:'checkpoint-'+i,ownerCanvasId:'root'})); const source=createDocument('large',[{id:'root',parent:null}],rows),planner=createDuplicatePlanner(source,registry,rows.map(row=>({id:row.id,incarnation:row.incarnation})),{createId:()=>{throw Error('not allocating yet');}},binding); + // 捕获后先保留原 JSON 字节,再探测封存期间的字符串编码次数;探针只改变计数,仍委托原 stringify。 const checkpoint=planner.checkpoint(),before=JSON.stringify(checkpoint),stringify=JSON.stringify;let encoded=0,atFirst,tick=0,yields=0; assert.equal(hasDetachedJsonData(checkpoint),true); JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('checkpoint-'))encoded++;return Reflect.apply(stringify,this,[value,...args]);}; diff --git a/tests/headless/duplicate-scheduling.test.mjs b/tests/headless/duplicate-scheduling.test.mjs index bf3b277..4347009 100644 --- a/tests/headless/duplicate-scheduling.test.mjs +++ b/tests/headless/duplicate-scheduling.test.mjs @@ -21,6 +21,7 @@ const selection=[{id:'F',incarnation:1}],options=()=>{let id=0;return{createId:( test('cooperative deep planning yields throughout scanning, allocation and remapping and matches the synchronous plan',async()=>{ let slices=0;const plan=await planDuplicateAsync(document,registry,selection,options(),{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{slices++;}}); assert.ok(slices>200);assert.deepEqual(plan,planDuplicate(document,registry,selection,options()));assert.equal(validJson(plan),true); + // 规划只生成 changes;显式 applyChanges 后再验证文档,并检查复制入边仍从原上级 C 出发。 const after=applyChanges(document,plan.changes);validateDocument(after,registry); assert.equal(after.elements.get(plan.elementIds.edge).source.elementId,'C');assert.equal(after.elements.get(plan.elementIds.edge).target.elementId,plan.elementIds.F); assert.equal(Object.isFrozen(plan.changes[0].after.data),true);assert.equal(document.elements.size,2003); @@ -75,6 +76,7 @@ test('mapping output preserves special source IDs as own JSON fields without pro // 对比每次保存与间隔保存:最终映射、所有 yield 和阶段覆盖一致,而全量检查点显著减少,最大步距仍受限。 test('stream checkpoint cadence reduces full snapshots while preserving every task yield, phase and final mapping',async()=>{ + // now 固定为 0,所以 50ms 间隔不会自行到期;阶段切换和步数上限仍会保存,yield 次数必须相同。 const binding={sourceToken:'captured',schemaToken:'schema'},run=async interval=>{let yields=0;const checkpoints=[],state=await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>0,maxStepsPerSlice:64,checkpointIntervalMs:interval,maxStepsPerCheckpoint:2048,yieldTask:async()=>{yields++;},checkpoint:async state=>{checkpoints.push(state);}});return{state,yields,checkpoints};},every=await run(0),paced=await run(50); assert.deepEqual(paced.state,every.state);assert.equal(paced.yields,every.yields);assert.ok(paced.checkpoints.lengthstate.phase));for(const state of every.checkpoints)assert.ok(phases.has(state.phase));assert.equal(paced.checkpoints.at(-1).phase,'emit');assert.ok(paced.checkpoints.every(Object.isFrozen));for(let i=1;i{ const sourceDocument=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('good','S'),node('opaque','S','unknown-widget')]),state=await prepare(sourceDocument),source=createDuplicatePlanSource(sourceDocument,registry,state,'snapshot'); + // 逐行收集错误而非整体中止,确认合法 good 先输出;这里只读源,不提交 editor 或发送后端请求。 const lines=[];for(let i=0;;i++){const line=await source.read(i,signal);if(!line)break;lines.push(line);}assert.ok(lines[0].value);assert.ok(lines.some(line=>line.value?.change.after?.data?.title==='good'));assert.equal(lines.at(-1).error,'copy_schema_unavailable');assert.equal(lines.at(-1).key,'element:opaque');assert.throws(()=>planDuplicate(sourceDocument,registry,[{id:'F',incarnation:1}],{createId:()=>crypto.randomUUID()}),/copy_schema_unavailable/); });