docs(tests): clarify validation ownership and digest lifecycle checks
This commit is contained in:
@@ -10,11 +10,13 @@ import {stageCreation,materializeCreations} from '../../src/lib/canvas-core/crea
|
||||
const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:''}}),document=()=>createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node('old-'+i))),registry=createRegistry(builtinComponents());
|
||||
// 连续追加130条跨过窗口边界,保存第1/64/65个前缀;检查顺序、只读集合、forEach this与materialize结果及重复ID拒绝。
|
||||
test('creation drafts avoid reenumerating the base per item, preserve held prefixes and fold bounded append windows in order',()=>{
|
||||
// 只替换底表的 Symbol.iterator 来计数;get/has 等直接查询不算全表遍历,暂存追加窗口另行维护。
|
||||
const base=document();let reads=0;const elements={...base.elements,[Symbol.iterator](){reads++;return base.elements[Symbol.iterator]();}},source={...base,elements};let draft=source;const held=[];
|
||||
for(let i=0;i<130;i++){draft=stageCreation(draft,{kind:'element',id:'new-'+i,before:null,after:node('new-'+i)});if([0,63,64].includes(i))held.push(draft);if(i===31)assert.equal(reads,0);}
|
||||
assert.ok(reads<=1);assert.deepEqual(held.map(d=>d.elements.size),[1001,1064,1065]);assert.equal(held[0].elements.has('new-1'),false);assert.equal(draft.elements.get('old-10').id,'old-10');assert.equal(draft.elements.get('new-129').id,'new-129');assert.equal(draft.elements.set,undefined);const ids=[...draft.elements.keys()];assert.deepEqual(ids.slice(-130),Array.from({length:130},(_,i)=>'new-'+i));assert.equal([...draft.elements.values()].length,1130);const visits=[];draft.elements.forEach(function(value,key,map){assert.equal(this.tag,true);assert.strictEqual(map,draft.elements);visits.push(key);},{tag:true});assert.deepEqual(visits,ids);const materialized=materializeCreations(draft);assert.ok(isFactoryDocument(materialized));assert.equal(materialized.elements.size,1130);assert.equal(base.elements.size,1000);assert.throws(()=>stageCreation(draft,{kind:'element',id:'new-0',before:null,after:node('new-0')}),/duplicate_identity/);
|
||||
});
|
||||
// 策略按命令顺序看到1000/1001/1002前缀,持有前缀也固定;创建与中间编辑一起只发布一次、产生一个操作/历史单元。
|
||||
// 策略依次看到 1000/1001/1002/1002:编辑 a 不增加元素,随后才创建 c;持有的旧前缀大小保持不变。
|
||||
// 创建和中间编辑放在同一事务内,只发布一次并产生一个操作/历史单元。
|
||||
test('ordered command policies see stable prior prefixes; one owned publication has one history unit and round-trips undo/redo',()=>{
|
||||
const held=[],seen=[];let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy(command,doc){held.push(doc);seen.push([command.type,doc.elements.size,doc.canvases.size]);return{enabled:true};}});let publications=0;editor.subscribe(()=>publications++);const before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});const prefix=tx.getDocument();tx.dispatch({type:'element.create',element:node('b')});assert.equal(prefix.elements.has('b'),false);tx.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'edited'}});tx.dispatch({type:'element.create',element:node('c')});});assert.equal(result.status,'applied');assert.deepEqual(seen.map(row=>row[1]),[1000,1001,1002,1002]);assert.deepEqual(held.map(d=>d.elements.size),[1000,1001,1002,1002]);assert.equal(publications,1);const after=editor.getSnapshot();assert.ok(isFactoryDocument(after.document));assert.equal(after.history.undo.length,1);assert.equal(after.operations.size,1);assert.equal(after.document.elements.get('a').data.title,'edited');assert.equal(before.document.elements.size,1000);assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.size,1000);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'edited');
|
||||
});
|
||||
@@ -26,7 +28,8 @@ test('policy, schema, duplicate and runtime failures never publish staged creati
|
||||
test('canvas and element creation drafts compose a tree while held canvas prefixes and base snapshots remain fixed',()=>{
|
||||
const editor=createEditor({document:document(),registry});let first,second;const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}});first=tx.getDocument();tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'root',entryElementId:'entry'}}});second=tx.getDocument();tx.dispatch({type:'element.create',element:{...node('nested'),ownerCanvasId:'child'}});assert.equal(first.canvases.has('child'),false);assert.equal(second.elements.has('nested'),false);assert.equal(tx.getDocument().canvases.get('child').parent.entryElementId,'entry');});assert.equal(result.status,'applied');assert.ok(isFactoryDocument(editor.getSnapshot().document));assert.equal(editor.getSnapshot().document.elements.get('nested').ownerCanvasId,'child');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.has('child'),false);assert.equal(editor.getSnapshot().document.elements.has('entry'),false);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.get('child').parent.entryElementId,'entry');
|
||||
});
|
||||
// 事务内暂替Map迭代器,禁止大表遍历;32次创建仍获准且单一历史,finally恢复全局原型方法。
|
||||
// 事务回调内暂替 Map 迭代器,禁止暂存时遍历大表;32 次创建仍获准且单一历史。
|
||||
// finally 在回调结束时恢复原型,因此这里只约束逐命令暂存阶段,不禁止提交阶段为完整校验遍历文档。
|
||||
test('editor creation staging does not enumerate an existing thousand-element table for each command',()=>{
|
||||
const editor=createEditor({document:document(),registry});const iterator=Map.prototype[Symbol.iterator];let admissions=0;const result=editor.transaction(tx=>{try{Map.prototype[Symbol.iterator]=function(){if(this.size>=1000)throw Error('full table copied during creation staging');return iterator.call(this);};for(let i=0;i<32;i++){assert.equal(tx.dispatch({type:'element.create',element:node('batch-'+i)}).enabled,true);admissions++;}}finally{Map.prototype[Symbol.iterator]=iterator;}});assert.equal(result.status,'applied');assert.equal(admissions,32);assert.equal(editor.getSnapshot().document.elements.size,1032);assert.equal(editor.getSnapshot().history.undo.length,1);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ test('digest pool bounds execution and FIFO queue, rejects overload and drops qu
|
||||
const started=[],release=[];const pool=createDigestPool({concurrency:2,maxQueued:2,execute:async bytes=>{started.push(await bytes.text());return new Promise(resolve=>release.push(()=>resolve(hash)));}}),cancel=new AbortController(),a=pool.digest(new Blob(['A'])),b=pool.digest(new Blob(['B'])),c=pool.digest(new Blob(['C']),cancel.signal),d=pool.digest(new Blob(['D']));const rejected=assert.rejects(c,/import_aborted/);await assert.rejects(pool.digest(new Blob(['overflow'])),/digest_capacity/);cancel.abort();await rejected;await tick();assert.deepEqual(started,['A','B']);release[0]();await a;await tick();assert.deepEqual(started,['A','B','D']);release[1]();release[2]();await Promise.all([b,d]);pool.close();await assert.rejects(pool.digest(new Blob()),/digest_pool_closed/);
|
||||
});
|
||||
// 取消活动 first 后先拒绝调用者,但底层仍挂起时 second 不能启动;first 真正退出后才复用槽位,并忽略其迟到输出。
|
||||
// execute 故意不监听 AbortSignal,模拟不可立即停止的计算;release 控制真实退出时刻,而非调用者 Promise 拒绝时刻。
|
||||
test('active cancellation cannot free a compute slot before its executor stops; late output is ignored',async()=>{
|
||||
let release;const started=[],controller=new AbortController(),pool=createDigestPool({concurrency:1,execute:async bytes=>{started.push(await bytes.text());if(started.length===1)await new Promise(resolve=>release=resolve);return hash;}}),first=pool.digest(new Blob(['first']),controller.signal),rejected=assert.rejects(first,/import_aborted/),second=pool.digest(new Blob(['second']));await tick();controller.abort();await rejected;await tick();assert.deepEqual(started,['first']);release();assert.equal(await second,hash);pool.close();
|
||||
});
|
||||
|
||||
@@ -25,12 +25,14 @@ test('editor creations detach caller data; topology-only validation cannot bypas
|
||||
const f=setup();let id=0;const editor=createEditor({document:f.document,registry:f.registry,createId:()=>`op-${++id}`}),incoming=node('new');assert.equal(editor.dispatch({type:'element.create',element:incoming}).status,'applied');incoming.geometry.width=-1;incoming.data.text=undefined;const record=editor.getSnapshot().document.elements.get('new');assert.equal(record.geometry.width,100);assert.equal(record.data.text,'hello');const fresh=createDocument('p',[{id:'root',parent:null}],[node('topology')]).elements.get('topology');f.allow(false);const calls=f.calls();validateElementRecord(fresh,f.registry,new Map(),false);assert.equal(f.calls(),calls);assert.throws(()=>validateElementRecord(fresh,f.registry),/invalid_component_data/);editor.dispose();
|
||||
});
|
||||
|
||||
// 用空canvas、墓碑、缺端点及非法几何比较两种遍历结果,再检查合法迭代器的让步步数。
|
||||
// 用空 canvas、墓碑、缺端点及非法几何比较两种遍历的首个拒绝结果,再检查合法迭代器的让步步数。
|
||||
// outcome 只观察 code/targets,没有记录所有校验步骤;相同结果不能单独证明内部每一步的执行顺序。
|
||||
test('synchronous and cooperative validation preserve check order and the same rejection results',()=>{
|
||||
const f=setup(),base=f.document,edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'missing'},path:null},cases=[base,{...base,canvases:new Map()}, {...base,tombstones:new Map([['a',0]])},{...base,tombstones:new Map([['a',1]])},{...base,canvases:new Map([['root',{id:'root',parent:null}],['other',{id:'other',parent:null}]])},createDocument('p',[{id:'root',parent:null}],[node(),edge]),createDocument('p',[{id:'root',parent:null}],[{...node(),geometry:{...node().geometry,width:0}}])];
|
||||
/** 将抛错规范为code/targets或valid,比较同步与协作遍历的拒绝结果,而非依赖异常对象引用。 */
|
||||
const outcome=callback=>{try{callback();return'valid';}catch(error){return{code:error.code,targets:error.targets};}};
|
||||
for(const document of cases)assert.deepEqual(outcome(()=>validateDocument(document,f.registry)),outcome(()=>{for(const _ of validateDocumentSteps(document,f.registry)){};}));
|
||||
// base 仅含一张 canvas 和一个元素:两次 next 各让出一次,第三次报告完成;这里不测帧耗时。
|
||||
const iterator=validateDocumentSteps(base,f.registry);assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.equal(iterator.next().done,true);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ import {detachJsonData,hasDetachedJsonData} from '../../src/lib/canvas-core/json
|
||||
// 修改外部嵌套对象/日期不得影响结果;数组空洞等变null,特殊键保留,伪造冻结副本无证明,循环/BigInt明确抛错。
|
||||
test('JSON detachment owns only its copied immutable result and preserves JSON semantics',()=>{
|
||||
let calls=0;const external={nested:{text:'before'},date:new Date('2026-01-01Z'),custom:{toJSON(){calls++;return{value:2};}},array:[,undefined,NaN],['__proto__']:{safe:true}};
|
||||
// toJSON 应在隔离时只执行一次,随后修改原对象/Date 来检查结果与调用方引用是否脱离。
|
||||
const value=detachJsonData(external);assert.equal(calls,1);assert.equal(hasDetachedJsonData(value),true);assert.equal(hasDetachedJsonData(external),false);
|
||||
external.nested.text='after';external.date.setUTCFullYear(2027);
|
||||
assert.equal(value.nested.text,'before');assert.equal(value.date,'2026-01-01T00:00:00.000Z');assert.deepEqual(value.array,[null,null,null]);assert.equal(Object.hasOwn(value,'__proto__'),true);
|
||||
assert.ok(Object.isFrozen(value.nested));assert.throws(()=>{value.nested.text='mutate';},TypeError);
|
||||
// freeze 副本或 structuredClone 虽可保持相同内容,却不携带隔离函数私有的对象身份证明。
|
||||
assert.equal(hasDetachedJsonData(Object.freeze({...value})),false);assert.equal(hasDetachedJsonData(structuredClone(value)),false);
|
||||
assert.equal(hasDetachedJsonData(Object.freeze({nested:{mutable:true}})),false);
|
||||
const cycle={};cycle.self=cycle;assert.throws(()=>detachJsonData(cycle));assert.throws(()=>detachJsonData({big:1n}));
|
||||
|
||||
@@ -8,10 +8,12 @@ import {createNetworkGate} from '../../src/lib/canvas-runtime/network.ts';
|
||||
/** 暴露resolve的手动等待点,让首请求占住名额,测试可在第二请求获准前改变身份或abort。 */
|
||||
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{resolve,promise};};
|
||||
// 首请求等待时使身份失效,次请求不执行;恢复身份后第三请求能执行,证明拒绝的名额已释放。
|
||||
// 首请求已经越过准入门,身份改变不会回溯撤销它;断言检查的是排队请求获准时重新核验。
|
||||
test('transport rechecks identity after waiting for capacity and releases a rejected permit',async()=>{
|
||||
let current=true,calls=0;const gate=createNetworkGate(1,()=>{if(!current)throw Error('identity_changed');}),hold=deferred(),signal=new AbortController().signal,first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(signal,async()=>{calls++;}),/identity_changed/);await Promise.resolve();current=false;hold.resolve();await first;await second;assert.equal(calls,1);current=true;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);
|
||||
});
|
||||
// 排队请求先abort,随后释放首请求并运行第三个;调用数与护栏计数都只能包含真正获准的两个任务。
|
||||
// 排队请求先 abort,随后释放首请求并运行第三个;调用数与护栏计数都只能包含真正获准的两个任务。
|
||||
// assert.rejects 在 abort 之前挂好,避免预期的拒绝被当作未处理异常;取消不占用下一次可用名额。
|
||||
test('an aborted queued transport cannot pass the effect guard or consume the next capacity grant',async()=>{
|
||||
let guards=0,calls=0;const gate=createNetworkGate(1,()=>guards++),hold=deferred(),signal=new AbortController().signal,abort=new AbortController(),first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(abort.signal,async()=>{calls++;}),/cancelled/);await Promise.resolve();abort.abort(Error('cancelled'));await second;hold.resolve();await first;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);assert.equal(guards,2);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.
|
||||
// 用 Node createHash 计算参照摘要;既不复用待测算法,也不以自身输出作为预期值。
|
||||
const expected=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
// 空值、标准字符串、百万个 a 及长度 0–259 的随机输入,分别按六种块宽更新;hex 可重复读取,但最终化后禁止 update。
|
||||
// 六种切分覆盖 64 字节块的前后边界及多块更新;随机内容每次不同,预期摘要始终由 Node crypto 独立计算。
|
||||
test('incremental SHA-256 matches known vectors, every padding boundary and varied chunk splits',()=>{
|
||||
for(const bytes of [Buffer.alloc(0),Buffer.from('abc'),Buffer.from('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'),Buffer.alloc(1000000,97),...Array.from({length:260},(_,n)=>randomBytes(n))]){
|
||||
for(const chunk of [1,7,63,64,65,8192]){const hash=createSha256();for(let offset=0;offset<bytes.length;offset+=chunk)hash.update(bytes.subarray(offset,offset+chunk));assert.equal(hash.hex(),expected(bytes),`${bytes.length}/${chunk}`);assert.equal(hash.hex(),expected(bytes));assert.throws(()=>hash.update(new Uint8Array()),/digest_finalized/);}
|
||||
@@ -20,6 +21,7 @@ test('source digest reads bounded slices, yields and never materializes the enti
|
||||
const data=randomBytes(1024*1024+13),reads=[];let yields=0;
|
||||
// 禁止对整个源 Blob 调 arrayBuffer,slice 记录请求块长并交给真实 Blob 读字节。
|
||||
class SlicedBlob extends Blob{arrayBuffer(){throw Error('whole Blob read');}slice(start,end){reads.push(end-start);return super.slice(start,end);}}
|
||||
// 最后一块只有 13 字节;slice 的请求长度不超过上限,并且只在块与块之间让出执行权。
|
||||
assert.equal(await importSourceDigest(new SlicedBlob([data]),undefined,{chunkBytes:65536,yieldTask:async()=>{yields++;}}),expected(data));assert.equal(reads.length,17);assert.ok(reads.every(size=>size<=65536));assert.equal(yields,16);
|
||||
});
|
||||
// 分别在读块挂起时 abort 和 yield 回调内 abort;迟到块及调度返回后都不能完成摘要,统一拒绝 import_aborted。
|
||||
|
||||
Reference in New Issue
Block a user