76 lines
13 KiB
JavaScript
76 lines
13 KiB
JavaScript
import {test} from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,editable} from '../../src/lib/canvas-core/index.ts';
|
|
import {rectangleGeometry,bezierChainSegments} from '../../src/lib/canvas-core/geometry.ts';
|
|
import {hitCubic} from '../../src/lib/canvas-core/curve-hit.ts';
|
|
import {hitProjectedCanvas} from '../../src/lib/canvas-bindings/hit-query.ts';
|
|
import {createRendererCatalog,builtinRendererDefinitions} from '../../src/lib/canvas-bindings/renderers.ts';
|
|
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
|
import {toWorld} from '../../src/lib/canvas-core/coordinates.ts';
|
|
import {compatConnector,measuredConnector} from '../../src/lib/canvas-bindings/connectors.ts';
|
|
const registry=createRegistry(builtinComponents());
|
|
const node=(id,owner='P',geometry={},componentId='text',data={})=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'',...data},geometry:{x:0,y:0,width:100,height:100,rotation:0,...geometry}});
|
|
const edge=(id,source,target,owner='P',path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source},target:{elementId:target},data:{},path});
|
|
const doc=elements=>createDocument('project',[{id:'P',parent:null}],elements);
|
|
const draw=(document,r=registry,canvas='P',preview=()=>undefined)=>projectCanvas(document,canvas,r,new Set(),preview);
|
|
const query=(projection,r=registry,point={x:50,y:50},options={},services={},budget={})=>hitProjectedCanvas(projection,createDocumentQueries(projection.document,r),r,'view',{space:'scene',point,zoom:1,...options},services,budget);
|
|
|
|
test('grid configuration is captured per editor and retained through commits and history',()=>{
|
|
const configuration={cellSize:32,maxElementCells:4,maxQueryCells:2},document=doc([node('A')]);let next=0;const editor=createEditor({document,registry,queryBudget:configuration,createId:()=>`op${++next}`});configuration.cellSize=1000;
|
|
assert.deepEqual(editor.getQueries().budget,{cellSize:32,maxElementCells:4,maxQueryCells:2});assert.ok(Object.isFrozen(editor.getQueries().budget));assert.ok(editor.getQueries().spatial.get('P').oversized.has('A'));assert.deepEqual(editor.getQueries().hitNodes('P',{x:50,y:50}).ids,['A']);editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:300}});assert.equal(editor.getQueries().budget.cellSize,32);editor.history.undo();assert.deepEqual(editor.getQueries().hitNodes('P',{x:50,y:50}).ids,['A']);
|
|
for(const configuration of [{cellSize:0},{cellSize:Infinity},{maxElementCells:.1},{maxQueryCells:1000001}])assert.throws(()=>createDocumentQueries(document,registry,0,configuration),/invalid_query_budget/);
|
|
assert.throws(()=>advanceDocumentQueries(editor.getQueries(),editor.getSnapshot().document,registry,editor.getQueries().revision,undefined,{cellSize:64}),/query_budget_changed/);
|
|
});
|
|
|
|
test('component geometry extends hit bounds and invalidates only declared data dependencies',()=>{
|
|
const geometry={kind:'node',version:1,dataDependencies:['radius'],bounds:({geometry:g,element})=>({x:g.x-element.data.radius,y:g.y-element.data.radius,width:2*element.data.radius,height:2*element.data.radius}),hit:({geometry:g,element,point,tolerance})=>Math.hypot(point.x-g.x,point.y-g.y)<=element.data.radius+tolerance},definition={id:'circle',dataVersion:1,structure:'node',features:[editable()],validate:()=>true,copy:{references:[],omit:[]},geometry},r=createRegistry([...builtinComponents(),definition]);geometry.dataDependencies.push('title');assert.deepEqual(r.components.get('circle').geometry.dataDependencies,['radius']);
|
|
let next=0;const editor=createEditor({document:doc([node('A','P',{},'circle',{radius:200})]),registry:r,createId:()=>`op${++next}`}),first=editor.getQueries();assert.deepEqual(first.hitNodes('P',{x:-150,y:0}).ids,['A']);assert.deepEqual(first.hitNodeBounds('P',{x:-150,y:0}).ids,[]);
|
|
editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'new'}});assert.equal(editor.getQueries().spatial,first.spatial);assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{radius:10}}).status,'applied');assert.notEqual(editor.getQueries().spatial,first.spatial);assert.deepEqual(editor.getQueries().hitNodes('P',{x:-150,y:0}).ids,[]);
|
|
for(const bad of [{...geometry,version:2},{...geometry,kind:'connection'},{...geometry,hit:null},{...geometry,dataDependencies:[3]}])assert.throws(()=>createRegistry([{...definition,geometry:bad}]),/invalid_geometry_protocol/);
|
|
const badRegistry=createRegistry([{...definition,geometry:{...geometry,bounds:()=>({x:0,y:0,width:-1,height:10})}}]),bad=doc([node('A','P',{},'circle',{radius:200})]);assert.deepEqual(createDocumentQueries(bad,badRegistry).hitNodes('P',{x:0,y:0}),{ids:[],complete:false,unavailable:['A']});
|
|
});
|
|
|
|
test('scene query converts screen pixels once and returns nested display path at different zooms',()=>{
|
|
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P',{x:300,y:200,width:500,height:400,rotation:37},'frame',{presentation:{contentScale:2,unitScale:.75}}),node('A','S',{x:100,y:100,width:80,height:60,rotation:21})]),p=draw(document),transform=p.transforms.get('S'),point=toWorld(transform,{x:140,y:130});
|
|
for(const zoom of [.1,1,4]){const viewport={x:70,y:-110,zoom,origin:{x:200,y:60}},result=query(p,registry,{x:point.x*zoom+viewport.x+viewport.origin.x,y:point.y*zoom+viewport.y+viewport.origin.y},{space:'screen',viewport,tolerancePx:0});const hit=result.hits.find(hit=>hit.target.id==='A');assert.ok(hit);assert.equal(hit.ownerCanvasId,'S');assert.deepEqual(hit.displayPath,{viewSessionId:'view',canvasIds:['P','S'],entryIds:['F']});assert.ok(Math.abs(result.pointScene.x-point.x)<1e-8);assert.equal(result.complete,true);assert.ok(Object.isFrozen(hit.displayPath.canvasIds));}
|
|
const child=query(draw(document,registry,'S'),registry,{x:140,y:130},{tolerancePx:0}).hits.find(hit=>hit.target.id==='A');assert.deepEqual(child.displayPath.canvasIds,['S']);assert.deepEqual(child.displayPath.entryIds,[]);
|
|
const simple=draw(doc([node('A')]));for(const zoom of [.1,1,4]){assert.equal(query(simple,registry,{x:100+5/zoom,y:50},{zoom,tolerancePx:6}).hits.length,1);assert.equal(query(simple,registry,{x:100+7/zoom,y:50},{zoom,tolerancePx:6}).hits.length,0);}
|
|
});
|
|
|
|
test('preview nodes outside committed grid remain hittable; stale provenance is rejected',()=>{
|
|
const document=doc([node('A')]),p=draw(document,registry,'P',()=>({...document.elements.get('A').geometry,x:5000}));assert.equal(query(p,registry,{x:5050,y:50}).hits.length,0);assert.equal(query(p,registry,{x:5050,y:50},{},{previewIds:new Set(['A'])}).hits[0].target.id,'A');assert.equal(query(p,registry,{x:50,y:50},{},{previewIds:new Set(['A'])}).hits.length,0);
|
|
assert.throws(()=>hitProjectedCanvas(p,createDocumentQueries(document,registry),createRegistry(builtinComponents()),'view',{space:'scene',point:{x:0,y:0},zoom:1}),/query_registry_changed/);assert.throws(()=>projectCanvas(document,'P',createRegistry(builtinComponents()),new Set(),()=>undefined,null,undefined,{queries:createDocumentQueries(document,registry)}),/query_registry_changed/);
|
|
const other=doc([node('A')]);assert.throws(()=>hitProjectedCanvas(p,createDocumentQueries(other,registry),registry,'view',{space:'scene',point:{x:0,y:0},zoom:1}),/stale_query_snapshot/);assert.throws(()=>query(p,registry,{x:0,y:0},{zoom:Number.MIN_VALUE}),/invalid_hit_query/);
|
|
});
|
|
|
|
const cubicPoint=([a,b,c,d],t)=>({x:(1-t)**3*a.x+3*(1-t)**2*t*b.x+3*(1-t)*t*t*c.x+t**3*d.x,y:(1-t)**3*a.y+3*(1-t)**2*t*b.y+3*(1-t)*t*t*c.y+t**3*d.y});
|
|
test('bounded cubic refinement hits actual curve, rejects chord and preserves ambiguous/budget outcomes',()=>{
|
|
const curve=[{x:0,y:0},{x:0,y:200},{x:200,y:200},{x:200,y:0}],options={error:.01,maxDepth:24,maxSteps:10000};for(const t of [.1,.33,.5,.8]){const result=hitCubic(curve,cubicPoint(curve,t),2,options);assert.equal(result.hit,true);assert.equal(result.certainty,'confirmed');}assert.equal(hitCubic(curve,{x:100,y:0},2,options).hit,false);
|
|
const exhausted=hitCubic(curve,{x:100,y:150},1,{...options,maxSteps:1});assert.equal(exhausted.hit,true);assert.equal(exhausted.certainty,'possible');assert.equal(exhausted.budget,true);
|
|
const line=[{x:0,y:0},{x:10,y:0},{x:90,y:0},{x:100,y:0}];assert.equal(hitCubic(line,{x:50,y:3},4,options).certainty,'confirmed');assert.equal(hitCubic(line,{x:50,y:5},4,options).hit,false);
|
|
const huge=[{x:-1e308,y:0},{x:-1e308,y:1e308},{x:1e308,y:1e308},{x:1e308,y:0}];const result=hitCubic(huge,{x:0,y:7.5e307},1e305,options);assert.equal(result.hit,true);assert.ok(!Number.isNaN(result.distance));
|
|
});
|
|
|
|
test('connection query follows projected curve and collapsed endpoints, with explicit unavailable custom geometry',()=>{
|
|
const path={type:'bezier-chain',knots:[],startControl:{x:0,y:200},endControl:{x:0,y:200}},document=doc([node('A'),node('B','P',{x:500}),edge('E','A','B','P',path)]),p=draw(document),from=compatConnector(p.nodes[0],undefined,'right'),to=compatConnector(p.nodes[1],undefined,'left'),point=cubicPoint(bezierChainSegments(from,to,path)[0],.5);
|
|
for(const zoom of [.1,1,4]){const result=query(p,registry,point,{zoom,tolerancePx:6});assert.equal(result.complete,true);assert.equal(result.hits.find(hit=>hit.target.id==='E').certainty,'confirmed');}
|
|
const budget=query(p,registry,point,{}, {},{maxCurveSteps:1});assert.equal(budget.complete,false);assert.equal(budget.hits.find(hit=>hit.target.id==='E').certainty,'possible');assert.ok(budget.diagnostics.some(item=>item.code==='geometry_query_budget'));
|
|
const invalid=query(p,registry,point,{}, {edgeGeometry:()=>[[{x:NaN,y:0}]]});assert.equal(invalid.complete,false);assert.ok(invalid.diagnostics.some(item=>item.code==='invalid_geometry_response'));
|
|
const collapsed=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),node('F','P',{x:500},'frame',{presentation:{collapsed:true}}),node('A','S'),edge('E','C','A')]),c=draw(collapsed),f=compatConnector(c.nodes.find(n=>n.id==='C'),undefined,'right'),t=compatConnector(c.nodes.find(n=>n.id==='F'),undefined,'left'),at=cubicPoint(bezierChainSegments(f,t)[0],.5),hit=query(c,registry,at).hits.find(hit=>hit.target.id==='E');assert.deepEqual(hit.displayedEndpoints,{source:'C',target:'F'});assert.equal(hit.ownerCanvasId,'P');assert.equal(collapsed.elements.get('E').target.elementId,'A');
|
|
});
|
|
|
|
test('measured native handles use outer edges and loose target fallback without fabricating absent bounds',()=>{
|
|
const node={internals:{positionAbsolute:{x:100,y:200},handleBounds:{source:[{id:'out',position:'right',x:90,y:40,width:10,height:20}],target:null}}};assert.deepEqual(measuredConnector(node,'out','source'),{point:{x:200,y:250},normal:{x:1,y:0}});assert.deepEqual(measuredConnector(node,'out','target'),measuredConnector(node,'out','source'));assert.equal(measuredConnector(node,'absent','target'),undefined);assert.equal(measuredConnector(undefined,null,'source'),undefined);
|
|
});
|
|
|
|
|
|
test('registered custom renderers provide model hit shapes, measured anchors, display-space curves and stroke width',()=>{
|
|
let context;
|
|
const definition={id:'custom',dataVersion:1,structure:'node',features:[],validate:()=>true,geometry:{...rectangleGeometry,hit:({geometry:g,point,tolerance})=>Math.hypot(point.x-g.x-g.width/2,point.y-g.y-g.height/2)<=g.width/2+tolerance}},connection={id:'custom-edge',dataVersion:1,structure:'connection',features:[],validate:()=>true,geometry:{kind:'connection',version:1,strokeWidth:()=>20,segments:input=>{context=input;return[[input.source.point,{x:200,y:200},{x:300,y:200},input.target.point]];}}},r=createRegistry([...builtinComponents(),definition,connection]),catalog=createRendererCatalog([...builtinRendererDefinitions,{componentId:'custom',dataVersion:1,structure:'node',rendererId:'circle'},{componentId:'custom-edge',dataVersion:1,structure:'connection',rendererId:'custom-edge'}]),document=doc([node('A','P',{},'custom'),node('B','P',{x:400},'custom'),{...edge('E','A','B'),componentId:'custom-edge',path:{type:'opaque-custom',value:7}}]),p=projectCanvas(document,'P',r,new Set(),()=>undefined,null,catalog),anchors={anchor:(node,_handle,end)=>({point:{x:node.position.x+(end==='source'?100:0),y:50},normal:{x:end==='source'?1:-1,y:0}})};
|
|
assert.equal(query(p,r,{x:1,y:1},{tolerancePx:0},anchors).hits.some(hit=>hit.target.id==='A'),false);
|
|
const absent=query(p,r,{x:250,y:160},{tolerancePx:0});assert.ok(absent.diagnostics.some(item=>item.code==='geometry_anchor_unavailable'));
|
|
const hit=query(p,r,{x:250,y:170},{tolerancePx:0},anchors);assert.equal(hit.hits.find(item=>item.target.id==='E').certainty,'confirmed');assert.equal(context.element,document.elements.get('E'));assert.equal(context.displayPath,undefined);assert.deepEqual(context.element.path,{type:'opaque-custom',value:7});assert.deepEqual(context.transform,{x:0,y:0,scale:1,rotation:0});
|
|
const invalid=query(p,r,{x:250,y:170},{}, {...anchors,strokeWidth:()=>NaN});assert.equal(invalid.complete,false);assert.ok(invalid.diagnostics.some(item=>item.code==='invalid_geometry_paint'));
|
|
const throwing=query(p,r,{x:250,y:170},{}, {...anchors,edgeGeometry:()=>{throw Error('handler');}});assert.equal(throwing.complete,false);assert.ok(throwing.diagnostics.some(item=>item.code==='geometry_handler_failed'));
|
|
});
|