diff --git a/tests/headless/creation-draft.test.mjs b/tests/headless/creation-draft.test.mjs index 8758b36..5ea2d4a 100644 --- a/tests/headless/creation-draft.test.mjs +++ b/tests/headless/creation-draft.test.mjs @@ -1,22 +1,32 @@ +/** + * 批量创建草稿的不可变前缀与工作量测试。以1000条底表检查逐条stage不重复复制整表,旧前缀不随追加变化。 + * 策略、schema和runtime拒绝仍必须整次事务不发布;计数/替换迭代器只测本路径,不声称所有创建成本固定。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createEditor,createRegistry,builtinComponents,isFactoryDocument} from '../../src/lib/canvas-core/index.ts'; import {stageCreation,materializeCreations} from '../../src/lib/canvas-core/creation-draft.ts'; +/** 构造1000个既有节点的文档;每个用例新建,registry可共享,只读前缀用于后续对照。 */ const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:''}}),document=()=>createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node('old-'+i))),registry=createRegistry(builtinComponents()); +// 连续追加130条跨过窗口边界,保存第1/64/65个前缀;检查顺序、只读集合、forEach this与materialize结果及重复ID拒绝。 test('creation drafts avoid reenumerating the base per item, preserve held prefixes and fold bounded append windows in order',()=>{ const base=document();let reads=0;const elements={...base.elements,[Symbol.iterator](){reads++;return base.elements[Symbol.iterator]();}},source={...base,elements};let draft=source;const held=[]; for(let i=0;i<130;i++){draft=stageCreation(draft,{kind:'element',id:'new-'+i,before:null,after:node('new-'+i)});if([0,63,64].includes(i))held.push(draft);if(i===31)assert.equal(reads,0);} assert.ok(reads<=1);assert.deepEqual(held.map(d=>d.elements.size),[1001,1064,1065]);assert.equal(held[0].elements.has('new-1'),false);assert.equal(draft.elements.get('old-10').id,'old-10');assert.equal(draft.elements.get('new-129').id,'new-129');assert.equal(draft.elements.set,undefined);const ids=[...draft.elements.keys()];assert.deepEqual(ids.slice(-130),Array.from({length:130},(_,i)=>'new-'+i));assert.equal([...draft.elements.values()].length,1130);const visits=[];draft.elements.forEach(function(value,key,map){assert.equal(this.tag,true);assert.strictEqual(map,draft.elements);visits.push(key);},{tag:true});assert.deepEqual(visits,ids);const materialized=materializeCreations(draft);assert.ok(isFactoryDocument(materialized));assert.equal(materialized.elements.size,1130);assert.equal(base.elements.size,1000);assert.throws(()=>stageCreation(draft,{kind:'element',id:'new-0',before:null,after:node('new-0')}),/duplicate_identity/); }); +// 策略按命令顺序看到1000/1001/1002前缀,持有前缀也固定;创建与中间编辑一起只发布一次、产生一个操作/历史单元。 test('ordered command policies see stable prior prefixes; one owned publication has one history unit and round-trips undo/redo',()=>{ const held=[],seen=[];let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy(command,doc){held.push(doc);seen.push([command.type,doc.elements.size,doc.canvases.size]);return{enabled:true};}});let publications=0;editor.subscribe(()=>publications++);const before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});const prefix=tx.getDocument();tx.dispatch({type:'element.create',element:node('b')});assert.equal(prefix.elements.has('b'),false);tx.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'edited'}});tx.dispatch({type:'element.create',element:node('c')});});assert.equal(result.status,'applied');assert.deepEqual(seen.map(row=>row[1]),[1000,1001,1002,1002]);assert.deepEqual(held.map(d=>d.elements.size),[1000,1001,1002,1002]);assert.equal(publications,1);const after=editor.getSnapshot();assert.ok(isFactoryDocument(after.document));assert.equal(after.history.undo.length,1);assert.equal(after.operations.size,1);assert.equal(after.document.elements.get('a').data.title,'edited');assert.equal(before.document.elements.size,1000);assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.size,1000);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'edited'); }); +// 四种失败分别发生在创建流水线不同位置,结果均rejected且根、history、operations保持原状。 test('policy, schema, duplicate and runtime failures never publish staged creations',()=>{ for(const failure of ['policy','schema','duplicate','runtime']){let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy:(command)=>failure==='policy'&&command.type==='element.create'&&command.element.id==='b'?{enabled:false,code:'denied',targets:['b']}:{enabled:true},reduceRuntime(){if(failure==='runtime')throw Error('runtime_failed');}}),before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});tx.dispatch({type:'element.create',element:failure==='duplicate'?node('a'):failure==='schema'?{...node('b'),geometry:{...node('b').geometry,width:-1}}:node('b')});});assert.equal(result.status,'rejected',failure);assert.strictEqual(editor.getSnapshot(),before);assert.equal(editor.getSnapshot().history.undo.length,0);assert.equal(editor.getSnapshot().operations.size,0);} }); +// 先创建入口,再canvas,再内部节点;旧前缀不见后来结构,undo移除整组,redo恢复入口关系。 test('canvas and element creation drafts compose a tree while held canvas prefixes and base snapshots remain fixed',()=>{ const editor=createEditor({document:document(),registry});let first,second;const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}});first=tx.getDocument();tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'root',entryElementId:'entry'}}});second=tx.getDocument();tx.dispatch({type:'element.create',element:{...node('nested'),ownerCanvasId:'child'}});assert.equal(first.canvases.has('child'),false);assert.equal(second.elements.has('nested'),false);assert.equal(tx.getDocument().canvases.get('child').parent.entryElementId,'entry');});assert.equal(result.status,'applied');assert.ok(isFactoryDocument(editor.getSnapshot().document));assert.equal(editor.getSnapshot().document.elements.get('nested').ownerCanvasId,'child');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.has('child'),false);assert.equal(editor.getSnapshot().document.elements.has('entry'),false);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.get('child').parent.entryElementId,'entry'); }); +// 事务内暂替Map迭代器,禁止大表遍历;32次创建仍获准且单一历史,finally恢复全局原型方法。 test('editor creation staging does not enumerate an existing thousand-element table for each command',()=>{ const editor=createEditor({document:document(),registry});const iterator=Map.prototype[Symbol.iterator];let admissions=0;const result=editor.transaction(tx=>{try{Map.prototype[Symbol.iterator]=function(){if(this.size>=1000)throw Error('full table copied during creation staging');return iterator.call(this);};for(let i=0;i<32;i++){assert.equal(tx.dispatch({type:'element.create',element:node('batch-'+i)}).enabled,true);admissions++;}}finally{Map.prototype[Symbol.iterator]=iterator;}});assert.equal(result.status,'applied');assert.equal(admissions,32);assert.equal(editor.getSnapshot().document.elements.size,1032);assert.equal(editor.getSnapshot().history.undo.length,1); }); diff --git a/tests/headless/creation-size-plan.test.mjs b/tests/headless/creation-size-plan.test.mjs index d6a3163..7e1cc42 100644 --- a/tests/headless/creation-size-plan.test.mjs +++ b/tests/headless/creation-size-plan.test.mjs @@ -1,3 +1,7 @@ +/** + * 异步获知图片默认尺寸后修正创建历史的规划测试。plan只生成候选document/history,不能修改已发intent或凭空添加撤销单元。 + * 区分用户显式resize、单位换算、移动和redo分支,检查整个undo/redo路径仍可恢复,不以当前尺寸碰巧相同判定未改过。 + */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createDocument, createEditor, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts'; @@ -6,7 +10,9 @@ import { applyChanges, invert } from '../../src/lib/canvas-core/document.ts'; import { describeHistoryTransition, applyHistoryTransition } from '../../src/lib/canvas-core/history-transition.ts'; const registry = createRegistry(builtinComponents()); +/** 固定image第一生命周期,删除恢复后这个异步任务目标应失效。 */ const target = { id: 'image', incarnation: 1 }; +/** 先创建260×200图片并记录创建history entry,propose统一尝试改默认值为260×130,允许覆盖editor依赖。 */ function setup(options = {}) { let id = 0; const editor = createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], []), registry, createId: () => `id-${++id}`, ...options }); @@ -15,6 +21,7 @@ function setup(options = {}) { const entry = editor.getSnapshot().history.undo[0]; return { editor, entry, propose() { const { document, history } = editor.getSnapshot(); return planCreationSize(document, history, registry, entry, target, { width: 260, height: 130 }); } }; } +/** 从plan当前状态逆序撤销到空,再按undo顺序重做,对照最终图片;只适用于不含额外初始元素的调用。 */ function traverse(plan) { let document = plan.document; for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes)); @@ -23,6 +30,7 @@ function traverse(plan) { assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image')); return document; } +// 创建后移位/旋转/改名,默认尺寸修正保留这些编辑与栈顺序;原editor根和序列化intent均不变。 test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => { const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 80, rotation: 25 } }); @@ -37,6 +45,7 @@ test('default size enriches the creation unit across move, rotation and title ed assert.deepEqual([...before.operations.values()].map(value => JSON.stringify(value)), intents); traverse(plan); }); +// 位置变化撤销后仍在redo,规划保留redo且当前x不提前跳到未来值;实际重做再得到90与新默认高度。 test('an unrelated redo stack is retained and rebased without becoming the working document', () => { const { editor, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 90 } }); editor.history.undo(); @@ -48,6 +57,7 @@ test('an unrelated redo stack is retained and rebased without becoming the worki assert.equal(document.elements.get('image').geometry.x, 90); assert.equal(document.elements.get('image').geometry.height, 130); }); +// resize已撤销时允许修默认基线;redo仍用用户300高度,逆向回到修正后的130。 test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => { const { editor, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { width: 400, height: 300 } }); editor.history.undo(); @@ -56,6 +66,7 @@ test('a future explicit resize keeps the chosen dimensions, with a corrected und assert.equal(resized.elements.get('image').geometry.height, 300); assert.equal(applyChanges(resized, invert(resize.changes)).elements.get('image').geometry.height, 130); }); +// 用户先改高400再改回200,仍属于显式调整,不得以最终值相同覆盖其意图。 test('applied resize rejects even after a later resize returns to the initial dimensions', () => { const { editor, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { height: 400 } }); @@ -63,15 +74,18 @@ test('applied resize rejects even after a later resize returns to the initial di const before = editor.getSnapshot(); assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before); }); +// 创建已撤销,原图片不存在,异步默认尺寸任务以target_changed拒绝。 test('an undone creation cannot be enriched', () => { const { editor, propose } = setup(); editor.history.undo(); assert.throws(propose, { code: 'creation_size_target_changed' }); }); +// 删除再恢复使生命周期变化,即使ID相同,旧创建尺寸任务也不能修改新对象。 test('a restored element has a new incarnation and rejects the old asynchronous task', () => { const { editor, propose } = setup(); editor.dispatch({ type: 'element.remove', target }); editor.history.undo(); // Undo of removal creates a new incarnation: an old asynchronous task is stale. assert.throws(propose, { code: 'creation_size_target_changed' }); }); +// 用已有historyTransition格式描述plan,JSON往返后正反回放仍精确相等,entries不暴露set。 test('the complete history proposal is reversible using the existing transition proof format', () => { const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } }); const before = editor.getSnapshot(), plan = propose(); @@ -82,6 +96,7 @@ test('the complete history proposal is reversible using the existing transition assert.deepEqual([...restored.entries], [...before.history.entries]); assert.equal(plan.history.entries.set, undefined); }); +// 允许raw changes构造单位×2与几何同比变化,修正尺寸按单位换算而非误判成用户resize,保留最新位置并遍历历史。 test('a logical unit conversion preserves size, latest position and the full undo path', () => { const { editor, propose } = setup({ allowRawChanges: true }); const before = editor.getSnapshot().document.elements.get('image'); @@ -96,6 +111,7 @@ test('a logical unit conversion preserves size, latest position and the full und assert.equal(plan.document.elements.get('image').geometry.x, 45); traverse(plan); }); +// 图片移入contentScale=2子图后仍保留owner/位置,默认高度换到局部65;undo只移图片不删初始frame,再重做一致。 test('a real move into a scaled child canvas is retained through enrichment and undo', () => { const frame = { id: 'frame', incarnation: 1, ownerCanvasId: 'root', componentId: 'frame', dataVersion: 1, structure: 'node', data: { title: 'Frame', text: '', presentation: { contentScale: 2 } }, geometry: { x: 0, y: 0, width: 600, height: 400, rotation: 0 } }; const { editor, propose } = setup({ document: createDocument('p', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [frame]) }); @@ -110,11 +126,13 @@ test('a real move into a scaled child canvas is retained through enrichment and for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes); assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image')); }); +// historyLimit=1淘汰创建条目后不能修补,也不能为了异步结果补造历史单元。 test('evicted creation history is rejected without synthesizing a new undo entry', () => { const { editor, propose } = setup({ historyLimit: 1 }); editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } }); assert.throws(propose, { code: 'creation_size_target_changed' }); }); +// resize与移动都撤销后,按redo正确次序重放保留用户300高和x=75,再反向回到plan状态。 test('a future resize followed by movement retains the user size through both redo entries', () => { const { editor, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } }); @@ -127,6 +145,7 @@ test('a future resize followed by movement retains the user size through both re for (const id of plan.history.redo) document = applyChanges(document, invert(plan.history.entries.get(id).changes)); assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image')); }); +// Infinity尺寸拒绝;远端内容已改变使历史基准过期,同样拒绝且不改editor根。 test('invalid sizes and stale document baselines are rejected without mutation', () => { const { editor, entry } = setup(); const root = editor.getSnapshot(); assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: Infinity, height: 1 }), { code: 'invalid_creation_size' }); @@ -135,10 +154,12 @@ test('invalid sizes and stale document baselines are rejected without mutation', assert.throws(() => planCreationSize(stale.document, stale.history, registry, entry, target, { width: 260, height: 130 }), { code: 'element_conflict' }); assert.strictEqual(editor.getSnapshot(), stale); }); +// 0.5高不满足组件尺寸约束,规划必须拒绝,不能因默认值修正绕过普通resize限制。 test('component constraints cannot be bypassed by a default size proposal', () => { const { editor, entry } = setup(); const root = editor.getSnapshot(); assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: 260, height: 0.5 }), { code: 'creation_size_constraint' }); }); +// 默认宽改520、高130,redo只改高300时应保留新默认宽,避免拿旧整份geometry覆盖未编辑轴。 test('a redo edit to one size axis preserves the corrected default on the other axis', () => { const { editor, entry } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } }); diff --git a/tests/headless/geometry-cache.test.mjs b/tests/headless/geometry-cache.test.mjs index df603ea..9e3ba8e 100644 --- a/tests/headless/geometry-cache.test.mjs +++ b/tests/headless/geometry-cache.test.mjs @@ -1,11 +1,18 @@ +/** + * bindings几何投影缓存测试。用缩放子图与跨层曲线比较缓存投影和直接投影,按计算次数验证局部失效。 + * 统计用于确定性工作量检查,不是浏览器帧率基准;缓存不能把旧查询、旧生命周期或可变预览当成不变输入。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts'; import {createGeometryProjectionCache} from '../../src/lib/canvas-bindings/geometry-cache.ts'; import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts'; +// 最小节点/连线工厂保留owner和可选path,以同一registry构造对照。 const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner,path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:source},target:{elementId:target},path}); +// P含缩放入口F,S含A/B;in边归P带路径,local边归S,祖先变化应影响不同几何缓存项。 const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',presentation:{contentScale:2,unitScale:.75}}},node('A','S'),node('B','S'),edge('in','C','A','P',{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:500,y:200},in:{x:10,y:0},out:{x:20,y:0}}]}),edge('local','A','B','S')]); +// 每次draw与直接投影深比较;标题、选择与元数据不增计算,A局部变化只重算自身,F移动再影响后代及路径。 test('indexed renderer projection preserves legacy output and avoids unrelated geometry work on title, selection and ACK',()=>{ let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`,runtime:0}),cache=createGeometryProjectionCache(),owner={},selected=new Set(),draw=()=>{const doc=editor.getSnapshot().document,actual=projectCanvas(doc,'P',registry,selected,()=>undefined,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});assert.deepEqual(actual,projectCanvas(doc,'P',registry,selected,()=>undefined));return actual;}; draw();const before=cache.stats();assert.deepEqual({nodes:before.nodesComputed,children:before.childrenComputed,edges:before.edgesComputed},{nodes:4,children:1,edges:2});editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'only title'}});draw();assert.deepEqual(cache.stats(),before);selected.add('B');draw();assert.deepEqual(cache.stats(),before);editor.updateRuntime(()=>1);draw();assert.deepEqual(cache.stats(),before); @@ -13,11 +20,13 @@ test('indexed renderer projection preserves legacy output and avoids unrelated g editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+4);assert.equal(cache.stats().childrenComputed,before.childrenComputed+1);assert.equal(cache.stats().edgesComputed,before.edgesComputed+1); }); +// 折叠清不可见缓存,旧query拒绝;预览进入/退出各重算,删除恢复incarnation=2后重新出现,不能沿用旧记录身份。 test('geometry cache prunes collapsed/deleted data, respects previews/lifecycles and rejects stale query snapshots',()=>{ let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`}),cache=createGeometryProjectionCache(),owner={},draw=(geometry=()=>undefined)=>{const doc=editor.getSnapshot().document;return projectCanvas(doc,'P',registry,new Set(),geometry,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});};draw();const old=editor.getQueries();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{contentScale:2,unitScale:.75,collapsed:true}}});draw();assert.equal(cache.stats().nodes,2);assert.equal(cache.stats().children,0);assert.equal(cache.stats().edges,1);assert.throws(()=>projectCanvas(editor.getSnapshot().document,'P',registry,new Set(),()=>undefined,null,undefined,{queries:old}),/stale_query_snapshot/); editor.history.undo();draw();assert.equal(cache.stats().nodes,4);const before=cache.stats().nodesComputed,preview={...editor.getSnapshot().document.elements.get('A').geometry,x:900};draw(id=>id==='A'?preview:undefined);assert.equal(cache.stats().nodesComputed,before+1);draw();assert.equal(cache.stats().nodesComputed,before+2);editor.dispatch({type:'element.remove',target:{id:'A',incarnation:1}});draw();assert.equal(cache.stats().nodes,3);editor.history.undo();draw();assert.equal(cache.stats().nodes,4);assert.equal(editor.getSnapshot().document.elements.get('A').incarnation,2); }); +// 原path后来修改不改变first结果,新输入按scale=2/x偏移1得到61;输出深冻结,换owner清旧边缓存。 test('path cache snapshots mutable preview inputs and geometry cache cannot be corrupted through returned values',()=>{ const cache=createGeometryProjectionCache(),owner={},t={x:1,y:2,scale:2,rotation:0},path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:0,y:0},out:{x:0,y:0}}]};cache.begin(owner,'project','P');const first=cache.edge('e',1,path,t);path.knots[0].position.x=30;const second=cache.edge('e',1,path,t);assert.notEqual(first,second);assert.equal(second.knots[0].position.x,61);assert.equal(first.knots[0].position.x,21);assert.ok(Object.isFrozen(second.knots[0].position));cache.end();cache.begin({},'project','P');cache.node('A',1,node('A').geometry,t);cache.end();assert.equal(cache.stats().edges,0); }); diff --git a/tests/headless/gesture-placement.test.mjs b/tests/headless/gesture-placement.test.mjs index e2fabf4..039352c 100644 --- a/tests/headless/gesture-placement.test.mjs +++ b/tests/headless/gesture-placement.test.mjs @@ -1,20 +1,37 @@ +/** + * 手势坐标上下文与目标可见性的回归。三层canvas加独立子视图,验证祖先位置/缩放/身份变化何时取消预览。 + * 同时检查owner链读取缓存与未知祖先数据隔离;这里没有DOM指针捕获,测试的是core会话及placement读取器。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createGesturePlacementReader} from '../../src/lib/canvas-core/gesture-placement.ts'; import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts'; +/** 合法100×100节点工厂,可指定owner和frame组件,用来搭建坐标链。 */ 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}}); +/** 子canvas局部空间中的单个曲线点,连接手势用它验证owner祖先变化会失效路径预览。 */ const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:-10,y:0},out:{x:10,y:0}}]}; +/** root→child→grand由F/G入口连接,A/D及其边归child,B归grand;同时挂root与child-view比较坐标边界。 */ function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}},{id:'grand',parent:{canvasId:'child',entryElementId:'G'}}],[node('F','root','frame'),node('G','child','frame'),node('A','child'),node('B','grand'),node('D','child'),node('C'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'child',source:{elementId:'A'},target:{elementId:'D'},path,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor);sessions.attach('v','root');sessions.attach('child-view','child');return{editor,sessions};} +// 根视图预览B时移动祖先F,手势先取消;迟到commit拒绝且B的持久几何仍为0。 test('moving an ancestor cancels a descendant preview before a late commit can use old coordinates',()=>{const{editor,sessions}=fixture();assert.equal(sessions.begin('v',[{id:'B',incarnation:1}]).enabled,true);sessions.update('v',{id:'B',incarnation:1},{x:50});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);const before=editor.getSnapshot();assert.equal(sessions.commit('v').status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(before.document.elements.get('B').geometry.x,0);}); +// 折叠F隐藏A,独立child-view也不能编辑根节点C;分别断言target_hidden与target_outside_view。 test('collapsed and outside-view targets cannot acquire a gesture',()=>{const{editor,sessions}=fixture();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'target_hidden');assert.equal(sessions.begin('child-view',[{id:'C',incarnation:1}]).code,'target_outside_view');}); +// 对child局部路径预览后移动F,连接手势必须取消,不能把旧路径坐标提交到新上下文。 test('moving a path owner ancestor cancels its connection preview',()=>{const{editor,sessions}=fixture();assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).enabled,true);sessions.updateConnection('v',{...path,knots:[]});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().connectionGesture,null);}); +// 分别改contentScale/unitScale/collapsed要求坐标失效;仅标签/标题或无关C几何改变则保留同一gesture对象。 test('scale and collapse cancel active descendant gestures but presentation labels do not',()=>{for(const presentation of [{contentScale:2},{unitScale:2},{collapsed:true}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);sessions.update('v',{id:'B',incarnation:1},{x:50});editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation}});assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'coordinate_context_changed');}const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'another title',presentation:{labelMode:'hidden',contentScale:1,unitScale:1}}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);editor.dispatch({type:'element.geometry',target:{id:'C',incarnation:1},patch:{x:80}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);}); +// child-view以child为坐标根,上方F移动不影响本地B预览,commit仍可合法写x=50。 test('ancestor changes above an independently opened view do not alter its local coordinate space',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);sessions.update('child-view',{id:'B',incarnation:1},{x:50});const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);assert.equal(sessions.commit('child-view').status,'applied');assert.equal(editor.getSnapshot().document.elements.get('B').geometry.x,50);}); +// 祖先生命周期、未来版本或同几何换owner都改变上下文;即使像素位置看似一致,也必须取消旧手势。 test('ancestor restore, future version and equal-geometry reparent invalidate the old context',()=>{for(const patch of [{incarnation:2},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='F'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}const{editor,sessions}=fixture();editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('H','root','frame'),geometry:{...node('H').geometry,x:-24,y:-36}}});tx.dispatch({type:'canvas.create',canvas:{id:'sibling',parent:{canvasId:'root',entryElementId:'H'}}});});sessions.begin('v',[{id:'B',incarnation:1}]);assert.equal(editor.dispatch({type:'element.move',target:{id:'F',incarnation:1},canvasId:'sibling',geometry:node('F').geometry}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}); +// 目标A由text变plain-text后旧预览取消,理由target_changed,不能只凭相同ID保留租约。 test('direct target component changes cannot retain an old geometry lease',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,componentId:'plain-text'}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'target_changed');}); +// 删除F使child-view回退root,订阅只收到一次同时包含视图变更与手势取消的快照。 test('deleting the viewed canvas publishes its root fallback and cancellation together',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('child-view').canvasId,'root');assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'canvas_changed');}); +// 用禁止遍历的表包络读取grand链,1000次复用同一结果且仅两次元素get,验证按链而非全图扫描。 test('placement readers share a checked owner chain and never enumerate the project',()=>{const{editor}=fixture(),document=editor.getSnapshot().document;let reads=0;const protectedDocument={...document,canvases:{has:id=>document.canvases.has(id),get:id=>document.canvases.get(id),[Symbol.iterator](){throw Error('project enumeration');}},elements:{get(id){reads++;return document.elements.get(id);},[Symbol.iterator](){throw Error('project enumeration');}}},read=createGesturePlacementReader(protectedDocument,'root',editor.registry),first=read('grand');assert.equal(first.length,2);for(let i=0;i<1000;i++)assert.strictEqual(read('grand'),first);assert.equal(reads,2);}); +// 未来版本F的data getter一读就抛错;placement应以target_hidden拒绝且getter读取数为0。 test('future ancestor data is never interpreted to decide visibility',()=>{const{editor}=fixture(),document=editor.getSnapshot().document,frame=document.elements.get('F');let inspected=0;const opaque={...frame,dataVersion:99,get data(){inspected++;throw Error('opaque payload read');}},elements=new Map(document.elements);elements.set('F',opaque);const read=createGesturePlacementReader({...document,elements},'root',editor.registry);assert.throws(()=>read('grand'),/target_hidden/);assert.equal(inspected,0);}); diff --git a/tests/headless/queries.test.mjs b/tests/headless/queries.test.mjs index 3309deb..5f036e5 100644 --- a/tests/headless/queries.test.mjs +++ b/tests/headless/queries.test.mjs @@ -1,23 +1,34 @@ +/** + * 文档查询索引的语义与增量更新回归。用父子canvas、跨层连线及声明引用构造关系图,比较增量维护与完整重建结果。 + * 空间测试使用确定性几何与计数,不依赖浏览器;complete/opaque覆盖信息必须随同文档发布,不能凭“未查到”推断对象不存在。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,applyChanges} from '../../src/lib/canvas-core/index.ts'; import {removalChanges} from '../../src/lib/canvas-core/structure.ts'; +// 节点/边工厂允许指定owner及几何;跨层in边归父canvas,local边归子canvas。 const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text',geometry={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:10,y:20,width:100,height:60,rotation:0,...geometry}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,structure:'connection',ownerCanvasId:owner,componentId:'connection',dataVersion:1,source:{elementId:source},target:{elementId:target},data:{},path:null}); +// P中F作为S入口,F的ports引用A;用来同时覆盖owner、端点与数组反向引用三类索引。 const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',ports:[{id:'p',nodeId:'A'}]}},node('A','S'),node('B','S'),edge('in','C','A'),edge('local','A','B','S')]); +/** Map规范为排序数组,只消除集合遍历次序差异,不改变实际成员。 */ const sets=map=>[...map].map(([id,ids])=>[id,[...ids].sort()]).sort(([a],[b])=>a.localeCompare(b)); +/** 全量比较关系表、入口映射、opaque集合、每canvas成员及空间entries/cells/oversized;不只比查询条数。 */ function parity(actual,expected){for(const key of ['ownerMembers','canvasChildren','incidentEdges','elementReferrers','canvasReferrers'])assert.deepEqual(sets(actual.relations[key]),sets(expected.relations[key]),key);assert.deepEqual([...actual.relations.childCanvasByEntry].sort(),[...expected.relations.childCanvasByEntry].sort());assert.deepEqual([...actual.relations.opaqueReferences].sort(),[...expected.relations.opaqueReferences].sort());for(const id of actual.document.canvases.keys()){assert.deepEqual(actual.members(id),expected.members(id));assert.deepEqual(actual.childCanvases(id),expected.childCanvases(id));assert.deepEqual([...actual.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)),[...expected.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)));assert.deepEqual(sets(actual.spatial.get(id)?.cells??new Map()),sets(expected.spatial.get(id)?.cells??new Map()));assert.deepEqual([...(actual.spatial.get(id)?.oversized??[])].sort(),[...(expected.spatial.get(id)?.oversized??[])].sort());}} +// 检查父子成员、入边和port引用,集合无写方法;未知组件标记引用不完整,缺canvas与更换registry明确拒绝。 test('query snapshots index ownership, incoming edges and declared array references without mutable maps',()=>{ const q=createDocumentQueries(document,registry);assert.deepEqual(q.members('P'),['C','F','in']);assert.deepEqual(q.members('S'),['A','B','local']);assert.deepEqual(q.incident('A'),['in','local']);assert.equal(q.relations.childCanvasByEntry.get('F'),'S');assert.deepEqual([...q.relations.elementReferrers.get('A')],['F']);assert.equal(q.relations.referenceComplete,true);assert.equal(q.relations.ownerMembers.set,undefined);assert.equal(q.relations.ownerMembers.get('S').add,undefined);assert.equal(q.spatial.get('S').cells.set,undefined);assert.ok(Object.isFrozen(q.spatial.get('S').entries.get('A')));assert.deepEqual(removalChanges(document,{id:'F',incarnation:1},q),removalChanges(document,{id:'F',incarnation:1})); const opaque=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('unknown','P','future')]),partial=createDocumentQueries(opaque,registry);assert.equal(partial.relations.referenceComplete,false);assert.deepEqual([...partial.relations.opaqueReferences],['unknown']);assert.throws(()=>q.members('missing'),/canvas_not_loaded/);assert.throws(()=>advanceDocumentQueries(q,document,createRegistry(builtinComponents()),0),/query_registry_changed/); }); +// 标题/运行元数据变化复用空间表;runtime失败时根和索引一起不发布,合法几何只更新对应owner表,undo再与重建比较。 test('title and metadata commits reuse spatial tables; listeners observe matching document/index revisions and rejected commits keep both',()=>{ let fail=false,next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:{tag:0},reduceRuntime:previous=>{if(fail)throw Error('runtime failure');return previous;}}),initial=editor.getQueries(),members=initial.members('S');editor.subscribe((_,snapshot)=>{const q=editor.getQueries();assert.equal(q.document,snapshot.document);assert.equal(q.revision,snapshot.revision);}); assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'renamed'}}).status,'applied');const titled=editor.getQueries();assert.equal(titled.spatial,initial.spatial);assert.equal(titled.relations.ownerMembers,initial.relations.ownerMembers);assert.equal(titled.members('S'),members);assert.equal(editor.getQueries(initial.document),initial);assert.equal(editor.updateRuntime(()=>({tag:1})).status,'applied');assert.equal(editor.getQueries(),titled);fail=true;const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'rejected');assert.equal(editor.getSnapshot(),before);assert.equal(editor.getQueries(),titled);fail=false; assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'applied');assert.equal(editor.getQueries().spatial.get('P'),initial.spatial.get('P'));assert.notEqual(editor.getQueries().spatial.get('S'),initial.spatial.get('S'));assert.deepEqual(initial.candidateNodes('S',{x:0,y:0,width:150,height:150}),['A','B']);assert.deepEqual(editor.getQueries().candidateNodes('S',{x:0,y:0,width:150,height:150}),['B']);assert.equal(editor.history.undo().status,'applied');parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision)); }); +// 40轮几何/port编辑穿插跨owner移动,每步重建对照;删除恢复、ACK顺序重排也保持等价,同内容再协调复用查询对象。 test('incremental queries match rebuilt indexes through transactions, cross-owner repair, deletion/restore and canonical reordering',()=>{ let next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:0}),check=()=>parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision)); for(let i=0;i<40;i++){const target=editor.getSnapshot().document.elements.get(i%2?'A':'B');assert.equal(editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:i*27,rotation:(i*11)%360}});tx.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{ports:[{id:'p',nodeId:target.id}]}});}).status,'applied');check();if(i%5===0){assert.equal(editor.dispatch({type:'element.move',target,canvasId:target.ownerCanvasId==='S'?'P':'S'}).status,'applied');check();}} @@ -25,21 +36,25 @@ test('incremental queries match rebuilt indexes through transactions, cross-owne const root=editor.getSnapshot(),canonical=createDocument(root.document.projectId,[...root.document.canvases.values()].reverse(),[...root.document.elements.values()].reverse(),root.document.tombstones);assert.equal(editor.reconcile(canonical,1,root.rootRevision).status,'applied');check();const after=editor.getQueries(),same=createDocument(canonical.projectId,[...canonical.canvases.values()],[...canonical.elements.values()],canonical.tombstones);assert.equal(editor.reconcile(same,2,editor.getSnapshot().rootRevision).status,'applied');assert.equal(editor.getQueries(),after); }); +// 巨大或溢出几何走oversized保守路径;旋转命中检查内外点与角点,非法查询范围拒绝,不把不完整结果说成完整。 test('owner-local grid conservatively selects rotated/huge nodes and precisely hits corners with finite query budgets',()=>{ const source=createDocument('project',[{id:'P',parent:null}],[node('rotated','P','text',{x:0,y:0,width:100,height:10,rotation:45}),node('huge','P','text',{x:-1000000,y:-1000000,width:2000000,height:2000000}),node('overflow','P','text',{x:Number.MAX_VALUE,y:0,width:Number.MAX_VALUE,height:100})]),q=createDocumentQueries(source,registry);assert.ok(q.spatial.get('P').oversized.has('huge'));assert.ok(q.spatial.get('P').oversized.has('overflow'));assert.deepEqual(q.hitNodeBounds('P',{x:50,y:5}).ids,['rotated','huge']);assert.equal(q.hitNodeBounds('P',{x:50,y:5}).complete,false);assert.ok(!q.hitNodeBounds('P',{x:10,y:40}).ids.includes('rotated'));assert.ok(q.candidateNodes('P',{x:-1e12,y:-1e12,width:2e12,height:2e12}).includes('rotated'));assert.throws(()=>q.candidateNodes('P',{x:0,y:0,width:-1,height:10}),/invalid_query_bounds/); const g=source.elements.get('rotated').geometry,angle=Math.PI/4,x=g.x+g.width/2+(g.width/2*Math.cos(angle)-g.height/2*Math.sin(angle)),y=g.y+g.height/2+(g.width/2*Math.sin(angle)+g.height/2*Math.cos(angle));assert.ok(q.hitNodeBounds('P',{x,y}).ids.includes('rotated')); }); +// 生成100个不同角度/位置矩形,用正向旋转生成内外点;外点默认不命中,6单位容差可覆盖5单位边外位置。 test('rotated rectangles are found across grid cells with forward-generated inside/outside points',()=>{ const nodes=Array.from({length:100},(_,i)=>node(`n-${i}`,'P','text',{x:i*1000,y:(i%5)*700,width:50+i,height:20+i%9,rotation:(i*37)%360})),doc=createDocument('project',[{id:'P',parent:null}],nodes),q=createDocumentQueries(doc,registry); for(const record of nodes){const g=record.geometry,angle=g.rotation*Math.PI/180,world=(x,y)=>({x:g.x+g.width/2+x*Math.cos(angle)-y*Math.sin(angle),y:g.y+g.height/2+x*Math.sin(angle)+y*Math.cos(angle)}),inside=world(g.width*.4,g.height*.4),outside=world(g.width/2+5,0);assert.ok(q.hitNodeBounds('P',inside).ids.includes(record.id));assert.ok(!q.hitNodeBounds('P',outside).ids.includes(record.id));assert.ok(q.hitNodeBounds('P',outside,6).ids.includes(record.id));} }); +// 自定义数组引用去重;改canvas引用移除旧反向关系且复用无关表,声明值变非法后清该主体引用并标opaque。 test('reverse element/canvas references remove obsolete subjects and mark malformed or unsupported declarations incomplete',()=>{ const custom=createRegistry([...builtinComponents(),{id:'refs',dataVersion:1,structure:'node',validate:()=>true,features:[],copy:{references:[{path:['targets'],many:true,target:'element',external:'preserve'},{path:['scopes'],many:true,target:'canvas',external:'preserve'}],omit:[]}}]),r={...node('R','P','refs'),data:{targets:['A','A'],scopes:['S']}},r2={...node('R2','P','refs'),data:{targets:['B'],scopes:['S']}},doc=createDocument('project',[...document.canvases.values()],[...document.elements.values(),r,r2]),q=createDocumentQueries(doc,custom); assert.deepEqual([...q.relations.canvasReferrers.get('S')],['R','R2']);assert.deepEqual([...q.relations.elementReferrers.get('A')],['F','R']);const change={kind:'field',target:{id:'R',incarnation:1},field:'data.scopes',before:['S'],after:['P']},next=applyChanges(doc,[change]),updated=advanceDocumentQueries(q,next,custom,1,[change]);assert.deepEqual([...updated.relations.canvasReferrers.get('S')],['R2']);assert.deepEqual([...updated.relations.canvasReferrers.get('P')],['R']);assert.equal(updated.relations.elementReferrers,q.relations.elementReferrers);const invalid={kind:'field',target:{id:'R',incarnation:1},field:'data.targets',before:['A','A'],after:'invalid'},bad=advanceDocumentQueries(updated,applyChanges(next,[invalid]),custom,2,[invalid]);assert.equal(bad.relations.referenceComplete,false);assert.deepEqual([...bad.relations.opaqueReferences],['R']);assert.equal(bad.relations.canvasReferrers.has('P'),false);assert.deepEqual([...bad.relations.elementReferrers.get('A')],['F']); }); +// runtime切完整覆盖时unknown由unloaded变absent,旧查询不变;foreign完整canvas声明被拒绝且根/查询不发布。 test('coverage changes share the metadata publication boundary and invalid coverage cannot partially publish a root',()=>{ const editor=createEditor({registry,document,runtime:{complete:false},queryCoverage:snapshot=>({projectComplete:snapshot.runtime.complete,topologyComplete:true,completeCanvasIds:snapshot.runtime.complete?['P','S']:[]})}); const before=editor.getSnapshot(),queries=editor.getQueries();assert.equal(queries.lookupElement('unknown').status,'unloaded');assert.equal(queries.membersResult('P').complete,false);let observed;