/** * 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); const inside = projection.nodes.find(node => node.id === 'inside'); assert.deepEqual(inside.position, { x: 134, y: 256 }); assert.equal(projection.edges[0].target, 'inside'); assert.equal(inside.selected, true); editor.dispatch({ type: 'element.data', target: { id: 'frame', incarnation: 1 }, patch: { presentation: { collapsed: true } } }); projection = projectCanvas(editor.getSnapshot().document, 'root', registry, new Set(), sessions.effectiveGeometry); assert.equal(projection.nodes.some(node => node.id === 'inside'), false); assert.equal(projection.edges[0].target, 'frame'); assert.equal(editor.getSnapshot().document.elements.get('edge').target.elementId, 'inside'); assert.equal(editor.getSnapshot().document.elements.get('inside').ownerCanvasId, 'child'); sessions.canvas('view', 'child'); assert.equal(sessions.getSnapshot().views.get('view').canvasId, 'child'); assert.deepEqual(projectCanvas(editor.getSnapshot().document, 'child', registry, new Set(), sessions.effectiveGeometry).nodes[0].position, { x: 10, y: 20 }); 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); const path = { type: 'bezier-chain', extension: { untouched: true }, startControl: { x: 40, y: 0 }, knots: [{ id: 'k', mode: 'corner', position: point, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] }; const transformed = transformPath(path, transform); assert.deepEqual(transformed.extension, path.extension); assert.ok(Math.abs(transformed.startControl.y - 20) < 1e-9); 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'); assert.equal(sessions.beginConnection('one', target).enabled, true); assert.equal(sessions.begin('two', [{ id: 'outside', incarnation: 1 }]).code, 'gesture_busy'); assert.equal(sessions.beginConnection('two', target).code, 'gesture_busy'); const path = { type: 'bezier-chain', knots: [] }; for (let i = 0; i < 20; i++) sessions.updateConnection('one', { ...path, startControl: { x: i, y: 0 } }); assert.equal(editor.getSnapshot().history.undo.length, 0); assert.equal(editor.getSnapshot().operations.size, 0); assert.equal(editor.getSnapshot().document.elements.get('edge').path, null); // 20 次预览只改会话;commit 将最后一次 x=19 的控制向量提交成一个操作及一个撤销单元。 assert.equal(sessions.commitConnection('one').status, 'applied'); assert.equal(editor.getSnapshot().history.undo.length, 1); assert.equal(editor.getSnapshot().operations.size, 1); assert.equal(editor.getSnapshot().document.elements.get('edge').path.startControl.x, 19); sessions.beginConnection('one', target); sessions.updateConnection('one', { ...path, startControl: { x: 99, y: 0 } }); detach(); assert.equal(sessions.getSnapshot().connectionGesture, null); assert.equal(editor.getSnapshot().document.elements.get('edge').path.startControl.x, 19); 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; } }); const first = host.workspace('account-a', 'workspace'), current = host.bindAccount('account-a'); assert.equal(first, host.workspace('account-a', 'workspace')); assert.equal(current(), true); assert.equal(created.length, 1); const second = host.workspace('account-b', 'workspace'); assert.notEqual(second, first); assert.equal(current(), false); assert.ok(second.authEpoch > first.authEpoch); await host.retireIdentity(); assert.equal(second.current(), false); assert.ok(closes.every(value => value === false)); 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: [] }); const remote = { type: 'bezier-chain', startControl: { x: 100, y: 0 }, knots: [] }; editor.dispatch({ type: 'changes.apply', changes: [{ kind: 'field', target: { id: 'edge', incarnation: 1 }, field: 'path', before: null, after: remote }] }, { origin: 'remote', history: false }); assert.equal(sessions.getSnapshot().connectionGesture, null); assert.deepEqual(editor.getSnapshot().document.elements.get('edge').path, remote); 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; const receipts = new Map(); const storage = { async claim(scope) { leader = { scope, fence: 1, active: true, async release() { leader.active = false; } }; return leader; }, async recover() { return image; }, async commit(_leader, batch) { if (broken) throw new Error('quota'); image = structuredClone(batch.image); return { durableRootRevision: image.rootRevision }; }, async readFile() {}, close() { closed = true; } }; const persistence = { compile: (intent, requestId, snapshot) => ({ canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])), payload: { intent, requestId } }), async submit(request) { const { applyChanges } = await import('../../src/lib/canvas-core/document.ts'); server = applyChanges(server, JSON.parse(request.body).intent.changes); version++; receipts.set(request.id, { document: server, versions: new Map([['root', version], ['child', 1]]) }); return { outcome: 'applied' }; }, async query() { return { outcome: 'unresolved' }; }, canonical: request => receipts.get(request.id) }; const workspace = createWorkspaceRuntime({ accountId: 'account', workspaceId: 'workspace', authEpoch: 1, registry, storage, isCurrentIdentity: () => true,digests:{digest:async()=> 'a'.repeat(64),close(){digestClosed=true;}}, adapters: () => ({ persistence }) }); const [a, b] = await Promise.all([workspace.open(document, new Map([['root', 1], ['child', 1]])), workspace.open(document, new Map([['root', 1], ['child', 1]]))]); assert.equal(a, b); const detach = a.sessions.attach('first'); a.sessions.attach('second'); detach(); a.editor.dispatch({ type: 'element.data', target: { id: 'outside', incarnation: 1 }, patch: { title: 'survives view detach' } }); await a.settle(); broken = true; a.editor.dispatch({ type: 'element.data', target: { id: 'outside', incarnation: 1 }, patch: { title: 'not durable yet' } }); await assert.rejects(() => workspace.close(), /workspace_close_failed/); assert.equal(leader.active, true); assert.equal(closed, false);assert.equal(digestClosed,false); assert.equal(a.editor.dispatch({ type: 'element.data', target: { id: 'outside', incarnation: 1 }, patch: { title: 'late' } }).status, 'rejected'); assert.equal(workspace.getProjects().size, 1); // 第一次关闭失败后 workspace 仍保留 project 和 leader,供同一 owner 重试保存;成功后才关闭共享资源和存储。 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)); const editor = createEditor({ document: source, registry }); assert.equal(editor.dispatch({ type: 'element.data', target: { id: 'edge', incarnation: 1 }, patch: { appearance: { color: '#123456' } } }).status, 'applied'); assert.equal(editor.dispatch({ type: 'connection.path', target: { id: 'edge', incarnation: 1 }, path: null }).code, 'unsupported_connection_path'); assert.deepEqual(editor.getSnapshot().document.elements.get('edge').path, future.path); 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 }); // 同一 view/element 的第二场手势有新 token;旧 lease 的三个入口都应无效,不能只防止重复 commit。 first.preview({ x: 500 }); first.commit(); first.cancel(); assert.equal(sessions.getSnapshot().gesture.preview.get('outside').x, 200); assert.equal(editor.getSnapshot().history.undo.length, 0); second.commit(); assert.equal(editor.getSnapshot().document.elements.get('outside').geometry.x, 200); assert.equal(editor.getSnapshot().history.undo.length, 1); 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);});