docs(tests): explain paint bounds hit queries and scene caching

This commit is contained in:
2026-11-12 02:40:06 +08:00
parent ea102b9949
commit 330d8d6e34
5 changed files with 85 additions and 0 deletions
+14
View File
@@ -1,22 +1,36 @@
/**
* 连线绘制溢出与缓存回归。端点矩形不足以代表曲线范围,索引需包含控制多边形和描边;缺锚点、非法响应或预算不足必须unknown。
* 使用服务回调注入锚点/曲线/线宽,测试路径不依赖真实DOM测量或浏览器SVG绘制。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
import {projectedConnectionPaintOverflow} from '../../src/lib/canvas-bindings/connection-paint.ts';
import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts';
// 两节点位于y=500,边可指定ID;几何服务可将曲线弯到视口上方以制造端点矩形外的绘制。
const registry=createRegistry(builtinComponents()),node=(id,x)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x,y:500,width:100,height:100,rotation:0}}),edge=(id='edge')=>({id,incarnation:1,ownerCanvasId:'p',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null});
/** 每次新建等价文档/投影,检验缓存不能只靠输入引用相同判断可复用。 */
const draw=(edges=[edge()])=>projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),...edges]),'p',registry,new Set(),()=>undefined);
// 控制点到y=-500,10描边使保守范围扩5;查询端点之间屏幕外弯曲区域仍能找到edge,节点y保持500。
test('bowed cubic paint is indexed outside endpoint rectangles including stroke while document bounds remain unchanged',()=>{const projection=draw(),result=projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:100,y:-500},{x:500,y:-500},{x:500,y:550}]],strokeWidth:()=>10});assert.deepEqual(result.unknown,[]);assert.deepEqual(result.regions[0].bounds,{x:95,y:-505,width:410,height:1060});const index=createPaintIndex();index.sync(result.regions);assert.ok(index.query({x:250,y:0,width:10,height:10}).has('edge'));assert.equal(projection.document.elements.get('a').geometry.y,500);});
// 直线全被端点范围覆盖时无额外区域;缺/NaN锚点、非法段和无限线宽不能被当成无需绘制。
test('straight contained paint needs no overflow; missing anchors and invalid geometry stay unknown',()=>{const projection=draw();assert.equal(projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2}).regions.length,0);for(const services of [{anchor:()=>undefined},{anchor:()=>({point:{x:NaN,y:0},normal:{x:1,y:0}})},{edgeGeometry:()=>[[{x:NaN,y:0}]],strokeWidth:()=>1},{strokeWidth:()=>Infinity}])assert.deepEqual(projectedConnectionPaintOverflow(projection,registry,services).unknown,['edge']);});
// 边或段预算超限用paint_budget/edge unknown报告;非法零预算拒绝,不伪称完整覆盖。
test('work budgets return unknown instead of treating omitted edges or excessive segments as invisible',()=>{const projection=draw([edge('one'),edge('two')]);assert.ok(projectedConnectionPaintOverflow(projection,registry,{},1).unknown.includes('paint_budget'));assert.deepEqual(projectedConnectionPaintOverflow(draw(),registry,{edgeGeometry:()=>[[],[]]},4096,1).unknown,['edge']);assert.throws(()=>projectedConnectionPaintOverflow(draw(),registry,{},0));});
import {createConnectionPaintIndex} from '../../src/lib/canvas-bindings/connection-paint.ts';
// 相同ACK/节点title不重算曲线;测量锚点、显式geometryRevision与incarnation变化需重算,删除边清缓存。
test('connection geometry cache skips equal ACKs and unrelated titles, invalidates measured anchors, stroke configuration and lifecycle',()=>{const cache=createConnectionPaintIndex();let geometryCalls=0,shift=0,width=2;const services={anchor:(node,_handle,end)=>({point:{x:node.position.x+(end==='source'?100:0)+shift,y:550},normal:{x:end==='source'?1:-1,y:0}}),edgeGeometry:(_edge,from,to)=>{geometryCalls++;return[[from.point,{x:200,y:550},{x:400,y:550},to.point]];},strokeWidth:()=>width};cache.sync(draw(),registry,services);assert.equal(geometryCalls,1);cache.sync(draw(),registry,services);assert.equal(geometryCalls,1);const p=draw();p.nodes[0].data.title='unrelated node title';cache.sync(p,registry,services);assert.equal(geometryCalls,1);shift=10;cache.sync(draw(),registry,services);assert.equal(geometryCalls,2);width=20;cache.sync(draw(),registry,services,'new-stroke-config');assert.equal(geometryCalls,3);const restored=projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),{...edge(),incarnation:2}]),'p',registry,new Set(),()=>undefined);cache.sync(restored,registry,services,'new-stroke-config');assert.equal(geometryCalls,4);cache.sync({...restored,edges:[]},registry,services,'new-stroke-config');assert.equal(cache.stats().entries,0);});
// 曲线已缓存也要计入新maxSegments预算,不能因不重算就绕过限制;clear清实际条目。
test('cached segment costs still respect a smaller budget instead of claiming complete coverage',()=>{const cache=createConnectionPaintIndex(),projection=draw([edge('one'),edge('two')]),services={edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2};cache.sync(projection,registry,services);const computed=cache.stats().computed,limited=cache.sync(projection,registry,services,undefined,{maxSegments:1});assert.ok(limited.unknown.includes('paint_budget'));assert.equal(cache.stats().computed,computed);cache.clear();assert.equal(cache.stats().entries,0);});
// 仅投影预览path弯到上方,document.path仍null;进入/退出预览都重算且查询范围随之恢复。
test('connection preview paths invalidate paint even while the committed connection record stays unchanged',()=>{const cache=createConnectionPaintIndex(),original=draw(),nearBow={x:250,y:-50,width:100,height:100};assert.equal(cache.sync(original,registry).query(nearBow).size,0);const preview=draw();preview.edges[0].data.path={type:'bezier-chain',knots:[{id:'preview-knot',mode:'corner',position:{x:300,y:-1000},in:{x:-100,y:0},out:{x:100,y:0}}]};const shown=cache.sync(preview,registry);assert.ok(shown.query(nearBow).has('edge'));assert.equal(preview.document.elements.get('edge').path,null);assert.equal(cache.sync(draw(),registry).query(nearBow).size,0);assert.equal(cache.stats().computed,3);});
// 无边时代理禁止遍历nodes,溢出与缓存路径均直接返回空结果,避免全图无效工作。
test('an empty connection projection does not enumerate unrelated nodes',()=>{
const projection=draw();projection.edges=[];projection.nodes=new Proxy(projection.nodes,{get(target,key,receiver){if(key===Symbol.iterator||key==='map')throw Error('unrelated nodes enumerated');return Reflect.get(target,key,receiver);}});
assert.deepEqual(projectedConnectionPaintOverflow(projection,registry),{regions:[],unknown:[],computed:0});assert.equal(createConnectionPaintIndex().sync(projection,registry).unknown.length,0);
});
// 重复ID按后者解析锚点,原地替换投影行也读新位置;缺b则两边unknown,较小边预算仍正确报告。
test('endpoint filtering preserves duplicate IDs, mutable replacement, missing targets and edge budgets',()=>{
const projection=draw([edge('one'),edge('two')]),last={...projection.nodes[0],position:{x:900,y:500}},positions=[];
projection.nodes.push(last);const services={anchor:(node,_handle,end)=>{positions.push(node.position.x);return{point:{x:node.position.x,y:550},normal:{x:end==='source'?1:-1,y:0}};}};
+18
View File
@@ -1,3 +1,7 @@
/**
* 模型与投影命中的几何/预算回归。覆盖自定义形状、屏幕像素容差、嵌套坐标、预览、曲线细分和实际锚点适配。
* complete、confirmed、possible及diagnostics分别检查;未知或预算耗尽不能伪造确定命中。
*/
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';
@@ -9,12 +13,18 @@ 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());
/** 可指定owner/几何/component/data的节点工厂,保持通用包络合法以聚焦几何行为。 */
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}});
/** 连线工厂允许owner与自定义path,端点默认不含port,由适配服务补锚点。 */
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});
/** 单canvas P的最小文档,嵌套用例单独构造树。 */
const doc=elements=>createDocument('project',[{id:'P',parent:null}],elements);
/** 直接投影指定canvas,可注入预览几何,避免依赖React渲染。 */
const draw=(document,r=registry,canvas='P',preview=()=>undefined)=>projectCanvas(document,canvas,r,new Set(),preview);
/** 按该投影文档建query并使用固定view身份,默认scene空间/zoom=1,可覆盖屏幕viewport、services及预算。 */
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);
// queryBudget在editor创建时捕获冻结,外部后改不影响;编辑/undo沿用配置,非法预算和中途换预算拒绝。
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']);
@@ -22,6 +32,7 @@ test('grid configuration is captured per editor and retained through commits and
assert.throws(()=>advanceDocumentQueries(editor.getQueries(),editor.getSnapshot().document,registry,editor.getQueries().revision,undefined,{cellSize:64}),/query_budget_changed/);
});
// 自定义圆形可命中逻辑矩形外,radius变化失效而title不失效;registry捕获依赖,非法协议/响应给明确拒绝或不完整结果。
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,[]);
@@ -30,6 +41,7 @@ test('component geometry extends hit bounds and invalidates only declared data d
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']});
});
// 旋转缩放父子树在0.1/1/4倍屏幕坐标往返只转换一次;displayPath反映所开视图根,像素容差反算随zoom变化。
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));}
@@ -37,13 +49,16 @@ test('scene query converts screen pixels once and returns nested display path at
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);}
});
// 预览A移到x=5000,显式previewIds补入候选并排除旧位置;registry/文档来源不匹配和退化zoom均拒绝。
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/);
});
/** 按三次贝塞尔多项式正向生成曲线点,作为hitCubic的几何对照输入而非采样碰运气。 */
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});
// 正向曲线点应confirmed,弦中点不误命中;一步预算只possible且budget=true,极大坐标不能产NaN距离。
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);
@@ -51,6 +66,7 @@ test('bounded cubic refinement hits actual curve, rejects chord and preserves am
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));
});
// 按显示曲线跨zoom命中,受限细分为possible/不完整;折叠重定向显示端点到F但文档端点仍A。
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');}
@@ -59,11 +75,13 @@ test('connection query follows projected curve and collapsed endpoints, with exp
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');
});
// Handle在外侧边缘求锚点,Loose目标可回退source;不存在的handle或node不捏造边界点。
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);
});
// 自定义圆节点与粗线使用模型协议及服务锚点;缺锚点/非法线宽/handler抛错分别报告诊断,opaque path保持原样。
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}})};
+13
View File
@@ -1,24 +1,37 @@
/**
* 节点绘制范围与空间缓存测试。paint范围可以超出逻辑矩形,处理失败须保留unknown;缓存按实际依赖判断,不以ACK对象引用变化强制重算。
* 以纯矩形、投影和计数验证索引,不执行浏览器绘制,也不修改文档几何去迎合裁剪。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts';
/** 默认10×10区域,参数可构造边界接触、移动、巨型覆盖及非法矩形。 */
const region=(id,x,y,width=10,height=10)=>({id,bounds:{x,y,width,height}});
// 10单位网格测试负坐标与点边界,巨型范围保守索引;移动/移除/clear及时失效,原bounds对象不变。
test('paint grid updates moves/deletes, includes boundary and oversized entries without changing model geometry',()=>{const index=createPaintIndex(10,4),a=region('a',-10,-10),huge=region('huge',-1000,-1000,2000,2000);index.sync([a,huge]);assert.deepEqual([...index.query({x:0,y:0,width:0,height:0})].sort(),['a','huge']);index.sync([region('a',100,100),huge]);assert.deepEqual([...index.query({x:0,y:0,width:1,height:1})],['huge']);index.sync([region('a',100,100)]);assert.equal(index.query({x:0,y:0,width:1,height:1}).size,0);assert.equal(index.query({x:-1e9,y:-1e9,width:2e9,height:2e9}).size,1);assert.equal(a.bounds.x,-10);index.clear();assert.equal(index.query(a.bounds).size,0);});
// 整批含NaN或重复ID必须拒绝,safe保留旧网格位置;无限查询坐标同样拒绝。
test('invalid replacement is rejected before any grid changes',()=>{const index=createPaintIndex();index.sync([region('safe',0,0)]);assert.throws(()=>index.sync([region('safe',50,50),region('bad',NaN,0)]));assert.equal(index.query({x:0,y:0,width:1,height:1}).has('safe'),true);assert.throws(()=>index.sync([region('a',0,0),region('a',1,1)]));assert.throws(()=>index.query({x:Infinity,y:0,width:1,height:1}));});
import {projectedPaintOverflow} from '../../src/lib/canvas-bindings/projected-paint.ts';
import {createRegistry,createDocument,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
// 自定义bounds按role给更大paint矩形;broken handler进入unknown,成功范围不能倒写模型尺寸。
test('projected paint uses paint role independently of logical bounds and conservatively retains failed handlers',()=>{const text=builtinComponents().find(c=>c.id==='text'),roles=[],geometry={...text.geometry,bounds(context){roles.push(context.role);if(context.element.data.title==='broken')throw Error('broken');return context.role==='paint'?{x:-100,y:-50,width:400,height:200}:{x:0,y:0,width:100,height:100};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),node=(id,title)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title,text:''}}),document=createDocument('p',[{id:'p',parent:null}],[node('a','okay'),node('b','broken')]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),result=projectedPaintOverflow(projection,registry);assert.ok(roles.includes('paint'));assert.deepEqual(result.regions,[{id:'a',bounds:{x:-100,y:-50,width:400,height:200}}]);assert.deepEqual(result.unknown,['b']);assert.equal(document.elements.get('a').geometry.width,100);});
import {createProjectedPaintIndex} from '../../src/lib/canvas-bindings/projected-paint.ts';
// 同投影复用不增computed,坐标变换变化重算,回原变换仍需重算;节点移除清条目。
test('view paint cache reuses unchanged records, invalidates transform/identity and drops removed rows',()=>{const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:45},data:{title:'a',text:''}},document=createDocument('p',[{id:'p',parent:null}],[node]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();cache.sync(projection,registry);assert.equal(cache.stats().computed,1);cache.sync({...projection},registry);assert.equal(cache.stats().computed,1);const transforms=new Map(projection.transforms),t=transforms.get('p');transforms.set('p',{...t,x:t.x+100});cache.sync({...projection,transforms},registry);assert.equal(cache.stats().computed,2);cache.sync(projection,registry);assert.equal(cache.stats().computed,3);const cleared=cache.sync({...projection,nodes:[]},registry);assert.equal(cache.stats().entries,0);assert.equal(cleared.query({x:-100,y:-100,width:500,height:500}).size,0);cache.clear();});
// 仅声明radius依赖,深相等ACK/改title不重算;radius或incarnation改变才失效,计数同时覆盖上一轮差异还原。
test('paint invalidation follows declared data dependencies instead of ACK record identities or unrelated titles',()=>{let calls=0;const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:['radius'],bounds({element,geometry:g}){calls++;return{x:g.x-element.data.radius,y:g.y,width:g.width+2*element.data.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),nodes=Array.from({length:40},(_,i)=>({id:'n'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'n'+i,text:'',radius:10}})),cache=createProjectedPaintIndex(),sync=records=>cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);sync(nodes);assert.equal(calls,40);sync(structuredClone(nodes));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,title:'only title'}}:n));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,radius:25}}:n));assert.equal(calls,41);sync(nodes.map((n,i)=>i===1?{...n,incarnation:2}:n));assert.equal(calls,43);});
// 1000节点反复同步不重复捕获依赖;单节点位置改动捕获1项,全局变换改动捕获全部。
test('unchanged paint rows retain their captured dependencies across metadata and ACK copies',()=>{
const registry=createRegistry(builtinComponents()),records=Array.from({length:1000},(_,i)=>({id:'stable-'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'stable',text:''}})),document=createDocument('p',[{id:'p',parent:null}],records),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();projection.transforms.set('p',{...projection.transforms.get('p')});cache.sync(projection,registry);assert.equal(cache.stats().captured,1000);for(let i=0;i<10;i++)cache.sync({...projection},registry);assert.equal(cache.stats().captured,1000);cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);assert.equal(cache.stats().captured,1000);
projection.nodes[0].position.x+=500;cache.sync(projection,registry);assert.equal(cache.stats().captured,1001);projection.transforms.get('p').x+=100;cache.sync(projection,registry);assert.equal(cache.stats().captured,2001);
});
// 分别测试未声明与声明paint依赖;原地改嵌套radius/旋转仍可发现,先前捕获快照不能别名到调用方对象。
test('mutable nested paint data and geometry are compared against detached dependency snapshots',()=>{
for(const declared of [undefined,['paint']]){
const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:declared,bounds({element,geometry:g}){return{x:g.x-element.data.paint.radius,y:g.y,width:g.width+2*element.data.paint.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),record={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:'',paint:{radius:10}}},document=createDocument('p',[{id:'p',parent:null}],[record]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),mutable={...projection,document:{...document,elements:new Map([['a',record]])}},cache=createProjectedPaintIndex();assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),false);record.data.paint.radius=30;assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),true);assert.equal(cache.stats().captured,2);record.geometry.rotation=45;cache.sync(mutable,registry);assert.equal(cache.stats().captured,3);assert.equal(Object.isFrozen(record.data.paint),false);
}
});
// 插入renderer-unavailable标记后改用占位绘制规则,不继续用原组件外扩范围;移除标记恢复,范围缓存须失效。
test('renderer fallback switches paint protocols even when data and projected geometry stay equal',()=>{
const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,bounds({geometry:g}){return{x:g.x-100,y:g.y,width:g.width+200,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),document=createDocument('p',[{id:'p',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:''}}]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));projection.nodes[0].data.__headlessFallback={reason:'renderer-unavailable',componentId:'text',dataVersion:1,handles:[]};assert.equal(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).size,0);assert.equal(cache.stats().captured,2);delete projection.nodes[0].data.__headlessFallback;assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));
});
+14
View File
@@ -1,33 +1,47 @@
/**
* ReactFlow投影对象/数组复用的正确性测试。用引用断言区分无关变化、实际数据/选择/测量变化及owner/生命周期替换。
* 缓存必须保留最新document来源,失败准备不能部分替换已发布节点表。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
import {createProjectionCache} from '../../src/lib/canvas-bindings/projection-cache.ts';
// root含A/B及edge;可扩展child/frame构造场景坐标相同但owner不同的情况。
const registry=createRegistry(builtinComponents()),node=(id,owner='root')=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:'source'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}),edge={id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{title:''}},document=createDocument('project',[{id:'root',parent:null}],[node('A'),node('B'),edge]);
/** 先完整投影,再交reuse;scope是视图缓存身份,selection与geometry回调用于制造UI及预览变化。 */
const project=(cache,scope,document,selection=new Set(),geometry=()=>undefined)=>cache.reuse(scope,document,'root',projectCanvas(document,'root',registry,selection,geometry));
// 增加C或ACK等价克隆不替换A/B/edge;变换表可新建,不能据此强制所有元素重挂载。
test('unchanged projection objects survive unrelated additions and equivalent canonical record clones',()=>{
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),added=applyChanges(document,[{kind:'element',id:'C',before:null,after:node('C')}]),second=project(cache,scope,added);assert.equal(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]);assert.equal(first.edges[0],second.edges[0]);assert.notEqual(first.transforms,second.transforms);
const cloned=createDocument('project',[...added.canvases.values()],[...added.elements.values()]),third=project(cache,scope,cloned);assert.equal(third.nodes[0],second.nodes[0]);assert.equal(third.edges[0],second.edges[0]);
});
// 逐步改A正文、选B、预览A、补测量和改edge标题,只刷新实际相关投影,测量值必须保留。
test('content, selection, preview geometry, measurements and edges refresh only affected projections',()=>{
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'edited',beforePresent:true,afterPresent:true}]),second=project(cache,scope,changed);assert.notEqual(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]);
const selected=project(cache,scope,changed,new Set(['B']));assert.equal(second.nodes[0],selected.nodes[0]);assert.notEqual(second.nodes[1],selected.nodes[1]);const preview=project(cache,scope,changed,new Set(['B']),id=>id==='A'?{...node('A').geometry,x:40}:undefined);assert.notEqual(preview.nodes[0],selected.nodes[0]);assert.equal(preview.nodes[1],selected.nodes[1]);
const measured=projectCanvas(changed,'root',registry,new Set(['B']),()=>undefined);measured.nodes[0].measured={width:101,height:100};const result=cache.reuse(scope,changed,'root',measured);assert.equal(result.nodes[0].measured.width,101);
const edgeChanged=applyChanges(changed,[{kind:'field',target:{id:'edge',incarnation:1},field:'data.title',before:'',after:'edge title',beforePresent:true,afterPresent:true}]),last=project(cache,scope,edgeChanged);assert.notEqual(last.edges[0],first.edges[0]);assert.equal(last.edges[0].data.title,'edge title');
});
// incarnation或scope变化不复用;移入child即使场景position相同也必须换对象,删除/clear后不能复活旧缓存。
test('lifecycle, ownership, view scope and deletion never reuse an obsolete projection',()=>{
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),restored=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.notEqual(project(cache,scope,restored).nodes[0],first.nodes[0]);const prior=project(cache,scope,document);assert.notEqual(project(cache,{},document).nodes[0],prior.nodes[0]);
const canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame'},beforeMove=createDocument('project',canvases,[...document.elements.values(),frame]);const before=project(cache,scope,beforeMove).nodes.find(value=>value.id==='A');const moved=createDocument('project',canvases,[...beforeMove.elements.values()].map(record=>record.id==='A'?{...record,ownerCanvasId:'child',geometry:{...record.geometry,x:-24,y:-36}}:record)),after=project(cache,scope,moved).nodes.find(value=>value.id==='A');assert.deepEqual(before.position,after.position);assert.notEqual(before,after);
const current=project(cache,scope,document),empty=createDocument('project',[{id:'root',parent:null}]);project(cache,scope,empty);assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);cache.clear();assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);
});
// 平移不改变节点内部测量可保留,端口/子图scale或作用域变更不可沿用;clear后getNode为空。
test('measurement reuse preserves translated bounds but invalidates changed ports, scale and lifecycle',()=>{
const cache=createProjectionCache(),scope={},canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[]}},original=createDocument('project',canvases,[frame,node('A','child')]);project(cache,scope,original);assert.equal(cache.getNode('F').data.title,'F');
const translated=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'geometry',before:frame.geometry,after:{...frame.geometry,x:200}}]),next=projectCanvas(translated,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,translated,'root',next.nodes.find(value=>value.id==='F')),true);
const ports=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.ports',before:[],after:[{id:'input',direction:'input',childNodeId:'A'}],beforePresent:true,afterPresent:true}]),portProjection=projectCanvas(ports,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,ports,'root',portProjection.nodes.find(value=>value.id==='F')),false);
const scaled=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.presentation',before:null,after:{contentScale:2},beforePresent:false,afterPresent:true}]),scaleProjection=projectCanvas(scaled,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,scaled,'root',scaleProjection.nodes.find(value=>value.id==='A')),false);assert.equal(cache.canRetainMeasurement({},original,'root',projectCanvas(original,'root',registry,new Set(),()=>undefined).nodes[0]),false);cache.clear();assert.equal(cache.getNode('A'),undefined);
});
// 深相等新文档可共享nodes/edges数组,但result.document必须指向新来源;仅A正文变更不换edges/B。
test('unchanged projection arrays retain identity while fresh document/query provenance remains current',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),clone=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>structuredClone(record))),second=project(cache,scope,clone);assert.strictEqual(second.nodes,first.nodes);assert.strictEqual(second.edges,first.edges);assert.strictEqual(second.document,clone);assert.notStrictEqual(second.document,first.document);const changed=applyChanges(clone,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'array edit',beforePresent:true,afterPresent:true}]),third=project(cache,scope,changed);assert.notStrictEqual(third.nodes,second.nodes);assert.strictEqual(third.edges,second.edges);assert.strictEqual(third.nodes[1],second.nodes[1]);});
// 节点顺序反转必须换数组并按新顺序返回;scope替换与clear也不共享旧数组身份。
test('array reuse respects order, scope replacement and explicit clearing',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes.reverse();const reverse=cache.reuse(scope,document,'root',raw);assert.notStrictEqual(reverse.nodes,first.nodes);assert.deepEqual(reverse.nodes.map(node=>node.id),first.nodes.map(node=>node.id).reverse());const replacement=project(cache,{},document);assert.notStrictEqual(replacement.nodes,first.nodes);cache.clear();assert.notStrictEqual(project(cache,scope,document).nodes,first.nodes);});
// 新投影边ID不在文档时整个reuse失败,缓存A仍是旧对象;随后合法输入再采用新正文。
test('an invalid edge cannot partially replace the retained node table',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'prepared only',beforePresent:true,afterPresent:true}]),raw=projectCanvas(changed,'root',registry,new Set(),()=>undefined);raw.edges.push({...raw.edges[0],id:'missing-record'});assert.throws(()=>cache.reuse(scope,changed,'root',raw),/projection_identity_missing/);assert.strictEqual(cache.getNode('A'),first.nodes.find(node=>node.id==='A'));assert.equal(project(cache,scope,changed).nodes.find(node=>node.id==='A').data.text,'prepared only');});
// 新节点包络只多measured时保留相等嵌套props引用,换数组以暴露测量变化,冻结原输入不应被修改。
test('measurement-only updates retain equal data/style/position props without mutating frozen source values',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes=raw.nodes.map(node=>Object.freeze({...node,measured:{width:node.width,height:node.height}}));const result=cache.reuse(scope,document,'root',raw);for(let i=0;i<result.nodes.length;i++){assert.strictEqual(result.nodes[i].data,first.nodes[i].data);assert.strictEqual(result.nodes[i].style,first.nodes[i].style);assert.strictEqual(result.nodes[i].position,first.nodes[i].position);assert.ok(result.nodes[i].measured);}assert.notStrictEqual(result.nodes,first.nodes);});
+26
View File
@@ -1,26 +1,43 @@
/**
* 增量场景投影的缓存与发布回归。renderer解析计数观察局部工作量,并以projectCanvas完整遍历作为结果对照。
* 会话捕获、文档hint连续性、scope/renderer身份及未知组件规则都必须保留,性能优化不能隐藏漏帧或改变端点。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
import {builtinRendererCatalog} from '../../src/lib/canvas-bindings/renderers.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
import {createSceneProjection} from '../../src/lib/canvas-bindings/scene-projection.ts';
/** 默认root文本节点工厂,可换owner/component构造入口、后代和未知类型。 */
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
/** root含F/B,child含A,edge从A连B;两view共享sessions,catalog.resolve累计计数,project允许传旧会话/替换scope。 */
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A','child'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),scene=createSceneProjection(),owner={};sessions.attach('v');sessions.attach('w');let reads=0;const catalog={...builtinRendererCatalog,resolve(record){reads++;return builtinRendererCatalog.resolve(record);}};return{editor,sessions,scene,owner,registry,catalog,get reads(){return reads;},project(snapshot=sessions.getSnapshot(),document=editor.getSnapshot().document,scope=owner,renderers=catalog){return scene.project(scope,document,snapshot.views.get('v'),snapshot,registry,renderers,editor.getQueries(document));}};}
// 不影响当前图形内容的session变化复用整场景;本view选择改变selected并重新投影。
test('viewport, tool, modes and other-view selection do no scene traversal; this-view selection does',()=>{const f=fixture(),first=f.project(),reads=f.reads;assert.ok(reads>0);f.sessions.viewport('v',{x:40,y:20,zoom:2});f.sessions.tool('v','pan');f.sessions.mode('v','B','editing');f.sessions.select('w',['A']);assert.strictEqual(f.project(),first);assert.equal(f.reads,reads);f.sessions.select('v',['B']);const selected=f.project();assert.equal(selected.nodes.find(n=>n.id==='B').selected,true);assert.notStrictEqual(selected,first);assert.ok(f.reads>reads);});
// 捕获w手势x=60后再改120,传旧snapshot仍显示60,live显示120且后代跟随;取消回原位置。
test('captured geometry previews remain coherent even after live update, and are shared across views',()=>{const f=fixture(),first=f.project();f.sessions.begin('w',[{id:'F',incarnation:1}]);assert.strictEqual(f.project(),first);f.sessions.update('w',{id:'F',incarnation:1},{x:60});const captured=f.sessions.getSnapshot();f.sessions.update('w',{id:'F',incarnation:1},{x:120});const old=f.project(captured),live=f.project();assert.equal(old.nodes.find(n=>n.id==='F').position.x,60);assert.equal(live.nodes.find(n=>n.id==='F').position.x,120);assert.equal(live.nodes.find(n=>n.id==='A').position.x-old.nodes.find(n=>n.id==='A').position.x,60);f.sessions.cancel();assert.equal(f.project().nodes.find(n=>n.id==='F').position.x,0);});
// 只有phase变committing不影响显示;path预览进入场景,取消使路径显示复原。
test('connection path preview and cancel invalidate, ownership-only phase does not',()=>{const f=fixture();f.sessions.beginConnection('w',{id:'edge',incarnation:1});const initial=f.project(),snapshot=f.sessions.getSnapshot();assert.strictEqual(f.project({...snapshot,connectionGesture:{...snapshot.connectionGesture,phase:'committing'}}),initial);const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:40},in:{x:-10,y:0},out:{x:10,y:0}}]};assert.equal(f.sessions.updateConnection('w',path).enabled,true);const preview=f.project();assert.deepEqual(preview.edges[0].data.path,path);f.sessions.cancel();assert.notStrictEqual(f.project(),preview);assert.equal(f.project().edges[0].data.path,undefined);});
// 正文修改、scope或renderer替换、clear都不能返回过期场景,最新document必须随结果采用。
test('document and renderer/scope replacement and clearing cannot retain stale scene data',()=>{const f=fixture(),first=f.project();assert.equal(f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'edited'}}).status,'applied');const next=f.project();assert.equal(next.document,f.editor.getSnapshot().document);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'edited');assert.notStrictEqual(next,first);assert.notStrictEqual(f.project(undefined,undefined,{}),next);const previous=f.project(),custom={...f.catalog,resolve(element){const value=f.catalog.resolve(element);return value&&{...value,rendererId:'replacement'};}};assert.equal(f.project(undefined,undefined,undefined,custom).nodes[0].type,'replacement');f.scene.clear();assert.notStrictEqual(f.project(),previous);});
// 即使场景已缓存,也先拒绝错误document/registry的query,不可命中缓存后跳过来源检查。
test('a cached scene does not hide an invalid query provenance',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;f.project();const project=queries=>f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog,queries);assert.throws(()=>project({document:{...document},registry:f.registry}),/stale_query_snapshot/);assert.throws(()=>project({document,registry:{}}),/query_registry_changed/);assert.equal(f.project().document,document);});
// 连续两次B正文发布在下一次project前合并,renderer只解析B一次,A和edges引用保持。
test('continuous field publications rebuild only their dirty node, including several cuts before a frame',()=>{const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const initial=f.project(),reads=f.reads;for(const [id,text]of [['B','one'],['B','two']])assert.equal(f.editor.dispatch({type:'element.data',target:{id,incarnation:1},patch:{text}}).status,'applied');const next=f.project();assert.equal(f.reads-reads,1);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'two');assert.strictEqual(next.nodes.find(n=>n.id==='A'),initial.nodes.find(n=>n.id==='A'));assert.strictEqual(next.edges,initial.edges);stop();});
// 等价新文档不再resolve,nodes/edges/transforms整体引用复用,但结果携新document。
test('equivalent canonical clones retain every projected object and numeric transform',()=>{const f=fixture(),first=f.project(),reads=f.reads;const document=createDocument('p',[...first.document.canvases.values()],[...first.document.elements.values()]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.equal(f.reads,reads);assert.strictEqual(result.nodes,first.nodes);assert.strictEqual(result.edges,first.edges);assert.strictEqual(result.transforms,first.transforms);assert.strictEqual(result.document,document);});
// 先漏掉A的提交hint,再收到B后续hint,场景需检测基线不连续并找回两处正文,不信不完整dirty列表。
test('a missed publication cannot let an incomplete change hint hide a newer document',()=>{const f=fixture(),initial=f.project();f.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'missed'}});const middle=f.editor.getSnapshot().document;let later;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(middle,next.document,commit.changes);later=next.document;});f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'seen'}});const result=f.project();assert.notStrictEqual(result,initial);assert.strictEqual(result.document,later);assert.equal(result.nodes.find(n=>n.id==='A').data.text,'missed');assert.equal(result.nodes.find(n=>n.id==='B').data.text,'seen');stop();});
// 20轮正文/几何/选择/跨view预览/折叠缩放,后续增删改边和undo/redo,每个cut都与完整遍历比较。
test('mixed commands, hierarchy changes, selection and gestures match full traversal at every cut',()=>{
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});
/** 每个cut都按当前文档/会话完整投影,深比较增量结果,避免只验证节点数量或少数字段。 */
const compare=()=>{const document=f.editor.getSnapshot().document,snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),expected=projectCanvas(document,view.canvasId,f.registry,view.selection,id=>snapshot.gesture?.preview.get(id),snapshot.connectionGesture,f.catalog,{queries:f.editor.getQueries(document)});assert.deepEqual(f.project(),expected);};
/** 要求命令applied后立即比较,失败信息包含命令及结果便于定位混合序列中的差异。 */
const dispatch=command=>{const result=f.editor.dispatch(command);assert.equal(result.status,'applied',JSON.stringify({command,result}));compare();};compare();
for(let i=0;i<20;i++){
dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'step'+i}});
@@ -35,8 +52,10 @@ test('mixed commands, hierarchy changes, selection and gestures match full trave
dispatch({type:'element.move',target:{id:'C',incarnation:1},canvasId:'child'});dispatch({type:'element.remove',target:{id:'C',incarnation:1}});assert.equal(f.editor.history.undo().status,'applied');compare();assert.equal(f.editor.history.redo().status,'applied');compare();stop();
});
// 1000节点缓存建好后禁止表遍历,500正文更新只能resolve一个renderer,邻居引用不变。
test('1000-node continuous update performs no document enumeration and resolves only one renderer',()=>{
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let enumerate=true,resolves=0;
/** 首次允许遍历建立缓存,此后关闭枚举门,若局部更新退化成全表遍历则断言失败。 */
const protect=document=>({...document,elements:{size:document.elements.size,get:id=>document.elements.get(id),has:id=>document.elements.has(id),values(){assert.ok(enumerate,'unexpected whole-document values scan');return document.elements.values();},[Symbol.iterator](){assert.ok(enumerate,'unexpected whole-document iterator');return document.elements[Symbol.iterator]();}}});
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},firstDoc=protect(original),first=scene.project(owner,firstDoc,view,snapshot,registry,catalog);
const elements=[...original.elements.values()].map(record=>record.id==='500'?{...record,data:{...record.data,text:'only target'}}:record),after=protect(createDocument('p',[...original.canvases.values()],elements));
@@ -44,12 +63,16 @@ test('1000-node continuous update performs no document enumeration and resolves
const next=scene.project(owner,after,view,snapshot,registry,catalog);assert.equal(resolves,1);assert.equal(next.nodes[500].data.text,'only target');assert.strictEqual(next.nodes[499],first.nodes[499]);assert.equal(next.document,after);
});
// 无hint的ACK替换仍发现B正文,仅重建B;成员顺序反转后与完整投影次序一致。
test('replacement without changes compares records and detects reordered membership',()=>{const f=fixture(),initial=f.project(),doc=initial.document,records=[...doc.elements.values()].map(record=>record.id==='B'?{...record,data:{...record.data,text:'remote'}}:record),next=createDocument('p',[...doc.canvases.values()],records),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),reads=f.reads;f.scene.advance(doc,next,[]);const updated=f.scene.project(f.owner,next,view,snapshot,f.registry,f.catalog);assert.equal(f.reads-reads,1);assert.equal(updated.nodes.find(node=>node.id==='B').data.text,'remote');assert.strictEqual(updated.nodes.find(node=>node.id==='A'),initial.nodes.find(node=>node.id==='A'));const reordered=createDocument('p',[...next.canvases.values()],records.reverse());f.scene.advance(next,reordered,[]);assert.deepEqual(f.scene.project(f.owner,reordered,view,snapshot,f.registry,f.catalog),projectCanvas(reordered,'root',f.registry,view.selection,()=>undefined,null,f.catalog));});
// 选A/B时B解析抛错,先前场景两者selected仍false;解除错误后再完整生成选中场景。
test('a failed local renderer never mutates or publishes a partially updated scene',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;let fail=false;const catalog={...f.catalog,resolve(record){if(fail&&record.id==='B')throw Error('renderer failed');return f.catalog.resolve(record);}},first=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,catalog);f.sessions.select('v',['A','B']);const selected=f.sessions.getSnapshot();fail=true;assert.throws(()=>f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog),/renderer failed/);assert.equal(first.nodes.find(node=>node.id==='A').selected,false);assert.equal(first.nodes.find(node=>node.id==='B').selected,false);fail=false;const result=f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog);assert.equal(result.nodes.find(node=>node.id==='A').selected,true);assert.equal(result.nodes.find(node=>node.id==='B').selected,true);});
// 未来版本F作为unsupported,边源重定向F;B局部改动仍保留完整占位/端口规则及opaque原内容。
test('unknown placeholders and redirected handles keep the complete projection rules during a neighbour edit',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),base=f.editor.getSnapshot().document,records=[...base.elements.values()].map(record=>record.id==='F'?{...record,dataVersion:99,data:{unreadable:{preserve:true}}}:record.id==='edge'?{...record,source:{elementId:'A',portId:'unknown-custom'},target:{elementId:'B'}}:record),document=createDocument('p',[...base.canvases.values()],records),first=f.scene.project(f.owner,document,view,snapshot,f.registry,f.catalog),changed=createDocument('p',[...document.canvases.values()],records.map(record=>record.id==='B'?{...record,data:{...record.data,text:'still editable'}}:record));assert.equal(first.nodes.find(node=>node.id==='F').type,'unsupported');assert.equal(first.edges[0].source,'F');f.scene.advance(document,changed,[{kind:'field',target:{id:'B',incarnation:1},field:'data.text',before:'',after:'still editable'}]);const result=f.scene.project(f.owner,changed,view,snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(changed,'root',f.registry,view.selection,()=>undefined,null,f.catalog));assert.deepEqual(result.document.elements.get('F').data,{unreadable:{preserve:true}});});
// 同批增root/child节点并改B,只resolve三项,嵌套按深度优先顺序插入,既有F/A/edges引用复用。
test('new ordinary cards reuse existing projections and preserve depth-first nested membership order',()=>{
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
assert.equal(f.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-root')});tx.dispatch({type:'element.create',element:node('new-child','child')});tx.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'same batch'}});}).status,'applied');
@@ -58,18 +81,21 @@ test('new ordinary cards reuse existing projections and preserve depth-first nes
const beforeEdit=f.reads;f.editor.dispatch({type:'element.data',target:{id:'new-child',incarnation:1},patch:{text:'later edit'}});const edited=f.project();assert.equal(f.reads-beforeEdit,1);assert.equal(edited.nodes.find(n=>n.id==='new-child').data.text,'later edit');stop();
});
// 折叠child里新增节点只进document,不重建可见数组;展开F时它出现并与完整遍历一致。
test('new hidden cards do not reconstruct visible objects and appear correctly after expanding their entry',()=>{
const f=fixture();f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
f.editor.dispatch({type:'element.create',element:node('hidden-new','child')});const next=f.project();assert.equal(f.reads,reads);assert.strictEqual(next.nodes,first.nodes);assert.strictEqual(next.edges,first.edges);assert.ok(next.document.elements.has('hidden-new'));
f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:false}}});const expanded=f.project();assert.ok(expanded.nodes.some(n=>n.id==='hidden-new'));const snapshot=f.sessions.getSnapshot();assert.deepEqual(expanded,projectCanvas(expanded.document,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));stop();
});
// 提供members索引并禁文档枚举,增一张卡只resolve它,保留既有1000节点及edges引用。
test('inserting one card into 1000 nodes resolves only that renderer and never enumerates document records with a membership index',()=>{
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let resolves=0;
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},first=scene.project(owner,original,view,snapshot,registry,catalog),added=node('added'),raw=createDocument('p',[...original.canvases.values()],[...original.elements.values(),added]),document={...raw,elements:{size:raw.elements.size,get:id=>raw.elements.get(id),has:id=>raw.elements.has(id),values(){throw Error('whole-document enumeration');},[Symbol.iterator](){throw Error('whole-document enumeration');}}},queries={document,registry,members:()=>[...raw.elements.keys()]};
scene.advance(original,document,[{kind:'element',id:'added',before:null,after:added}]);resolves=0;const next=scene.project(owner,document,view,snapshot,registry,catalog,queries);assert.equal(resolves,1);assert.equal(next.nodes.length,1001);assert.equal(next.nodes.at(-1).id,'added');for(let i=0;i<1000;i++)assert.strictEqual(next.nodes[i],first.nodes[i]);assert.strictEqual(next.edges,first.edges);
});
// 分别新增已知/未知卡片及关联边,增量路径须与完整投影保持相同端点/未来端口占位规则。
test('mixed edge insertion and unknown new cards retain full endpoint and placeholder projection rules',()=>{
for(const unknown of [false,true]){const f=fixture(),before=f.editor.getSnapshot().document;f.project();const added=unknown?{...node('added'),componentId:'future',dataVersion:99,data:{opaque:{keep:true}}}:node('added'),edge={id:'new-edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'added',...(unknown?{portId:'future-port'}:{})},data:{},path:null},after=createDocument('p',[...before.canvases.values()],[...before.elements.values(),added,edge]);f.scene.advance(before,after,[{kind:'element',id:added.id,before:null,after:added},{kind:'element',id:edge.id,before:null,after:edge}]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,after,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(after,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));}
});