Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c426e94b58 | |||
| 330d8d6e34 |
@@ -1,14 +1,22 @@
|
||||
/**
|
||||
* 发送批次的稀疏变更压缩测试。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}));
|
||||
@@ -16,8 +24,10 @@ function fixture(changes,base=document,presence){
|
||||
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));
|
||||
@@ -28,12 +38,14 @@ test('wire projection composes long field edits, preserves every original logica
|
||||
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();
|
||||
@@ -42,6 +54,7 @@ test('created records absorb checked updates while create/delete keeps tombstone
|
||||
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);
|
||||
@@ -49,6 +62,7 @@ test('net-zero field changes retain conditional presence checks; omitted true fl
|
||||
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})));
|
||||
@@ -63,12 +77,14 @@ test('unknown or inconsistent baselines and frozen or blocked records retain ord
|
||||
}
|
||||
});
|
||||
|
||||
// 只允许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}}]){
|
||||
@@ -77,6 +93,7 @@ test('invalid original before values and stale incarnations cannot become succes
|
||||
}
|
||||
});
|
||||
|
||||
// 禁止完整表遍历及读取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');}}};
|
||||
@@ -84,6 +101,7 @@ test('projection checks only affected records and leaves a large untouched docum
|
||||
});
|
||||
|
||||
|
||||
// 表情字符使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]);
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* bindings与宿主生命周期的组合测试。涵盖树投影、坐标/曲线转换、连接手势、账户代次与工作区关闭,以及未知类型占位。
|
||||
* 部分用例用内存storage/persistence演示持久化接口,不是磁盘崩溃或真实HTTP集成验证。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDocument, createEditor, createRegistry, builtinComponents, createSessions } from '../../src/lib/canvas-core/index.ts';
|
||||
import { projectCanvas, toLocal, toWorld, transformPath } from '../../src/lib/canvas-bindings/projection.ts';
|
||||
import { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
|
||||
const registry = createRegistry(builtinComponents());
|
||||
/** 合法节点工厂,可指定owner、组件与几何,搭建父入口和子节点。 */
|
||||
const node = (id, ownerCanvasId, componentId = 'text', geometry = {}) => ({ id, incarnation: 1, structure: 'node', ownerCanvasId, componentId, dataVersion: 1, data: { title: id, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0, ...geometry } });
|
||||
/** 父canvas所有的跨层边,从outside连到child内inside;折叠仅改变显示端点。 */
|
||||
const edge = { id: 'edge', incarnation: 1, structure: 'connection', ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, source: { elementId: 'outside' }, target: { elementId: 'inside' }, path: null };
|
||||
// frame位于(100,200),child有默认内边距,inside局部(10,20)投影应到(134,256)。
|
||||
const document = createDocument('project', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [node('frame', 'root', 'frame', { x: 100, y: 200, width: 640, height: 400 }), node('inside', 'child', 'text', { x: 10, y: 20 }), node('outside', 'root', 'text', { x: 900 }), edge]);
|
||||
|
||||
// 根视图显示子节点偏移,折叠把边显示端点转frame但文档仍inside;独立打开child使用原局部坐标。
|
||||
test('canvas projection uses owner-local geometry and stable IDs; collapse changes only display endpoints', () => {
|
||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor); const detach = sessions.attach('view', 'root');
|
||||
let projection = projectCanvas(document, 'root', registry, new Set(['inside']), sessions.effectiveGeometry);
|
||||
@@ -23,6 +31,7 @@ test('canvas projection uses owner-local geometry and stable IDs; collapse chang
|
||||
detach(); sessions.dispose();
|
||||
});
|
||||
|
||||
// 90度/0.5倍变换点和控制向量往返误差小于1e-9,extension未知字段保留。
|
||||
test('rotated/scaled canvas transforms and curve controls round-trip without changing opaque path fields', () => {
|
||||
const transform = { x: 100, y: 200, scale: .5, rotation: 90 }, point = { x: 40, y: 80 };
|
||||
const round = toLocal(transform, toWorld(transform, point)); assert.ok(Math.abs(round.x - point.x) < 1e-9 && Math.abs(round.y - point.y) < 1e-9);
|
||||
@@ -31,6 +40,7 @@ test('rotated/scaled canvas transforms and curve controls round-trip without cha
|
||||
const local = transformPath(transformed, transform, true); assert.ok(Math.abs(local.knots[0].position.x - 40) < 1e-9);
|
||||
});
|
||||
|
||||
// 一视图占连接手势时另一视图节点/连接begin都busy;20次预览不记历史,提交一次,detach只取消后来预览。
|
||||
test('connection drag owns the project gesture, previews without history, commits once and cancels on detach', () => {
|
||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor), target = { id: 'edge', incarnation: 1 };
|
||||
const detach = sessions.attach('one'), detach2 = sessions.attach('two');
|
||||
@@ -47,6 +57,7 @@ test('connection drag owns the project gesture, previews without history, commit
|
||||
detach2(); sessions.dispose();
|
||||
});
|
||||
|
||||
// 同账号workspace复用,切号使旧current失效并增authEpoch;retire在await close前先封身份,host关闭后不可再取workspace。
|
||||
test('host scopes account epochs, preserves workspace owners across navigation, and fences before asynchronous close', async () => {
|
||||
const created = [], closes = [];
|
||||
const host = createRuntimeHost({ registry, workspace(accountId, workspaceId, authEpoch, current) { const value = { accountId, workspaceId, authEpoch, current, async close() { closes.push(current()); } }; created.push(value); return value; } });
|
||||
@@ -57,6 +68,7 @@ test('host scopes account epochs, preserves workspace owners across navigation,
|
||||
await host.close(); assert.throws(() => host.workspace('account-b', 'workspace'), /host_disposed/);
|
||||
});
|
||||
|
||||
// 远端path覆盖使连接预览取消,已提交remote路径保持且不新建本地历史。
|
||||
test('late connection changes cancel preview without restoring stale path or unrelated appearance', () => {
|
||||
const editor = createEditor({ document, registry, allowRawChanges: true }), sessions = createSessions(editor); sessions.attach('view');
|
||||
sessions.beginConnection('view', { id: 'edge', incarnation: 1 }); sessions.updateConnection('view', { type: 'bezier-chain', knots: [] });
|
||||
@@ -66,6 +78,7 @@ test('late connection changes cancel preview without restoring stale path or unr
|
||||
assert.equal(editor.getSnapshot().history.undo.length, 0); sessions.dispose();
|
||||
});
|
||||
|
||||
// 两并发open复用engine,detach不丢文档;存储quota使close失败,leader/资源池保留但新编辑拒绝,修复后重试才释放。
|
||||
test('workspace retains a failed close for retry and stops edits before releasing its persistent owner', async () => {
|
||||
const { createWorkspaceRuntime } = await import('../../src/lib/canvas-runtime/workspace.ts');
|
||||
let image = null, leader, broken = false, closed = false, digestClosed=false, server = document, version = 1;
|
||||
@@ -84,6 +97,7 @@ test('workspace retains a failed close for retry and stops edits before releasin
|
||||
broken = false; await workspace.close(); assert.equal(leader.active, false); assert.equal(closed, true);assert.equal(digestClosed,true);
|
||||
});
|
||||
|
||||
// 未来path允许外观编辑但拒绝路径重写,文档原path保留,兼容投影不解释其控制点。
|
||||
test('future curve formats remain readable and opaque while appearance edits preserve their path', () => {
|
||||
const future = { ...edge, path: { type: 'future-curve', opaque: { retained: true } } };
|
||||
const source = createDocument('project', [...document.canvases.values()], [...document.elements.values()].filter(element => element.id !== 'edge').concat(future));
|
||||
@@ -94,10 +108,12 @@ test('future curve formats remain readable and opaque while appearance edits pre
|
||||
assert.equal(projectCanvas(editor.getSnapshot().document, 'root', registry, new Set(), id => editor.getSnapshot().document.elements.get(id)?.geometry).edges[0].data.path, undefined);
|
||||
});
|
||||
|
||||
// 第一lease取消后启动第二场,旧preview/commit/cancel都不得改变第二场x=200或history,只有新lease能提交。
|
||||
test('a canceled pointer lease cannot update, finish or cancel a newer gesture in the same view', async () => {
|
||||
const { guardEditingLease } = await import('../../src/components/canvas/editing-lease.ts');
|
||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor); sessions.attach('view');
|
||||
const target = { id: 'outside', incarnation: 1 };
|
||||
/** 捕获当前gesture token包装三动作;旧lease必须在下一场手势开始后全部变为无效调用。 */
|
||||
const lease = () => { const token = sessions.getSnapshot().gesture.token; return guardEditingLease(() => sessions.getSnapshot().gesture?.token === token, { preview: patch => sessions.update('view', target, patch), commit: () => sessions.commit('view'), cancel: () => sessions.cancel('pointer_cancel') }); };
|
||||
sessions.begin('view', [target]); const first = lease(); first.preview({ x: 100 }); first.cancel();
|
||||
sessions.begin('view', [target]); const second = lease(); second.preview({ x: 200 });
|
||||
@@ -107,18 +123,22 @@ test('a canceled pointer lease cannot update, finish or cancel a newer gesture i
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
// 构造未知/未来节点与边的异常业务字段,投影只读安全包络并按边补去重Handle;原文档序列化值不变。
|
||||
test('unknown payloads project safe placeholders and retained endpoint handles without interpreting their fields',()=>{
|
||||
const unknown={...node('unknown','root','plugin:future'),dataVersion:7,data:{title:'Unknown',ports:{find:'opaque'},presentation:{unitScale:'future'},assetId:{future:true},__headlessFallback:{handles:'opaque'},opaque:{retained:true}}},future={...node('future','root','text'),dataVersion:99,data:{title:'Future',text:{opaque:true},ports:'opaque'}},connection={...edge,componentId:'plugin:edge',dataVersion:7,source:{elementId:'unknown',portId:'plugin:out'},target:{elementId:'future',portId:'plugin:in'},data:{appearance:{color:{future:true}}},path:{type:'future'}};
|
||||
const source=createDocument('project',[{id:'root',parent:null}],[unknown,future,connection,{...connection,id:'repeat'}]),before=JSON.stringify([...source.elements]),p=projectCanvas(source,'root',registry,new Set(),()=>undefined),u=p.nodes.find(row=>row.id==='unknown'),f=p.nodes.find(row=>row.id==='future');
|
||||
assert.equal(u.type,'unsupported');assert.equal(u.draggable,false);assert.equal(u.data.ports,undefined);assert.equal(u.data.assetId,undefined);assert.equal(u.data.__headlessFallback.reason,'unknown-component');assert.equal(u.data.__headlessFallback.handles.filter(row=>row.id==='plugin:out').length,1);assert.ok(u.data.__headlessFallback.handles.some(row=>row.id==='plugin:out'&&row.side==='right'));assert.equal(f.data.__headlessFallback.reason,'unsupported-version');assert.ok(f.data.__headlessFallback.handles.some(row=>row.id==='plugin:in'&&row.side==='left'));assert.equal(p.edges[0].type,'unsupported');assert.deepEqual(p.edges[0].data,{});assert.equal(JSON.stringify([...source.elements]),before);
|
||||
});
|
||||
|
||||
// 未来frame隐藏内部显示树并重定向上级边,保留占位Handle;真实端点与子树数据不删除。
|
||||
test('future partition placeholders collapse only the display tree and expose redirected upper-canvas edges',()=>{
|
||||
const future=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(row=>row.id==='frame'?{...row,dataVersion:99,data:{...row.data,ports:{opaque:true},presentation:{unitScale:'future'}}}:row)),p=projectCanvas(future,'root',registry,new Set(),()=>undefined),frame=p.nodes.find(row=>row.id==='frame');assert.equal(frame.type,'unsupported');assert.equal(frame.style.pointerEvents,undefined);assert.equal(p.nodes.some(row=>row.id==='inside'),false);assert.equal(p.edges[0].target,'frame');assert.equal(p.edges[0].targetHandle,'left');assert.ok(frame.data.__headlessFallback.handles.some(row=>row.id==='left'));assert.equal(future.elements.get('edge').target.elementId,'inside');
|
||||
});
|
||||
|
||||
// 已知自定义组件无renderer显示占位,但安装了movable仍可拖/dispatch移动,不能一律当未知不可编辑。
|
||||
test('known components without a matching renderer retain registered geometry capability instead of using the text editor',async()=>{
|
||||
const {defineComponent,movable}=await import('../../src/lib/canvas-core/index.ts'),custom=defineComponent({id:'plugin:known',dataVersion:1,structure:'node',validate:()=>true,features:[movable()]}),r=createRegistry([...builtinComponents(),custom]),source=createDocument('project',[{id:'root',parent:null}],[node('custom','root','plugin:known')]);
|
||||
const projected=projectCanvas(source,'root',r,new Set(),()=>undefined).nodes[0];assert.equal(projected.type,'unsupported');assert.equal(projected.draggable,true);assert.equal(projected.data.__headlessFallback.reason,'renderer-unavailable');const editor=createEditor({document:source,registry:r});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'custom',incarnation:1},capability:'geometry.move',patch:{x:20}}).status,'applied');
|
||||
});
|
||||
// __headlessTarget由投影重新生成冻结,伪造data字段不能决定渲染身份,原模型仍原样保留。
|
||||
test('rendered targets carry captured incarnation/owner/placement and overwrite spoofed data without mutating the model',()=>{const projection=projectCanvas(document,'root',registry,new Set(),()=>undefined),inside=projection.nodes.find(node=>node.id==='inside');assert.deepEqual(inside.data.__headlessTarget,{id:'inside',incarnation:1,ownerCanvasId:'child',canvasId:'root'});assert.ok(Object.isFrozen(inside.data.__headlessTarget));const raw={...node('safe','root'),incarnation:2,data:{title:'safe',text:'',__headlessTarget:{id:'spoof',incarnation:99,ownerCanvasId:'elsewhere',canvasId:'elsewhere'}}},source=createDocument('p',[{id:'root',parent:null}],[raw]),before=JSON.stringify([...source.elements]),drawn=projectCanvas(source,'root',registry,new Set(),()=>undefined).nodes[0];assert.deepEqual(drawn.data.__headlessTarget,{id:'safe',incarnation:2,ownerCanvasId:'root',canvasId:'root'});assert.equal(JSON.stringify([...source.elements]),before);});
|
||||
|
||||
@@ -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}};}};
|
||||
|
||||
@@ -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}})};
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);});
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
/**
|
||||
* 查询快照代次的GC保留回归。独立Node进程开启--expose-gc,用WeakRef区分显式持有的旧前缀与已丢弃代次。
|
||||
* 这是受控强制GC场景,不能据此宣称生产内存没有任何泄漏或保证垃圾回收发生时机。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
// 同时测手动增量链、显式持有链和editor链,不只检查WeakRef消失,还检查旧前缀与最新查询都仍正确。
|
||||
test('latest query methods do not keep discarded query/document generations alive; explicitly held prefixes still work',()=>{
|
||||
// 转file URL供子进程ESM动态脚本导入;子进程使用当前process.execPath,避免与主测试互相共享GC状态。
|
||||
const core=new URL('../../src/lib/canvas-core/index.ts',import.meta.url).href,queries=new URL('../../src/lib/canvas-core/queries.ts',import.meta.url).href;
|
||||
// 嵌入脚本的chain(keep)连续推进30代query,held只在keep=true保留初始查询;WeakRef本身不作为强引用。
|
||||
// editorChain用真实editor推进同样30次几何,检查当前查询方法是否闭包保留旧document/query。
|
||||
// 12次setImmediate+gc跨过任务边界后输出布尔存活状态,同时读取显式旧前缀及最新文档的x证明可用性。
|
||||
const script=`
|
||||
import {createDocument,createEditor,applyChanges,createRegistry,builtinComponents} from ${JSON.stringify(core)};
|
||||
import {createDocumentQueries,advanceDocumentQueries} from ${JSON.stringify(queries)};
|
||||
@@ -12,5 +21,6 @@ test('latest query methods do not keep discarded query/document generations aliv
|
||||
function editorChain(){const editor=createEditor({registry,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:''}}]),queryCoverage:()=>({projectComplete:true,topologyComplete:true,completeCanvasIds:['p']})}),weakDocument=new WeakRef(editor.getSnapshot().document),weakQuery=new WeakRef(editor.getQueries());for(let i=1;i<=30;i++)editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{x:i}});return{editor,weakDocument,weakQuery};}
|
||||
const dropped=chain(false),kept=chain(true),owned=editorChain();for(let i=0;i<12;i++){await setImmediate();global.gc();}
|
||||
console.log(JSON.stringify({droppedDocument:!!dropped.weakDocument.deref(),droppedQuery:!!dropped.weakQuery.deref(),keptDocument:!!kept.weakDocument.deref(),keptQuery:!!kept.weakQuery.deref(),oldX:kept.held.document.elements.get('a').geometry.x,newX:dropped.last.document.elements.get('a').geometry.x,members:dropped.last.members('p'),editorDocument:!!owned.weakDocument.deref(),editorQuery:!!owned.weakQuery.deref(),editorX:owned.editor.getSnapshot().document.elements.get('a').geometry.x}));`;
|
||||
// 子进程限时15秒,先要求正常退出,再比JSON;旧丢弃代次应回收,显式held应存活且oldX仍0,最新x=30。
|
||||
const child=spawnSync(process.execPath,['--expose-gc','--input-type=module','-e',script],{encoding:'utf8',timeout:15000});assert.equal(child.status,0,child.stderr||String(child.error));assert.deepEqual(JSON.parse(child.stdout),{droppedDocument:false,droppedQuery:false,keptDocument:true,keptQuery:true,oldX:0,newX:30,members:['a'],editorDocument:false,editorQuery:false,editorX:30});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,54 @@
|
||||
/**
|
||||
* 资源显示订阅的独立生命周期回归。通过手动Promise控制读取完成顺序,检查逐项显示、部分失败重试、替换取消及句柄释放。
|
||||
* blob字符串仅为测试URL,release计数由桩实现;不运行真实资源解码或浏览器URL仓库。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createResourceView } from '../../src/lib/canvas-bindings/resource-view.ts';
|
||||
/** 暴露resolve/reject控制每次资源读取,模拟晚到成功与读取失败。 */
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
/** 等一个事件循环轮次,让资源Promise与通知完成,避免用固定墙钟延迟猜时序。 */
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
/** 收集resolve请求、onChange、onError及release序号;返回view与手动完成入口。 */
|
||||
function fixture() {
|
||||
const requests = [], updates = [], errors = [], released = [];
|
||||
const view = createResourceView({ resolve(resource, signal) { const gate = deferred(); requests.push({ resource, signal, gate }); return gate.promise; }, onChange(sources, failures) { updates.push({ sources, failures }); }, onError: error => errors.push(error) });
|
||||
/** 解析指定请求为URL句柄,release幂等记录一次,便于核对替换/卸载是否漏清理或重复释放。 */
|
||||
const finish = (index, url = `blob:${index}`) => { let disposed = false; requests[index].gate.resolve({ url, release() { if (!disposed) { disposed = true; released.push(index); } } }); };
|
||||
return { view, requests, updates, errors, released, finish, latest: () => updates.at(-1) };
|
||||
}
|
||||
// r1先就绪就显示,不等待r2;新增r3不重读旧资源或释放已就绪句柄,最终dispose各释放一次。
|
||||
test('ready images display individually; adding a node preserves existing handles and reads', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); await tick();
|
||||
assert.deepEqual([...f.latest().sources], [['a', { resourceId: 'r1', url: 'blob:0' }]]);
|
||||
f.view.sync([['a', 'r1'], ['b', 'r2'], ['c', 'r3']]); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r3']); assert.deepEqual(f.released, []);
|
||||
f.finish(2); await tick(); assert.equal(f.latest().sources.size, 2); f.finish(1); await tick(); assert.equal(f.latest().sources.size, 3); f.view.dispose(); assert.deepEqual(f.released.sort(), [0, 1, 2]);
|
||||
});
|
||||
// r2失败时r1仍在,retry只请求r2并保留r1句柄;成功后failures清零。
|
||||
test('a failed reader does not hide other ready images and retry touches only failures', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.requests[1].gate.reject(Error('read_failed')); await tick();
|
||||
assert.equal(f.latest().sources.get('a').url, 'blob:0'); assert.equal(f.latest().failures, 1); assert.equal(f.errors.length, 1);
|
||||
f.view.retry(); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r2']); assert.deepEqual(f.released, []);
|
||||
f.finish(2); await tick(); assert.equal(f.latest().failures, 0); assert.equal(f.latest().sources.size, 2); f.view.dispose();
|
||||
});
|
||||
// 同节点由old切new,旧signal被取消;旧实现忽略abort后才返回,也只释放旧句柄,不能覆盖新URL。
|
||||
test('replacement aborts the old reader and fences a late result that ignored cancellation', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'old']]); await tick(); f.view.sync([['a', 'new']]); await tick(); assert.equal(f.requests[0].signal.aborted, true);
|
||||
f.finish(1, 'blob:new'); await tick(); f.finish(0, 'blob:old'); await tick();
|
||||
assert.deepEqual(f.latest().sources.get('a'), { resourceId: 'new', url: 'blob:new' }); assert.deepEqual(f.released, [0]); f.view.dispose(); assert.deepEqual(f.released, [0, 1]);
|
||||
});
|
||||
// 删a只释放a,b保持;两次dispose只各清一次,已dispose的retry明确拒绝。
|
||||
test('removing one loaded node retains the other; repeated disposal does not release twice', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.finish(1); await tick();
|
||||
f.view.sync([['b', 'r2']]); assert.deepEqual(f.released, [0]); assert.equal(f.latest().sources.get('b').url, 'blob:1');
|
||||
f.view.dispose(); f.view.dispose(); assert.deepEqual(f.released, [0, 1]); assert.equal(f.latest().sources.size, 0); assert.throws(() => f.view.retry(), { code: 'resource_view_disposed' });
|
||||
});
|
||||
// dispose后资源才到,仍释放句柄但不再onChange/onError,避免旧视图收到取消噪音。
|
||||
test('disposal cleans late handles without publishing into a retired view or reporting cancellation as an error', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.view.dispose(); const count = f.updates.length; f.finish(0); await tick();
|
||||
assert.equal(f.updates.length, count); assert.deepEqual(f.released, [0]); assert.deepEqual(f.errors, []);
|
||||
});
|
||||
// 重复节点ID列表拒绝前不改现有订阅;随后同r1同步不新增读取或释放已用资源。
|
||||
test('invalid target lists reject before changing active subscriptions', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.finish(0); await tick();
|
||||
assert.throws(() => f.view.sync([['a', 'r2'], ['a', 'r3']]), { code: 'invalid_resource_view' }); assert.deepEqual(f.released, []);
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* 应用保留投影及固定版ReactFlow编译扩展测试。既检查业务数据不被标记污染,也截取实际依赖源码在VM中执行可见性/选择selector。
|
||||
* VM以简化Hook/几何服务替代浏览器,只验证编译补丁与选择逻辑,不能代表真实DOM、布局和交互端到端验证。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {createRequire} from 'node:module';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
import {createRetainedProjection} from '../../src/components/canvas/retained-projection.ts';
|
||||
// 通过createRequire加载CommonJS loader,直接处理已安装包的两种ESM入口,避免测试只验证手写仿制字符串。
|
||||
const require=createRequire(import.meta.url),transform=require('../../scripts/loaders/xyflow-retained.cjs');
|
||||
|
||||
// 保留仅浅复制被点名的行,嵌套data/position共享且原对象未写标记;无保留时返回原数组,新源对象不复用旧副本。
|
||||
test('retention keeps the complete native graph, preserves business data and releases flags after the owner ends',()=>{
|
||||
const project=createRetainedProjection(),a=Object.freeze({id:'a',dragging:false,data:Object.freeze({text:'draft'}),position:{x:2000,y:0}}),b={id:'b'},edge={id:'edge',source:'a',target:'b'},nodes=[a,b],edges=[edge];
|
||||
const retained=project(nodes,edges,new Set(['a','edge']));
|
||||
@@ -16,15 +22,18 @@ test('retention keeps the complete native graph, preserves business data and rel
|
||||
const restored={...a,data:{text:'new incarnation'}},next=project([restored,b],edges,new Set(['a']));assert.notEqual(next.nodes[0],retained.nodes[0]);assert.equal(next.nodes[0].data,restored.data);
|
||||
});
|
||||
|
||||
// 分别处理index.js/mjs,保留未hidden显式节点与边,关闭裁剪仍返完整图;源码锚点改动必须拒绝转换。
|
||||
test('pinned xyflow compiler extension retains only explicit offscreen owners and keeps native visibility',async()=>{
|
||||
for(const file of ['index.js','index.mjs']){
|
||||
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
|
||||
const start=code.indexOf('const selector$c = '),end=code.indexOf('\n};',start)+3;
|
||||
// 仅执行修改后的节点selector,getNodesInside固定返回原生可见节点,便于验证额外保留集合。
|
||||
const select=runInNewContext(code.slice(start,end)+'; selector$c', {getNodesInside:()=>[{id:'visible'}]});
|
||||
const nodes=[{id:'visible'},{id:'pinned',__endoleapRetain:true},{id:'other'},{id:'hidden',__endoleapRetain:true,hidden:true}],state={nodes,nodeLookup:new Map(nodes.map(node=>[node.id,node])),width:100,height:100,transform:[0,0,1]};
|
||||
assert.deepEqual([...select(true)(state)],['visible','pinned']);assert.deepEqual([...select(false)(state)],nodes.map(node=>node.id));assert.equal(state.nodeLookup.size,4);
|
||||
const edgeStart=code.indexOf('function useVisibleEdgeIds('),edgeEnd=code.indexOf('\n}',edgeStart)+2;
|
||||
const edges=[{id:'near',source:'visible',target:'other'},{id:'editing',source:'pinned',target:'other',__endoleapRetain:true},{id:'offscreen',source:'other',target:'pinned'}];
|
||||
// 用简化useStore/useCallback执行边可见ID函数;原生可见near与显式保留editing都要在,offscreen不应在。
|
||||
const result=runInNewContext(code.slice(edgeStart,edgeEnd)+'; useVisibleEdgeIds(true)',{useCallback:fn=>fn,useStore:fn=>fn({...state,edges}),shallow:()=>{},isEdgeVisible:({sourceNode})=>sourceNode.id==='visible'});
|
||||
assert.deepEqual([...result],['near','editing']);
|
||||
assert.throws(()=>transform(source.replace('const selector$c =','const changedSelector =').replace('getNodesInside(s.nodeLookup,','changedVisibility(s.nodeLookup,')),/Unsupported xyflow/);
|
||||
@@ -32,12 +41,14 @@ test('pinned xyflow compiler extension retains only explicit offscreen owners an
|
||||
});
|
||||
|
||||
|
||||
// 空监听数组不扫描也不运行effect,hook订阅或callback prop存在才扫描;返回userNode原引用而非内部包装。
|
||||
test('native selection scanning stays dormant without listeners and still serves hooks and the callback prop',async()=>{
|
||||
for(const file of ['index.js','index.mjs']){
|
||||
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
|
||||
const start=code.indexOf('const selector$l = '),end=code.indexOf('\n}',code.indexOf('function SelectionListener({',start))+2;
|
||||
let scans=0,effects=0;const callbacks=[],userNode={id:'selected',position:{x:0,y:0},data:{title:'original payload'},selected:true},node={...userNode,internals:{userNode}},edge={id:'edge',source:'selected',target:'other',selected:true};
|
||||
const lookup=value=>({*[Symbol.iterator](){scans++;yield[value.id,value];}}),state={nodeLookup:lookup(node),edgeLookup:lookup(edge),onSelectionChangeHandlers:[]};
|
||||
// 把React Hook替为同步桩并计数扫描/副作用;jsx直接调用组件,仍使用依赖源码中的实际选择逻辑。
|
||||
const render=runInNewContext(code.slice(start,end)+'; SelectionListener',{useStore:selector=>selector(state),useStoreApi:()=>({getState:()=>state}),useEffect:callback=>{effects++;callback();},jsx:(Component,props)=>Component(props),shallow:(a,b)=>JSON.stringify(a)===JSON.stringify(b)});
|
||||
assert.equal(render({}),null);assert.equal(scans,0);assert.equal(effects,0);
|
||||
state.onSelectionChangeHandlers=[value=>callbacks.push(value)];render({});assert.equal(scans,2);assert.deepEqual([...callbacks[0].nodes].map(node=>node.id),['selected']);assert.deepEqual([...callbacks[0].edges].map(edge=>edge.id),['edge']);assert.equal(callbacks[0].nodes[0],userNode);assert.equal(callbacks[0].edges[0],edge);
|
||||
|
||||
@@ -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));}
|
||||
});
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
/**
|
||||
* 原生矩形可见判断的边界回归。用于决定是否还需要额外保留,不代替paint范围、焦点/手势保留或完整场景裁剪。
|
||||
*/
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {nativeRectVisible} from '../../src/lib/canvas-bindings/visibility.ts';
|
||||
// 检查内含、部分重叠、恰好右边界及完全外部;零宽几何保守返回可见,不能把未知尺寸直接裁掉。
|
||||
test('additional retention is unnecessary for native-visible nodes and required for offscreen targets',()=>{const viewport={x:0,y:0,width:100,height:100};assert.equal(nativeRectVisible({x:10,y:10,width:20,height:20},viewport),true);assert.equal(nativeRectVisible({x:99,y:99,width:20,height:20},viewport),true);assert.equal(nativeRectVisible({x:100,y:0,width:10,height:10},viewport),false);assert.equal(nativeRectVisible({x:-20,y:-20,width:10,height:10},viewport),false);assert.equal(nativeRectVisible({x:1000,y:1000,width:0,height:10},viewport),true);});
|
||||
|
||||
Reference in New Issue
Block a user