docs(tests): explain copy mappings and resumable planning checks

This commit is contained in:
2026-11-12 17:28:04 +08:00
parent 7ed36b1ae0
commit c43af2ccb9
7 changed files with 12 additions and 0 deletions
+2
View File
@@ -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 源在不同切片下保持相同字节;预算边界取消时拒绝而非返回部分输出。
@@ -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/);
});
@@ -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<expected.changes.length;i++)assert.deepEqual((await source.read(i,signal())).value.change,expected.changes[i]);for(const[id,record]of document.elements)assert.deepEqual(await snapshot.readElement(id,signal()),record);await assert.rejects(decodeCopySnapshot(bytes,'project',registry,signal()),/invalid_copy_snapshot/);
@@ -27,6 +28,7 @@ test('indexed snapshots read topology before payload, preserve raw records on de
// 损坏尾部 edge 正文不妨碍打开索引及读取 A;请求损坏记录或正文 ID 与索引不符时才明确拒绝。
test('index decoding defers corrupt or invalid component content until the corresponding record is requested',async()=>{
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/);
});
+1
View File
@@ -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/);
@@ -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]);};
@@ -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.length<every.checkpoints.length/4);const phases=new Set(paced.checkpoints.map(state=>state.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<paced.checkpoints.length;i++)assert.ok(paced.checkpoints[i].steps-paced.checkpoints[i-1].steps<=2048+64);assert.equal(document.elements.size,2003);
});
+1
View File
@@ -26,6 +26,7 @@ test('lazy copy source seeks mapped records with exact synchronous geometry and
// 准备映射时不必转换未知尾节点;懒源先输出合法项,再为 opaque 记录报告 copy_schema_unavailable,而同步全计划会整体拒绝。
test('mapping preparation completes before converting unsupported tail records; source reports per-record failure',async()=>{
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/);
});