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()); 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 } }); const edge = { id: 'edge', incarnation: 1, structure: 'connection', ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, source: { elementId: 'outside' }, target: { elementId: 'inside' }, path: null }; 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]); 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(); }); 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); }); 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); 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(); }); 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/); }); 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(); }); 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); broken = false; await workspace.close(); assert.equal(leader.active, false); assert.equal(closed, true);assert.equal(digestClosed,true); }); 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); }); 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 }; 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 }); 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(); }); 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); }); 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'); }); 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'); }); 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);});