docs(tests): explain deep copy sources checkpoints and input probe
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* 持久复制源的引用依赖与批次选择测试。映射必须和声明的引用路径使用同一遍历规则,不能仍指向原来的子对象。
|
||||
* 外部保留引用还需校验目标生命周期;循环依赖要作为完整强连通组加入,不能靠拆边绕过依赖或容量。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,defineComponent} from '../../src/lib/canvas-core/index.ts';
|
||||
import {encodeDuplicateSource,prepareDuplicateImport,assertDuplicateExternal} from '../../src/lib/canvas-runtime/import-duplicate.ts';
|
||||
import {selectImportGroups} from '../../src/lib/canvas-runtime/import-groups.ts';
|
||||
// 可指定类型/归属/数据的第一代节点样本,用于 frame 端口及自定义对象数组引用。
|
||||
const node=(id,owner='P',componentId='text',data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'',...data},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||
// 使用内置复制 schema,frame.ports 中的 nodeId 被识别为元素引用,端口局部 ID 仍是普通数据。
|
||||
const registry=createRegistry(builtinComponents());
|
||||
|
||||
// 复制 frame 的持久源须声明子节点依赖并使用新 nodeId;篡改为旧引用或删掉依赖都在导入准备时拒绝。
|
||||
test('durable copy source includes mapped port dependencies and refuses an old source still targeting original children',async()=>{
|
||||
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');
|
||||
@@ -16,6 +23,7 @@ test('durable copy source includes mapped port dependencies and refuses an old s
|
||||
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/);
|
||||
});
|
||||
|
||||
// 自定义 rows.*.node 外部引用保持原目标并记录 incarnation;目标后来换代,晚期 guard 必须拒绝旧引用。
|
||||
test('object-array external references share the same traversal for mapping and late lifecycle guards',async()=>{
|
||||
const linked=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['rows','*','node'],target:'element',external:'preserve'}],omit:[]}}),r=createRegistry([...builtinComponents(),linked]),document=createDocument('P',[{id:'P',parent:null}],[node('C'),node('L','P','linked',{rows:[{node:'C',literal:'C'}]})]);
|
||||
const plan=planDuplicate(document,r,[{id:'L',incarnation:1}],{createId:()=> 'new-L'}),rows=(await(await encodeDuplicateSource(document,r,plan,new AbortController().signal)).text()).trim().split('\n').map(row=>JSON.parse(row)),job={source:{kind:'duplicate-ndjson'},stableIds:{'element:L':'new-L'}};
|
||||
@@ -23,7 +31,9 @@ test('object-array external references share the same traversal for mapping and
|
||||
const changed=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(row=>row.id==='C'?{...row,incarnation:2}:row));assert.throws(()=>assertDuplicateExternal({value:rows[0]},job,changed,r),/copy_external_changed/);
|
||||
});
|
||||
|
||||
// 构造待加入记录的位置描述:依赖键、编码字节数及变化数供 SCC 分组和硬容量检查。
|
||||
const locator=(key,dependencies,byteLength=100)=>({key,dependencies,byteLength,changeCount:1});
|
||||
// A/B 循环还依赖 outside:外部未完成时不选,已完成时整组可超过软 units=1,但字节或 changes 硬上限不足应抛错。
|
||||
test('SCC selection waits for missing external dependencies and enforces hard group capacity',()=>{
|
||||
const pending=[locator('A',['B']),locator('B',['A','outside'])];assert.deepEqual(selectImportGroups(pending,new Set(),{units:1,bytes:1000,changes:100}),[]);
|
||||
assert.deepEqual(selectImportGroups(pending,new Set(['outside']),{units:1,bytes:1000,changes:100}).flat().map(row=>row.key),['A','B']);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 索引复制快照的拓扑/正文分离测试:先读足够规划的结构信息,再按位置读取需要的完整元素。
|
||||
* 拓扑视图不是完整可编辑文档;未知正文错误可以延后到对应记录请求时,但定位、几何与格式边界必须先检查。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,validateDocument} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,10 +9,14 @@ import {encodeIndexedCopySnapshot,openIndexedCopySnapshot} from '../../src/lib/c
|
||||
import {prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
import {copyRegistrySignature,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||
import {createDuplicatePlanSource} from '../../src/lib/canvas-runtime/duplicate-source.ts';
|
||||
// 构造含私密标记文本和 presentation 扩展的节点,确保索引阶段没有悄悄解码或保留完整正文。
|
||||
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal,node=(id,owner='P',componentId='text')=>({id,incarnation:2,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'unparsed-payload-秘密',extra:{retained:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}});
|
||||
// 带端口、旋转/缩放子分区、外部入边及墓碑的基线,使懒读取结果可与同步深复制逐项比较。
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{...node('F').data,ports:[{id:'port',nodeId:'A'}],presentation:{unitScale:2,contentScale:.75,other:'not-needed-in-index'}},geometry:{...node('F').geometry,rotation:35}},node('A','S','image'),{id:'edge',incarnation:1,structure:'connection',ownerCanvasId:'P',componentId:'connection',dataVersion:1,data:{retained:true},source:{elementId:'C'},target:{elementId:'A'},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:70,y:80},in:{x:1,y:2},out:{x:3,y:4}}]}}],new Map([['deleted',4]]));
|
||||
// 以 NDJSON 行拆分快照,供定位索引和正文的定点损坏注入,不改变正常编码器。
|
||||
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));
|
||||
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}`});
|
||||
@@ -16,19 +24,23 @@ test('indexed snapshots read topology before payload, preserve raw records on de
|
||||
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/);
|
||||
});
|
||||
|
||||
// 损坏尾部 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/);
|
||||
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/);
|
||||
});
|
||||
|
||||
// 索引偏移空洞、零长度、非法几何、截断/追加字节、项目不符与各阶段取消都拒绝,不能返回貌似可用的源。
|
||||
test('indexed source rejects locator gaps, geometry corruption, truncation, foreign versions and aborted reads',async()=>{
|
||||
const bytes=await encodeIndexedCopySnapshot(document,signal()),rows=await lines(bytes),index=rows.findIndex(row=>JSON.parse(row).kind==='element-index');
|
||||
for(const change of [row=>({...row,offset:1}),row=>({...row,byteLength:0}),row=>({...row,record:{...row.record,geometry:{...row.record.geometry,width:0}}})]){const bad=[...rows];bad[index]=JSON.stringify(change(JSON.parse(bad[index])));await assert.rejects(openIndexedCopySnapshot(new Blob([bad.join('\n')+'\n']),'project',registry,signal()));}
|
||||
await assert.rejects(openIndexedCopySnapshot(bytes.slice(0,bytes.size-1),'project',registry,signal()));await assert.rejects(openIndexedCopySnapshot(new Blob([await bytes.text(),'x']),'project',registry,signal()));await assert.rejects(openIndexedCopySnapshot(bytes,'foreign',registry,signal()));const controller=new AbortController();controller.abort();await assert.rejects(encodeIndexedCopySnapshot(document,controller.signal),/copy_aborted/);await assert.rejects(openIndexedCopySnapshot(bytes,'project',registry,controller.signal),/copy_aborted/);const snapshot=await openIndexedCopySnapshot(bytes,'project',registry,signal());await assert.rejects(snapshot.readElement('A',controller.signal),/copy_aborted/);
|
||||
});
|
||||
|
||||
// 连续读取 120 条大记录应复用有限原始字节窗口而非每条 slice;再读首项需重新读块,证明旧字节窗口已淘汰。
|
||||
test('bounded raw windows preserve sequential parser batching and evict old bytes without caching decoded content',async()=>{
|
||||
const source=createDocument('project',[{id:'P',parent:null}],Array.from({length:120},(_,i)=>({...node(`record-${i}`),data:{title:`record-${i}`,text:'x'.repeat(3500)}}))),bytes=await encodeIndexedCopySnapshot(source,signal()),reads=[];
|
||||
// 记录实际 Blob.slice 次数,检查连续读记录时有限原始字节窗口的复用和旧窗口淘汰。
|
||||
class ObservedBlob extends Blob{slice(...args){reads.push(args);return super.slice(...args);}}
|
||||
const snapshot=await openIndexedCopySnapshot(new ObservedBlob([bytes]),'project',registry,signal());reads.length=0;
|
||||
for(let i=0;i<120;i++)assert.equal((await snapshot.readElement(`record-${i}`,signal())).data.title,`record-${i}`);assert.ok(reads.length>=5&&reads.length<120/4,`unexpected per-record Blob reads: ${reads.length}`);const count=reads.length;assert.equal((await snapshot.readElement('record-0',signal())).data.text.length,3500);assert.equal(reads.length,count+1,'first window did not leave the bounded cache');
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* 完整复制快照及准备期任务检查点测试。正文、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}));
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
* 可恢复深复制规划器的语义检查点测试。检查点保存遍历游标、阶段及稳定映射,不保存完整节点正文。
|
||||
* 任一阶段序列化/恢复后结果需与同步 planDuplicate 一致,已分配身份不能再分配,旧检查点也不能随后续 step 改变。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||||
import {createDuplicatePlanner,validateDuplicateCheckpoint} from '../../src/lib/canvas-core/duplicate-checkpoint.ts';
|
||||
import {planDuplicateResumable} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
// 节点及曲线工厂保留独立身份、owner 和端口,文本标记用于检查检查点没有复制完整正文。
|
||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:2,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal-data-not-in-checkpoint'},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]}});
|
||||
// 三层分区含内部/跨分区边与上级入边,触发 DFS、分配、映射、节点/边输出等全部阶段。
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),{...node('F','P','frame'),geometry:{...node('F').geometry,rotation:30},data:{title:'F',text:'',presentation:{contentScale:2},ports:[{id:'in',nodeId:'A',direction:'input',handle:'left'}]}},{...node('A','S','image'),data:{title:'A',text:'',assetId:'shared',runId:'not-copied'}},{...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75}}},node('B','T'),node('D','T'),edge('incoming','C','F'),edge('deep','C','B'),edge('internal','B','D','T'),edge('across','A','B','S'),edge('outgoing','F','C')]);
|
||||
// 绑定来源/schema token;targets 取真实身份,drain 有界步进到 result,未结束则报错而不返回部分计划。
|
||||
const binding={sourceToken:'source-sha',schemaToken:'schema-v1'},targets=(ids=['F','A'])=>ids.map(id=>({id,incarnation:document.elements.get(id).incarnation})),drain=planner=>{for(let i=0;i<100000;i++)if(planner.step())return planner.result();throw Error('planner did not terminate');};
|
||||
|
||||
// 逐阶段捕获并 JSON 往返检查点,以新文档对象恢复后对照完整同步计划;分配计数一致,原检查点不变且不含标记正文。
|
||||
test('each durable planning phase resumes with the same deep-copy plan and no repeated identity allocation',()=>{
|
||||
// 遍历每个可持久化阶段;emit 与 emit-edges 还要求已输出至少一项,避免只覆盖空阶段入口。
|
||||
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;}
|
||||
@@ -15,6 +24,7 @@ test('each durable planning phase resumes with the same deep-copy plan and no re
|
||||
}
|
||||
});
|
||||
|
||||
// 在嵌套 DFS 展开中停下,恢复后保留待访问兄弟项及显式选中边;给定完整映射时任何新 ID 分配都会使测试失败。
|
||||
test('nested DFS expansion checkpoints preserve pending siblings, selected edges and explicit stable mappings',()=>{
|
||||
for(const selection of [['F','C','incoming'],['A','internal'],['incoming']]){let reference=0;const selected=targets(selection),expected=planDuplicate(document,registry,selected,{createId:()=>`copy-${++reference}`}),mapping={elements:expected.elementIds,canvases:expected.canvasIds};let planner=createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('allocated despite complete mapping');}},binding),state;
|
||||
for(let i=0;i<10000;i++){const done=planner.step();state=planner.checkpoint();if(state.expanding?.index>=0||done)break;}
|
||||
@@ -22,15 +32,18 @@ test('nested DFS expansion checkpoints preserve pending siblings, selected edges
|
||||
}
|
||||
});
|
||||
|
||||
// 来源/schema 绑定不同、未来版本/阶段、负游标、非法映射或缺失工作项都拒绝;未完成时 result 也不可用。
|
||||
test('checkpoint bindings, shape and source identities reject incompatible state before producing a plan',()=>{
|
||||
const planner=createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},binding),state=planner.checkpoint();for(const patch of [{sourceToken:'different'},{schemaToken:'different'}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,...patch,checkpoint:state}),/binding_mismatch/);
|
||||
for(const patch of [{version:2},{phase:'future'},{cursor:-2},{elementIds:[['F','C']]},{work:[{kind:'element',id:'missing'}]}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,checkpoint:{...state,...patch}}));assert.throws(()=>planner.result(),/copy_plan_not_ready/);
|
||||
});
|
||||
|
||||
// 每片一步并持续保存,在已有至少两个元素 ID 后取消;用保存状态恢复,最终计划及 ID 分配总量仍与同步路径一致。
|
||||
test('cooperative resume checkpoints advance semantic cursors, yield and stop cleanly after cancellation',async()=>{
|
||||
const controller=new AbortController();let saved,ids=0,yields=0;await assert.rejects(planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},binding,{maxStepsPerSlice:1,yieldTask:async()=>{yields++;},checkpoint:async state=>{if(state.elementIds.length>=2){saved=state;controller.abort();}},signal:controller.signal}),/copy_aborted/);assert.ok(yields>0);assert.ok(saved.elementIds.length>=2);const old=ids,plan=await planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},{...binding,checkpoint:saved},{maxStepsPerSlice:2});let reference=0;assert.deepEqual(plan,planDuplicate(document,registry,targets(),{createId:()=>`async-${++reference}`}));assert.equal(ids,reference);assert.ok(ids>=old);
|
||||
});
|
||||
|
||||
// 首次大检查点按预算协作式编码,并发推进 planner 不影响旧检查点/image;字符串编码分片且 UTF-8 计数准确,finally 恢复全局探针。
|
||||
test('a first planning checkpoint encodes cooperatively and is isolated from later planner steps',async()=>{
|
||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const {hasDetachedJsonData}=await import('../../src/lib/canvas-core/json-data.ts');
|
||||
@@ -40,6 +53,7 @@ test('a first planning checkpoint encodes cooperatively and is isolated from lat
|
||||
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]);};
|
||||
try{
|
||||
// 封存检查点尚未完成时继续推进 planner,模拟存储异步执行和后续准备并行;已捕获检查点必须保持旧值。
|
||||
const pending=sealRecoveryImageAsync(17,1,1,checkpoint,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirst??=encoded;yields++;}});
|
||||
for(let i=0;i<20000&&planner.progress().steps<=10;i++)planner.step();
|
||||
assert.ok(planner.progress().steps>checkpoint.steps);
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
/**
|
||||
* 复制规划的协作调度与检查点保存节奏测试。逐步扫描、分配及映射可以让出执行权,但最终计划仍需完全一致。
|
||||
* 检查点保存比任务让出更稀疏,不能让慢 checkpoint I/O 本身强迫下一片立即再保存;取消或错误不发布半份计划。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,planDuplicateSteps,applyChanges,validateDocument,validateDocumentSteps,validJson} from '../../src/lib/canvas-core/index.ts';
|
||||
import {planDuplicateAsync,prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
// 使用内置复制定义,调度差异不会改变类型解释。
|
||||
const registry=createRegistry(builtinComponents());
|
||||
// 生成 frame/文本节点;完整数据较简单,测试聚焦大量扫描及映射步骤。
|
||||
const node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'copy'},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||
// 上级 C 指向 frame 的入边,验证协作式规划仍遵守上级入边复制规则。
|
||||
const edge={id:'edge',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}};
|
||||
// 2000 个子节点加入口、上级节点和入边构成 2003 项源,用于推动跨多个调度切片。
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('C'),...Array.from({length:2000},(_,i)=>node(`n${i}`,'S')),edge]);
|
||||
// 每轮重新生成确定性的 ID 分配器,使异步与同步计划能深比较,不受全局随机 ID 干扰。
|
||||
const selection=[{id:'F',incarnation:1}],options=()=>{let id=0;return{createId:()=>`new-${++id}`};};
|
||||
|
||||
// 固定时钟仍按每片 64 步让出两百余次;完整结果与同步路径相同,含上级入边、冻结副本及不变源文档。
|
||||
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);
|
||||
@@ -16,18 +26,21 @@ test('cooperative deep planning yields throughout scanning, allocation and remap
|
||||
assert.equal(Object.isFrozen(plan.changes[0].after.data),true);assert.equal(document.elements.size,2003);
|
||||
});
|
||||
|
||||
// 默认 task yield 允许真实零延时定时器取消大规划;取消发生在分配前,没有 ID 或内容被发布。
|
||||
test('default task yielding lets actual timers cancel a large copy without publishing content',async()=>{
|
||||
const controller=new AbortController();let ids=0,ran=false;
|
||||
const timer=setTimeout(()=>{ran=true;controller.abort();},0);
|
||||
try{await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`unused-${++ids}`},{signal:controller.signal,maxStepsPerSlice:16}),/copy_aborted/);assert.equal(ran,true);assert.equal(ids,0);assert.equal(document.elements.size,2003);}finally{clearTimeout(timer);}
|
||||
});
|
||||
|
||||
// 已分配部分 ID 后在 yield 中取消,明确拒绝而非返回部分计划,原文档没有任何 new-* 对象。
|
||||
test('cancellation during allocation never returns a partial plan or changes the source',async()=>{
|
||||
const controller=new AbortController();let allocated=0,slices=0;
|
||||
await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`new-${++allocated}`},{signal:controller.signal,now:()=>0,maxStepsPerSlice:32,yieldTask:async()=>{slices++;if(allocated>20)controller.abort();}}),/copy_aborted/);
|
||||
assert.ok(allocated>20&&allocated<2003);assert.ok(slices>1);assert.equal(document.elements.has('new-1'),false);
|
||||
});
|
||||
|
||||
// 规划开始后调用者修改 selection、worldOffset 和实时根,仍使用已捕获的源与选项,不能中途混入新状态。
|
||||
test('planning captures caller inputs and retains the original document when the live root changes',async()=>{
|
||||
const small=createDocument('project',[{id:'P',parent:null}],[node('A')]);let live=small,changed=false;
|
||||
const targets=[{id:'A',incarnation:1}],settings={...options(),worldOffset:{x:40,y:50}};
|
||||
@@ -35,6 +48,7 @@ test('planning captures caller inputs and retains the original document when the
|
||||
assert.equal(live.elements.size,0);assert.equal(result.changes[0].after.geometry.x,40);assert.equal(result.changes[0].after.geometry.y,50);assert.equal(small.elements.size,1);
|
||||
});
|
||||
|
||||
// 即使步数上限很高,时间预算也会让出;宿主调度错误、非法预算和预先取消均保留各自错误。
|
||||
test('time budget forces a task yield even when the step limit is high and scheduler errors propagate',async()=>{
|
||||
let tick=0,yields=0;
|
||||
await planDuplicateAsync(createDocument('p',[{id:'P',parent:null}],[node('A')]),registry,[{id:'A',incarnation:1}],options(),{now:()=>++tick,frameBudgetMs:1,maxStepsPerSlice:99999,yieldTask:async()=>{yields++;}});assert.ok(yields>10);
|
||||
@@ -43,6 +57,7 @@ test('time budget forces a task yield even when the step limit is high and sched
|
||||
const controller=new AbortController();controller.abort();await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{signal:controller.signal}),/copy_aborted/);
|
||||
});
|
||||
|
||||
// 协作式验证和规划不能过滤掉错误记录:多根文档和旧 incarnation 仍以与同步路径一致的错误拒绝。
|
||||
test('cooperative validation and planning retain invalid-input errors rather than skipping failed records',async()=>{
|
||||
const invalid={...document,canvases:new Map([...document.canvases,['bad',{id:'bad',parent:null}]])};
|
||||
assert.throws(()=>validateDocument(invalid,registry),/invalid_roots/);assert.throws(()=>[...validateDocumentSteps(invalid,registry)],/invalid_roots/);
|
||||
@@ -50,6 +65,7 @@ test('cooperative validation and planning retain invalid-input errors rather tha
|
||||
assert.throws(()=>[...planDuplicateSteps(document,registry,[{id:'F',incarnation:9}],options())],/stale_incarnation/);
|
||||
});
|
||||
|
||||
// 源 ID 为 __proto__/constructor 时仍编码为 own JSON 字段,映射对象原型不受影响且序列化往返稳定。
|
||||
test('mapping output preserves special source IDs as own JSON fields without prototype mutation',async()=>{
|
||||
const source=createDocument('special',[{id:'P',parent:null}],[node('__proto__'),node('constructor')]);
|
||||
const plan=await planDuplicateAsync(source,registry,[{id:'__proto__',incarnation:1},{id:'constructor',incarnation:1}],options());
|
||||
@@ -57,16 +73,19 @@ 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()=>{
|
||||
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);
|
||||
});
|
||||
// 节奏化准备取消后复用已分配 ID;检查点回调模拟耗时 1000,下一次同阶段保存仍从回调完成后重新计时。
|
||||
test('paced preparation resumes allocated IDs exactly and checkpoint I/O time does not force the next snapshot',async()=>{
|
||||
const binding={sourceToken:'captured',schemaToken:'schema'},controller=new AbortController(),allocation=options();let saved;
|
||||
await assert.rejects(prepareDuplicateStream(document,registry,selection,allocation,binding,{signal:controller.signal,now:()=>0,maxStepsPerSlice:64,checkpointIntervalMs:50,maxStepsPerCheckpoint:512,yieldTask:async()=>{},checkpoint:async state=>{if(state.phase==='allocate-elements'&&state.elementIds.length){saved=state;controller.abort();}}}),/copy_aborted/);assert.ok(saved.elementIds.length>0);
|
||||
const resumed=await prepareDuplicateStream(document,registry,selection,allocation,{...binding,checkpoint:saved},{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{}}),complete=await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{}});assert.deepEqual(resumed,complete);assert.deepEqual(resumed.elementIds.slice(0,saved.elementIds.length),saved.elementIds);
|
||||
let time=0,yields=0;const saves=[];await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>time,maxStepsPerSlice:64,checkpointIntervalMs:10,yieldTask:async()=>{time++;yields++;},checkpoint:async state=>{const start=time;time+=1000;saves.push({phase:state.phase,start,end:time});}});assert.ok(saves.length<yields/3);for(let i=1;i<saves.length;i++)if(saves[i].phase===saves[i-1].phase)assert.ok(saves[i].start-saves[i-1].end>=10);
|
||||
});
|
||||
// 非法 checkpoint 间隔或步数上限在任何 ID 分配/保存回调前拒绝,不能先产生部分准备成果。
|
||||
test('invalid stream checkpoint cadence rejects before allocation or publication',async()=>{
|
||||
for(const setting of [{checkpointIntervalMs:-1},{checkpointIntervalMs:NaN},{checkpointIntervalMs:Infinity},{maxStepsPerCheckpoint:0},{maxStepsPerCheckpoint:1.5}]){let allocated=0,published=0;await assert.rejects(prepareDuplicateStream(document,registry,selection,{createId:()=>{allocated++;return'new';}},{sourceToken:'captured',schemaToken:'schema'},{...setting,checkpoint:async()=>{published++;}}),/invalid_copy_budget/);assert.equal(allocated,0);assert.equal(published,0);}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 映射已准备好的复制源按记录游标懒输出测试,不要求先生成第二份完整输出 Blob。
|
||||
* 来源摘要、registry 签名和稳定 ID 共同绑定准备状态;单条未知类型可报告记录错误,不破坏先前合法输出。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,21 +9,27 @@ import {prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts'
|
||||
import {createDuplicatePlanSource} from '../../src/lib/canvas-runtime/duplicate-source.ts';
|
||||
import {copyRegistrySignature} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
// 内置定义、可覆盖节点工厂和固定信号,准备源使用一致类型 schema。
|
||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:10,y:20,width:100,height:100,rotation:0}}),signal=new AbortController().signal;
|
||||
// 旋转缩放 frame 包含端口和子节点,并有上级入边,覆盖懒映射的几何及引用一致性。
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',ports:[{id:'p',nodeId:'A'}],presentation:{contentScale:2}},geometry:{...node('F').geometry,rotation:30}},node('A','S'),{id:'incoming',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'A'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:40,y:60},in:{x:0,y:0},out:{x:1,y:1}}]}}]);
|
||||
// 以小切片只完成映射准备,绑定来源摘要与当前 registry 签名,停在尚未 emit 的阶段。
|
||||
async function prepare(source=document){let next=0;return prepareDuplicateStream(source,registry,[{id:'F',incarnation:1}],{createId:()=>`copy-${++next}`},{sourceToken:'a'.repeat(64),schemaToken:copyRegistrySignature(source,registry)},{maxStepsPerSlice:2});}
|
||||
|
||||
// 从末记录倒序读取仍逐项匹配同步计划;序列化后的准备状态可重建同一行,越界结束、取消及负游标分别正确处理。
|
||||
test('lazy copy source seeks mapped records with exact synchronous geometry and reference parity',async()=>{
|
||||
const state=await prepare(),source=createDuplicatePlanSource(document,registry,state,'snapshot');let next=0;const plan=planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=>`copy-${++next}`});assert.equal(state.phase,'emit');assert.equal(state.emitted,0);assert.equal(state.emittedEdges,0);
|
||||
for(let i=plan.changes.length-1;i>=0;i--){const line=await source.read(i,signal);assert.deepEqual(line.value.change,plan.changes[i]);assert.equal(line.nextCursor,i+1);assert.deepEqual(await createDuplicatePlanSource(document,registry,JSON.parse(JSON.stringify(state)),'snapshot').read(i,signal),line);}
|
||||
assert.equal(await source.read(plan.changes.length,signal),null);const aborted=new AbortController();aborted.abort();await assert.rejects(source.read(0,aborted.signal),/import_aborted/);await assert.rejects(source.read(-1,signal),/invalid_import_cursor/);
|
||||
});
|
||||
|
||||
// 准备映射时不必转换未知尾节点;懒源先输出合法项,再为 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');
|
||||
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/);
|
||||
});
|
||||
|
||||
// 运行 job 校验 snapshot SHA、parser 版本、稳定 ID、合法游标及未输出的 plan;任一绑定被篡改即拒绝。
|
||||
test('persisted record cursor source binds snapshot SHA, mapping and interpreter without a second output Blob',async()=>{
|
||||
const state=await prepare(),stableIds=Object.fromEntries([...state.elementIds.map(([id,mapped])=>[`element:${id}`,mapped]),...state.canvasIds.map(([id,mapped])=>[`canvas:${id}`,mapped])]),job={...createImportJob({id:'job',projectId:'project',sourceFingerprint:'snapshot',parserVersion:2,source:{kind:'duplicate-plan',fileId:'file',byteLength:100,sha256:state.sourceToken,targetCanvasId:'P',plan:state,copy:{sourceRevision:1,roots:state.roots.map(id=>stableIds[`element:${id}`])}}}),status:'running',stableIds};validateImportJob(job);
|
||||
for(const patch of [{parserVersion:1},{cursor:100},{stableIds:{}},{source:{...job.source,sha256:'b'.repeat(64)}},{source:{...job.source,plan:{...state,emitted:1}}}])assert.throws(()=>validateImportJob({...job,...patch}));
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
/**
|
||||
* 核心深复制产品规则测试:父子选择去重、分区树与引用映射、上级入边重接到副本,后代世界偏移只应用一次。
|
||||
* 图片引用复用已有资源,执行任务元数据按 schema 省略;普通字面量和未知可保留字段不能被当 ID 随意替换。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,defineComponent,planDuplicate,applyChanges,validateDocument,canvasTransform,toWorld} from '../../src/lib/canvas-core/index.ts';
|
||||
// 第三代文本节点工厂,副本应分配全新 ID/第一代身份;字面量 A 用来检查是否误当引用重写。
|
||||
const node=(id,owner='P',componentId='text')=>({id,incarnation:3,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal A',presentation:{contentScale:1},future:{keep:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}});
|
||||
// 带端点控制量和结点的贝塞尔路径,跨缩放坐标系复制时需分别核对位置及局部控制量。
|
||||
const path={type:'bezier-chain',startControl:{x:5,y:6},endControl:{x:-7,y:8},knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]};
|
||||
// 第二代连线引用 source/target 元素及局部 portId;复制时元素引用重映射,portId 不改。
|
||||
const edge=(id,source,target,owner='P',curve=null)=>({id,incarnation:2,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},path:curve,data:{title:id}});
|
||||
// 两层 frame 的 contentScale 与图片任务状态样本,分别验证坐标和资源/执行字段策略。
|
||||
const registry=createRegistry(builtinComponents()),F={...node('F','P','frame'),geometry:{...node().geometry,rotation:30},data:{...node().data,title:'F',presentation:{contentScale:2}}},G={...node('G','S','frame'),data:{...node().data,title:'G',presentation:{contentScale:.75}}},A={...node('A','S','image'),data:{title:'A',text:'A',assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task',progress:.5,runStatus:'running',custom:'A'}};
|
||||
// 三层分区同时含入边、深层入边、内部边、跨子区边及出边,明确区分哪些默认跟随被复制子树。
|
||||
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),F,A,G,node('B','T'),node('D','T'),edge('incoming','C','F','P',path),edge('deep','C','B'),edge('internal','B','D','T',path),edge('across','A','B','S'),edge('outgoing','F','C')]);
|
||||
// 从原文档取 incarnation,防止测试用过期身份绕过核心校验。
|
||||
const target=id=>({id,incarnation:document.elements.get(id).incarnation});
|
||||
// 默认同时选择 F 与已包含的 A,使用确定 ID 和世界偏移;extra 可替换映射及分配器测试恢复一致性。
|
||||
const fixture=(selection=['F','A'],extra={})=>{let next=0;return planDuplicate(document,registry,selection.map(target),{createId:()=>`copy-${++next}`,worldOffset:{x:32,y:48},...extra});};
|
||||
// 以 1e-7 容差比较旋转/缩放后的浮点世界坐标。
|
||||
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
|
||||
|
||||
// F/A 重复选择只复制一棵深树;内部引用和 canvas owner 映射,上级 C 保留并接入新分区,默认不复制出边,逐记录前缀都合法。
|
||||
test('deep copy deduplicates ancestor selection, maps the entire tree and preserves upper incoming sources',()=>{
|
||||
const before=JSON.stringify([...document.elements]),plan=fixture(),after=applyChanges(document,plan.changes);
|
||||
assert.deepEqual(plan.roots,[plan.elementIds.F]);assert.equal(Object.keys(plan.canvasIds).length,2);assert.equal(Object.keys(plan.elementIds).length,9);
|
||||
@@ -22,6 +35,7 @@ test('deep copy deduplicates ancestor selection, maps the entire tree and preser
|
||||
let partial=document;for(const change of plan.changes){partial=applyChanges(partial,[change]);validateDocument(partial,registry);}
|
||||
});
|
||||
|
||||
// 旋转/缩放嵌套下各节点和内部曲线只得到一次世界偏移,后代不重复平移;上级入边控制量按对应坐标规则处理。
|
||||
test('geometry uses one world offset across rotated/scaled ownership and does not offset descendants twice',()=>{
|
||||
const plan=fixture(),after=applyChanges(document,plan.changes);
|
||||
for(const id of ['F','A','G','B','D']){
|
||||
@@ -32,11 +46,13 @@ test('geometry uses one world offset across rotated/scaled ownership and does no
|
||||
const incoming=after.elements.get(plan.elementIds.incoming).path;near(incoming.knots[0].position.x,path.knots[0].position.x+16);near(incoming.knots[0].position.y,path.knots[0].position.y+24);assert.deepEqual(incoming.startControl,path.startControl);
|
||||
});
|
||||
|
||||
// 图片副本保留 assetId/src 和普通字面量,省略 runId/taskId/progress/runStatus,避免把已执行任务当新执行工作。
|
||||
test('image copies share resources and omit execution metadata while preserving ordinary literal values',()=>{
|
||||
const plan=fixture(),data=applyChanges(document,plan.changes).elements.get(plan.elementIds.A).data;
|
||||
assert.equal(data.assetId,'shared');assert.equal(data.src,'asset:shared');assert.equal(data.custom,'A');for(const key of ['runId','taskId','progress','runStatus'])assert.equal(Object.hasOwn(data,key),false);
|
||||
});
|
||||
|
||||
// 完整稳定映射生成相同计划且不调用 createId;碰撞 ID、多余映射范围及过期源 incarnation 都拒绝。
|
||||
test('a supplied stable mapping reproduces the same plan without allocating another identity',()=>{
|
||||
const first=fixture();const second=fixture(['F','A'],{mapping:{elements:first.elementIds,canvases:first.canvasIds},createId:()=>{throw Error('new identity allocated');}});assert.deepEqual(second,first);
|
||||
assert.throws(()=>fixture(['F'],{createId:()=> 'C'}),/duplicate_identity/);
|
||||
@@ -44,11 +60,13 @@ test('a supplied stable mapping reproduces the same plan without allocating anot
|
||||
assert.throws(()=>planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=> 'new'}),/stale_incarnation/);
|
||||
});
|
||||
|
||||
// 同时选择上级 C 和入边时,C 也生成副本,入边指向新 C/新 F,且只创建一次连线。
|
||||
test('selecting the upper source too maps one new source and does not duplicate the incoming edge',()=>{
|
||||
const plan=fixture(['F','A','C','incoming']),after=applyChanges(document,plan.changes),incoming=after.elements.get(plan.elementIds.incoming);
|
||||
assert.equal(incoming.source.elementId,plan.elementIds.C);assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(plan.changes.filter(change=>change.kind==='element'&&change.after?.id===incoming.id).length,1);
|
||||
});
|
||||
|
||||
// 自定义声明的列表元素引用和 canvas 引用映射到新 ID;execution 按 omit 删除,literal 不变,未选中的 reject 引用报错。
|
||||
test('declared nested/list references are remapped, resources remain shared and only declared transient fields disappear',()=>{
|
||||
const definition=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['links'],target:'element',many:true,external:'reject'},{path:['view'],target:'canvas',external:'reject'}],omit:[['execution']]}}),r=createRegistry([...builtinComponents(),definition]),linked={...node('L'),componentId:'linked',data:{title:'L',text:'A',links:['A','B'],view:'S',execution:{taskId:'task'},literal:'A'}},source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),linked]);let next=0;
|
||||
const plan=planDuplicate(source,r,[target('F'),{id:'L',incarnation:3}],{createId:()=>`ref-${++next}`}),copy=applyChanges(source,plan.changes).elements.get(plan.elementIds.L);
|
||||
@@ -56,6 +74,7 @@ test('declared nested/list references are remapped, resources remain shared and
|
||||
assert.throws(()=>planDuplicate(source,r,[{id:'L',incarnation:3}],{createId:()=>`external-${++next}`}),/copy_external_reference/);
|
||||
});
|
||||
|
||||
// 缺复制 schema、未来数据版本、重叠/危险路径及未知曲线都明确拒绝,不能静默省略或猜测转换。
|
||||
test('missing copy schema, unknown versions, ambiguous paths and opaque curves are explicit failures',()=>{
|
||||
const known=defineComponent({...builtinComponents()[0],id:'opaque',copy:undefined}),r=createRegistry([...builtinComponents(),known]),source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),{...node('U'),componentId:'opaque'}]);
|
||||
assert.throws(()=>planDuplicate(source,r,[{id:'U',incarnation:3}],{createId:()=> 'new'}),/copy_schema_unavailable/);
|
||||
@@ -65,6 +84,7 @@ test('missing copy schema, unknown versions, ambiguous paths and opaque curves a
|
||||
const invalid=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='incoming'?{...element,path:{type:'future'}}:element));assert.throws(()=>planDuplicate(invalid,registry,[target('F')],{createId:(()=>{let n=0;return()=>`unknown-${++n}`;})()}),/unsupported_copy_path/);
|
||||
});
|
||||
|
||||
// frame.ports 对象数组只映射 nodeId,局部端口 ID/handle 及原数据不变;错误数组形状和重叠通配路径都拒绝。
|
||||
test('frame port object arrays map child identities but retain local port IDs, handles and the source data',()=>{
|
||||
const ports=[{id:'in',direction:'input',nodeId:'A',handle:'left'},{id:'out',direction:'output',nodeId:'B',handle:'port:nested'}],source=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(record=>record.id==='F'?{...record,data:{...record.data,ports}}:record));let next=0;
|
||||
const plan=planDuplicate(source,registry,[target('F')],{createId:()=>`port-${++next}`}),data=applyChanges(source,plan.changes).elements.get(plan.elementIds.F).data;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* 渐进复制尚未完成时的真实浏览器原生输入探针,由 renderer 集成 runner 注入 CDP/求值/等待端口。
|
||||
* 在原卡片上双击、输入中文组合及连续字符,跨早/中/尾发布阶段检查同一 textarea 的 DOM、焦点与呈现。
|
||||
* evaluate 模板字符串是在页面执行的独立脚本,保持其原字节;这里只在调用边界解释内部回调与读数。
|
||||
*/
|
||||
/** Native input while a real progressive copy is still publishing. */
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// 导出给 runner:count 是原始节点规模;返回每次 input 的时间/内容/焦点样本,供外层报告或性能门槛使用。
|
||||
export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
// 至少 1000 节点才启用并发探针,先等待首帧出现且复制还未完成,保证输入发生在真实渐进发布期间。
|
||||
assert.ok(count>=1000,'concurrent input probe requires at least 1000 source nodes');
|
||||
await wait('window.__canvasPerformance.read().firstPresentedFrameMs!==null','copy did not present before native input');
|
||||
assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy finished before input probe');
|
||||
// Copied cards overlap originals. Find an actually exposed point; never send a
|
||||
// synthetic double-click through the copied card to the original underneath.
|
||||
// 页面脚本先找原始 perf-0 文本标签,再按屏幕网格调用 elementFromPoint,寻找未被副本遮住的原生命中点。
|
||||
const point=await evaluate(`(()=>{
|
||||
const label=document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]');
|
||||
if(!label)throw Error('original text label is missing');
|
||||
@@ -16,9 +24,11 @@ export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
if(label.contains(document.elementFromPoint(x,y)))return{x,y};
|
||||
throw Error('original text has no exposed native input point');
|
||||
})()`);
|
||||
// 通过 CDP 发送真实双击按下/释放,再等待原 textarea 进入可写焦点,不能用合成事件穿透副本。
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
|
||||
await wait(`document.activeElement===document.querySelector('.react-flow__node[data-id="perf-0"] textarea')&&!document.activeElement.readOnly`,'original text did not enter editing during copy');
|
||||
// 安装 input 监听器:每次记录输入值、阶段及已提交数量,用两次 RAF 后的时间近似观察到后续呈现机会。
|
||||
await evaluate(`(()=>{
|
||||
const element=document.activeElement,samples=[];
|
||||
const input=()=>{
|
||||
@@ -36,6 +46,7 @@ export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
return Number(status.match(/·\\s*(\\d+)\\s*项/)?.[1]??0);
|
||||
},dispose(){element.removeEventListener('input',input);}};
|
||||
})()`);
|
||||
// 先启动中文输入法组合再提交文本,随后分次插入 A/B/C;每次都等对应样本进入后续帧。
|
||||
await call('Input.imeSetComposition',{text:'复',selectionStart:1,selectionEnd:1});
|
||||
await call('Input.insertText',{text:'复制期间输入'});
|
||||
for(const text of [' A',' B',' C']){
|
||||
@@ -44,6 +55,7 @@ export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
}
|
||||
// Keep the same native editor alive through later publication and ACK work.
|
||||
// Small scenarios can finish during the early burst; the scale gate uses 10k.
|
||||
// 万级场景还在 50%/90% 进度注入 M/T,保持同一编辑器不重开;测量 CDP round trip 与样本帧延迟是不同读数。
|
||||
if(count>=10000)for(const [phase,fraction,text] of [['middle',.5,' M'],['tail',.9,' T']]){
|
||||
await wait(`window.__copyInputProbe.progress()>=${Math.floor((count+3)*fraction)}`,'copy did not reach '+phase+' publication');
|
||||
assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy completed before '+phase+' input');
|
||||
@@ -53,15 +65,18 @@ export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
await wait(`window.__copyInputProbe.samples.at(-1)?.phase===${JSON.stringify(phase)}&&window.__copyInputProbe.samples.at(-1)?.frameAt!==undefined`,phase+' input did not reach a frame');
|
||||
await evaluate(`window.__copyInputProbe.samples.at(-1).dispatchRoundTripMs=${dispatchRoundTripMs};true`);
|
||||
}
|
||||
// 最后页面脚本读取样本与焦点状态,移除监听器、blur 并删除探针全局变量;断言使用清理前捕获的结果。
|
||||
const result=await evaluate(`(()=>{
|
||||
const probe=window.__copyInputProbe,result={samples:probe.samples,value:probe.element.value,retained:probe.element.isConnected&&document.activeElement===probe.element};
|
||||
probe.dispose();probe.element.blur();delete window.__copyInputProbe;return result;
|
||||
})()`);
|
||||
// 检查同一原生输入节点/焦点留存及完整文本后缀,复制过程不能重建编辑 DOM 或覆盖用户输入。
|
||||
assert.equal(result.retained,true,'copy discarded native input DOM or focus');
|
||||
assert.ok(result.value.endsWith(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'native composition/typing was overwritten');
|
||||
assert.ok(result.samples.some(sample=>sample.copyActive),'all native input happened after copy completion');
|
||||
if(count>=10000)for(const phase of ['middle','tail'])assert.ok(result.samples.some(sample=>sample.phase===phase&&sample.copyActive),phase+' input was not observed before completion');
|
||||
assert.ok(result.samples.every(sample=>sample.retained),'copy interrupted an input presentation frame');
|
||||
// 每个样本挂载节点数仍少于 100,证明此探针未因原生编辑挂载全部离屏节点;这不是通用 FPS 保证。
|
||||
assert.ok(result.samples.every(sample=>sample.mountedNodes<100),'native editing mounted offscreen nodes during copy');
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user