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, 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, 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(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); }); 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(); });