93 lines
8.0 KiB
JavaScript
93 lines
8.0 KiB
JavaScript
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';
|
|
|
|
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()]);
|
|
const field=(before,after,key='data.text',extra={})=>({kind:'field',target:{id:'a',incarnation:1},field:key,before,after,...extra});
|
|
const long='intermediate '.repeat(200);
|
|
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)};
|
|
}
|
|
const values=document=>({elements:[...document.elements],canvases:[...document.canvases],tombstones:[...document.tombstones]});
|
|
|
|
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<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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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))));
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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})));
|
|
}
|
|
});
|
|
|
|
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})));
|
|
});
|
|
|
|
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/);
|
|
}
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
|
|
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})));
|
|
});
|