From c426e94b58e8329fe7ec1013b6c04e097cccbda4 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 02:43:36 +0800 Subject: [PATCH] docs(tests): explain retained views resource handles and batch proofs --- tests/headless/batch-projection.test.mjs | 18 ++++++++++++++++++ tests/headless/bindings.test.mjs | 20 ++++++++++++++++++++ tests/headless/query-retention.test.mjs | 10 ++++++++++ tests/headless/resource-view.test.mjs | 14 ++++++++++++++ tests/headless/retained-projection.test.mjs | 11 +++++++++++ tests/headless/visibility.test.mjs | 4 ++++ 6 files changed, 77 insertions(+) diff --git a/tests/headless/batch-projection.test.mjs b/tests/headless/batch-projection.test.mjs index 72cd1db..c575874 100644 --- a/tests/headless/batch-projection.test.mjs +++ b/tests/headless/batch-projection.test.mjs @@ -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]); diff --git a/tests/headless/bindings.test.mjs b/tests/headless/bindings.test.mjs index f257afd..115501c 100644 --- a/tests/headless/bindings.test.mjs +++ b/tests/headless/bindings.test.mjs @@ -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);}); diff --git a/tests/headless/query-retention.test.mjs b/tests/headless/query-retention.test.mjs index 53f7e12..3f14c68 100644 --- a/tests/headless/query-retention.test.mjs +++ b/tests/headless/query-retention.test.mjs @@ -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}); }); diff --git a/tests/headless/resource-view.test.mjs b/tests/headless/resource-view.test.mjs index 51b0f02..103bba5 100644 --- a/tests/headless/resource-view.test.mjs +++ b/tests/headless/resource-view.test.mjs @@ -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, []); diff --git a/tests/headless/retained-projection.test.mjs b/tests/headless/retained-projection.test.mjs index 32f9583..3795e25 100644 --- a/tests/headless/retained-projection.test.mjs +++ b/tests/headless/retained-projection.test.mjs @@ -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); diff --git a/tests/headless/visibility.test.mjs b/tests/headless/visibility.test.mjs index 8a7151a..6623091 100644 --- a/tests/headless/visibility.test.mjs +++ b/tests/headless/visibility.test.mjs @@ -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);});