47 lines
6.2 KiB
JavaScript
47 lines
6.2 KiB
JavaScript
/**
|
||
* 复制快照编解码的协作式预算测试。调度只影响让出时机,不能影响快照字节、墓碑、索引读取或取消语义。
|
||
* 同时覆盖普通/索引快照及旧 prepared duplicate 源格式;假时钟与工作量上限分别控制耗时和粗时钟场景。
|
||
*/
|
||
import {test} from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||
import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||
import {encodeIndexedCopySnapshot,openIndexedCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot-index.ts';
|
||
import {encodeDuplicateSource} from '../../src/lib/canvas-runtime/import-duplicate.ts';
|
||
import {createCopyBudget} from '../../src/lib/canvas-runtime/copy-budget.ts';
|
||
// 内置定义和独立 AbortSignal 工厂,避免一个场景的取消状态传入另一个场景。
|
||
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
|
||
// 包含中文、emoji、转义文本的节点工厂,让字节一致性比较覆盖多字节编码。
|
||
const node=i=>({id:`n-${i}`,incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:`卡片 ${i}`,text:'emoji 👀\n"quoted"'},geometry:{x:i,y:0,width:100,height:100,rotation:0}});
|
||
// 512 个节点及一个墓碑组成统一快照,既覆盖批量处理,也覆盖删除身份信息。
|
||
const document=createDocument('project',[{id:'canvas',parent:null}],Array.from({length:512},(_,i)=>node(i)),new Map([['retired',2]]));
|
||
// cheap 时钟固定为零,costly 每次前进 10;记录 yield 次数,区分按预算让出与固定条数休眠。
|
||
function scheduling(slow=false){let yields=0,tick=0;return{options:{now:()=>slow?(++tick)*10:0,yieldTask:async()=>{yields++;}},get yields(){return yields;}};}
|
||
|
||
// 普通/索引格式在便宜和昂贵时间切片下编码完全相同;解码保留 512 节点及墓碑,索引格式还读取末项核对内容。
|
||
test('copy snapshot bytes and decoded records are invariant across cheap and expensive scheduling slices',async()=>{
|
||
for(const [encode,decode,indexed] of [[encodeCopySnapshot,decodeCopySnapshot,false],[encodeIndexedCopySnapshot,openIndexedCopySnapshot,true]]){
|
||
const cheap=scheduling(),costly=scheduling(true),a=await encode(document,signal(),cheap.options),b=await encode(document,signal(),costly.options);assert.equal(await a.text(),await b.text());assert.equal(cheap.yields,0,'cheap work slept at a fixed record count');assert.ok(costly.yields>0);const before=costly.yields,snapshot=await decode(b,'project',registry,signal(),costly.options),restored=indexed?snapshot.document:snapshot;assert.equal(restored.elements.size,512);assert.deepEqual([...restored.tombstones],[['retired',2]]);assert.ok(costly.yields>before);if(indexed)assert.deepEqual(await snapshot.readElement('n-511',signal()),document.elements.get('n-511'));else assert.deepEqual([...restored.elements],[...document.elements]);
|
||
}
|
||
});
|
||
// 编码、普通解码及打开索引快照各自在第一次 yield 中取消;必须报 copy_aborted,不能返回可用的半成品,原文档不变。
|
||
test('abort at a scheduled yield prevents copy encoders and readers from returning a partial snapshot',async()=>{
|
||
const old=await encodeCopySnapshot(document,signal(),scheduling().options),indexed=await encodeIndexedCopySnapshot(document,signal(),scheduling().options);
|
||
for(const run of [(s,o)=>encodeCopySnapshot(document,s,o),(s,o)=>encodeIndexedCopySnapshot(document,s,o),(s,o)=>decodeCopySnapshot(old,'project',registry,s,o),(s,o)=>openIndexedCopySnapshot(indexed,'project',registry,s,o)]){const controller=new AbortController();let time=0,yields=0;await assert.rejects(run(controller.signal,{now:()=>time+=10,yieldTask:async()=>{yields++;controller.abort();}}),error=>error.code==='copy_aborted');assert.equal(yields,1);}
|
||
assert.equal(document.elements.size,512);assert.equal(document.elements.get('n-0').data.title,'卡片 0');
|
||
});
|
||
// 时钟不动时仍按调用者累计的工作量上限让出,避免大记录霸占主线程;取消后 step 及预先取消的预算创建都立即拒绝。
|
||
test('large records consume work budget and coarse clocks still yield without changing copy cancellation errors',async()=>{
|
||
const controller=new AbortController();let yields=0;const budget=createCopyBudget(controller.signal,{now:()=>0,yieldTask:async()=>{yields++;}});for(let i=0;i<64;i++)await budget.step(256);assert.ok(yields>0,'coarse clock allowed an unbounded byte workload');controller.abort();assert.throws(()=>budget.step(),error=>error.code==='copy_aborted');
|
||
const aborted=new AbortController();aborted.abort();assert.throws(()=>createCopyBudget(aborted.signal),error=>error.code==='copy_aborted');
|
||
});
|
||
// 拒绝非正或非有限 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');
|
||
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 源在不同切片下保持相同字节;预算边界取消时拒绝而非返回部分输出。
|
||
test('legacy prepared duplicate sources preserve exact bytes and abort before returning on a budget boundary',async()=>{
|
||
let id=0;const plan=planDuplicate(document,registry,[...document.elements.values()].map(({id,incarnation})=>({id,incarnation})),{createId:()=>`copy-${++id}`}),cheap=scheduling(),costly=scheduling(true),a=await encodeDuplicateSource(document,registry,plan,signal(),cheap.options),b=await encodeDuplicateSource(document,registry,plan,signal(),costly.options);assert.equal(await a.text(),await b.text());assert.equal(cheap.yields,0);assert.ok(costly.yields>0);const controller=new AbortController();let time=0;await assert.rejects(encodeDuplicateSource(document,registry,plan,controller.signal,{now:()=>time+=10,yieldTask:async()=>controller.abort()}),error=>error.code==='copy_aborted');
|
||
});
|