115 lines
10 KiB
JavaScript
115 lines
10 KiB
JavaScript
/**
|
||
* 发送批次的稀疏变更压缩测试。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();
|
||
// changes 可以集中到末个 wire 操作,但每个逻辑操作 ID 都须保留,便于回执完整覆盖原 ledger。
|
||
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<JSON.stringify(f.intents.map(({id,changes})=>({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仍留创建与删除,不把生命周期错误压成无操作。
|
||
// 这里是保留条件与生命周期的 wire 压缩;整体本地抵消须由 elision 另行证明,不能直接套用普通 create→delete 合并。
|
||
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})];
|
||
// 值最终仍缺失,不等于可以删除条件:beforePresent=false 还能阻止在已有 optional 的文档上误应用。
|
||
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]);
|
||
// 待压缩字段是很短的 blob,而参与范围护栏需携带含 emoji 的元素;UTF-8 成本会抵消压缩收益。
|
||
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})));
|
||
});
|