/** * 发送批次的稀疏变更压缩测试。wire操作保留全部逻辑ID,压缩只改变传输changes,不改ledger/history输入。 * 存在基线、前置条件、生命周期与UTF-8实际字节成本约束,不以“最终对象一样”就无条件消去中间意图。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createDocument, applyChanges } from '../../src/lib/canvas-core/document.ts'; import { createLedger, compressChanges } from '../../src/lib/canvas-runtime/ledger.ts'; import { projectBatch } from '../../src/lib/canvas-runtime/batch-projection.ts'; // root/child用于参与范围护栏对照,默认node同时含未来嵌套字段,检查位置压缩不覆盖业务数据。 const root='root', child='child'; const node=(id='a',ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'original',future:{keep:true}},geometry:{x:0,y:0,width:100,height:200,rotation:0}}); const document=createDocument(root,[{id:root,parent:null}],[node()]); /** 默认改a第一生命周期正文,可换字段/存在性标记,方便串起连续before→after。 */ const field=(before,after,key='data.text',extra={})=>({kind:'field',target:{id:'a',incarnation:1},field:key,before,after,...extra}); // 放大中间值让确定安全的连续编辑压缩更可能减少真实wire体积。 const long='intermediate '.repeat(200); /** 按顺序创建相依intent并入ledger,首次身份存在性来自base/显式presence;project允许缩小参与canvas范围。 */ function fixture(changes,base=document,presence){ const ledger=createLedger(); const intents=changes.map((changes,index)=>({id:`op-${index}`,operationType:'document.change',operationSchemaVersion:1,sequence:index+1,origin:'user',canvasIds:[root],dependencies:index?[`op-${index-1}`]:[],changes})); const seen=new Set(); for(const intent of intents){const bases={};for(const change of intent.changes){if(change.kind==='canvas')continue;const id=change.kind==='field'?change.target.id:change.id;bases[id]=presence??(seen.has(id)||base.elements.has(id)?'exists':'absent');seen.add(id);}ledger.add(intent,bases);} return{ledger,intents,project:(scope=[root])=>projectBatch(intents,base,ledger.getSnapshot(),scope)}; } /** 文档转成元素/canvas/墓碑数组比较完整结果,避免只看最终正文而漏生命周期差异。 */ const values=document=>({elements:[...document.elements],canvases:[...document.canvases],tombstones:[...document.tombstones]}); // original→长中间值→final可合成一个字段修改,但两逻辑ID都在;比较wire前后ledger及完整apply结果。 test('wire projection composes long field edits, preserves every original logical ID and leaves ledger/history inputs untouched',()=>{ const f=fixture([[field('original',long)],[field(long,'final')]]),before=JSON.stringify([...f.ledger.getSnapshot().operations]),wire=f.project(); assert.deepEqual(wire.map(op=>op.id),f.intents.map(op=>op.id)); assert.deepEqual(wire.at(-1).changes,[field('original','final','data.text',{afterPresent:true})]); assert.ok(JSON.stringify(wire).length({id,changes}))).length); assert.equal(JSON.stringify([...f.ledger.getSnapshot().operations]),before); assert.deepEqual(values(applyChanges(document,wire.flatMap(op=>op.changes))),values(applyChanges(document,f.intents.flatMap(op=>op.changes)))); assert.equal(applyChanges(document,wire.flatMap(op=>op.changes)).elements.get('a').data.future.keep,true); }); // 几何合成保留起始before,最终坐标正确且title/future业务数据完整,不用最后节点覆盖整对象。 test('position-only compaction keeps title, opaque data and the original before condition',()=>{ const start=node().geometry,middle={...start,x:12345,y:67890},final={...start,x:3,y:4},f=fixture([[field(start,middle,'geometry')],[field(middle,final,'geometry')]]),wire=f.project(); assert.equal(wire.at(-1).changes.length,1);assert.deepEqual(wire.at(-1).changes[0].before,start); const result=applyChanges(document,wire.flatMap(op=>op.changes));assert.deepEqual(result.elements.get('a').data,node().data);assert.deepEqual(result.elements.get('a').geometry,final); }); // new创建/更新/删除最终无节点但有墓碑;wire仍留创建与删除,不把生命周期错误压成无操作。 test('created records absorb checked updates while create/delete keeps tombstone and lifecycle evidence',()=>{ const created={...node('new'),data:{title:'new',text:long}},updated={...created,data:{...created.data,text:'final'}}; const f=fixture([[{kind:'element',id:'new',before:null,after:created}],[{...field(long,'final'),target:{id:'new',incarnation:1}}],[{kind:'element',id:'new',before:updated,after:null}]]),wire=f.project(); const result=applyChanges(document,wire.flatMap(op=>op.changes));assert.equal(result.elements.has('new'),false);assert.equal(result.tombstones.get('new'),1); assert.ok(wire.at(-1).changes.some(change=>change.kind==='element'&&change.after));assert.ok(wire.at(-1).changes.some(change=>change.kind==='element'&&change.after===null)); assert.deepEqual(values(result),values(applyChanges(document,f.intents.flatMap(op=>op.changes)))); }); // 可选字段从absent到值再absent,净值虽零仍保留前置存在性检查,apply结果与逐项操作相同。 test('net-zero field changes retain conditional presence checks; omitted true flags compose with explicit true flags',()=>{ const changes=[field(null,long,'data.optional',{beforePresent:false}),field(long,null,'data.optional',{beforePresent:true,afterPresent:false})]; const compressed=compressChanges(changes,{preserveChecks:true,preserveLifecycle:true});assert.equal(compressed.length,1);assert.equal(compressed[0].beforePresent,false);assert.equal(compressed[0].afterPresent,false); assert.deepEqual(values(applyChanges(document,compressed)),values(applyChanges(document,changes))); const f=fixture([[changes[0]],[changes[1]]]);assert.equal(f.project().at(-1).changes[0].beforePresent,false); }); // unknown/absent矛盾、缺基线、已冻结或blocked都回退原始顺序,不擅自重压可能发送的意图。 test('unknown or inconsistent baselines and frozen or blocked records retain ordered original changes',()=>{ for(const presence of ['unknown','absent']){ const f=fixture([[field('original',long)],[field(long,'final')]],document,presence);assert.deepEqual(f.project(),f.intents.map(({id,changes})=>({id,changes}))); } const missing=fixture([[field('original',long)],[field(long,'final')]]),snapshot=missing.ledger.getSnapshot(),operations=new Map(snapshot.operations); operations.set('op-0',{...operations.get('op-0'),basePresence:{}}); assert.deepEqual(projectBatch(missing.intents,document,{...snapshot,operations},[root]),missing.intents.map(({id,changes})=>({id,changes}))); for(const state of ['frozen','blocked']){ const f=fixture([[field('original',long)],[field(long,'final')]]); if(state==='frozen')f.ledger.freeze({id:'request',operationIds:['op-0'],canvasIds:[root],expectedVersions:{[root]:1},payload:{}});else f.ledger.block('op-0',['permission']); assert.deepEqual(f.project(),f.intents.map(({id,changes})=>({id,changes}))); } }); // 只允许child参与时不能借root条件证明压缩安全,也不能为了压缩扩大writer参与范围。 test('empty child creation does not borrow a parent canvas guard or widen participant claims',()=>{ const base=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'frame'}}],[{...node('frame'),componentId:'frame'}]); const created={...node('new',child),data:{title:'new',text:long}},f=fixture([[{kind:'element',id:'new',before:null,after:created}],[{...field(long,'final'),target:{id:'new',incarnation:1}}]],base); assert.deepEqual(f.project([child]),f.intents.map(({id,changes})=>({id,changes}))); }); // 创建后更新before不符或incarnation错误,压缩函数和batch都须拒绝,不能吸收进create掩盖冲突。 test('invalid original before values and stale incarnations cannot become successful compacted creates',()=>{ const created={...node('new'),data:{title:'new',text:long}}; for(const change of [{...field('wrong','final'),target:{id:'new',incarnation:1}},{...field(long,'final'),target:{id:'new',incarnation:2}}]){ assert.throws(()=>compressChanges([{kind:'element',id:'new',before:null,after:created},change]),/field_conflict|stale_incarnation/); const f=fixture([[{kind:'element',id:'new',before:null,after:created}],[change]]);assert.throws(()=>f.project(),/field_conflict|stale_incarnation/); } }); // 禁止完整表遍历及读取untouched,修改a仍可完成;稀疏证明只访问相关身份。 test('projection checks only affected records and leaves a large untouched document outside its sparse proof',()=>{ const elements=new Map(document.elements);elements.set('untouched',{...node('untouched'),data:{title:'opaque',text:'large'}}); const guarded={...document,elements:{get:id=>{assert.notEqual(id,'untouched');return elements.get(id);},has:id=>elements.has(id),[Symbol.iterator](){throw new Error('full_document_scan');}}}; const f=fixture([[field('original',long)],[field(long,'final')]],guarded);assert.equal(f.project().at(-1).changes[0].after,'final'); }); // 表情字符使guard UTF-8成本增加,按字节判定后应放弃无收益压缩,不能只比较JS字符串长度。 test('wire cost uses UTF-8 bytes, so a Unicode guard cannot turn a shorter JS string into a larger request',()=>{ const a={...node('a',child),data:{title:'😀'.repeat(50),text:'😀'.repeat(50),blob:'x'}}; const base=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'frame'}}],[{...node('frame'),componentId:'frame'},a]); const middle='m'.repeat(150),f=fixture([[field('x',middle,'data.blob')],[field(middle,'final','data.blob')]],base),wire=f.project([child]); assert.deepEqual(wire,f.intents.map(({id,changes})=>({id,changes}))); });