import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createDocument, createEditor, createRegistry, builtinComponents, defineComponent, defineFeature, editable, movable, resizable, rotatable, migrateElement, commonCanvas } from '../../src/lib/canvas-core/index.ts'; const geometry = { x: 10, y: 20, width: 100, height: 80, rotation: 0 }; const node = (id, ownerCanvasId = 'root', componentId = 'text') => ({ id, incarnation: 1, ownerCanvasId, componentId, dataVersion: 1, structure: 'node', geometry, data: { title: id, text: '' } }); const target = id => ({ id, incarnation: 1 }); function setup(elements = [node('a'), node('b')], options = {}) { let next = 0; return createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], elements), registry: createRegistry(builtinComponents()), createId: () => `op-${++next}`, ...options }); } test('runtime preparation shares the document publication boundary; metadata has a separate root revision', () => { const editor = setup(undefined, { runtime: Object.freeze({ ids: [] }), reduceRuntime(previous, next, commit) { if (next.document.elements.get('a').data.title === 'reject-ledger') throw new Error('ledger_failed'); return Object.freeze({ ids: Object.freeze([...previous.ids, ...commit.operationIds]) }); } }); const initial = editor.getSnapshot(); assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'reject-ledger' } }).status, 'rejected'); assert.strictEqual(editor.getSnapshot(), initial); const result = editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'committed' } }); assert.deepEqual(editor.getSnapshot().runtime.ids, result.operationIds); assert.equal(editor.getSnapshot().rootRevision, 1); assert.equal(editor.getSnapshot().revision, 1); const old = editor.getSnapshot(); assert.equal(editor.updateRuntime(value => ({ ...value, ready: true }), old.rootRevision).status, 'applied'); assert.equal(editor.getSnapshot().rootRevision, 2); assert.equal(editor.getSnapshot().revision, 1); assert.equal(editor.updateRuntime(value => value, old.rootRevision).code, 'stale_plan'); editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'canonical' } }, { origin: 'ack' }); assert.equal(editor.getSnapshot().operations.size, 1); assert.equal(editor.getSnapshot().history.undo.length, 1); }); test('a rejected second command publishes no document, history, operation or notification', () => { const editor = setup(), before = editor.getSnapshot(); let notifications = 0; editor.subscribe(() => notifications++); const result = editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'changed' } }); tx.dispatch({ type: 'element.geometry', target: target('missing'), patch: { x: 42 } }); }); assert.equal(result.status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); assert.equal(notifications, 0); }); test('can is pure and command handlers come from the component capability table', () => { let ids = 0; const frame = node('a', 'root', 'frame'), editor = setup([frame], { createId: () => String(++ids) }); const before = editor.getSnapshot(); assert.equal(editor.can({ type: 'element.geometry', target: target('a'), patch: { rotation: 30 } }).enabled, false); assert.equal(editor.can({ type: 'element.geometry', target: target('a'), patch: { x: 30 } }).enabled, true); assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before); }); test('absent and explicit null are distinct history values; view selection follows element deletion', async () => { const editor = setup(); editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } }); assert.equal(Object.hasOwn(editor.getSnapshot().document.elements.get('a').data, 'optional'), true); editor.history.undo(); assert.equal(Object.hasOwn(editor.getSnapshot().document.elements.get('a').data, 'optional'), false); editor.history.redo(); assert.equal(editor.getSnapshot().document.elements.get('a').data.optional, null); const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); const sessions = createSessions(editor); sessions.attach('view'); sessions.select('view', ['a']); editor.dispatch({ type: 'element.remove', target: target('a') }); assert.equal(sessions.getSnapshot().views.get('view').selection.size, 0); sessions.dispose(); assert.equal(sessions.getSnapshot().views.size, 0); }); test('permission changes cancel geometry previews and prevent new gestures', async () => { let permitted = true; const editor = setup(undefined, { policy: () => permitted ? { enabled: true } : { enabled: false, code: 'forbidden', targets: ['a'] } }); const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); const sessions = createSessions(editor); sessions.attach('view'); assert.equal(sessions.begin('view', [target('a')]).enabled, true); sessions.update('view', target('a'), { x: 100 }); permitted = false; editor.updateRuntime(() => ({ permissionEpoch: 2 })); assert.equal(sessions.getSnapshot().gesture, null); assert.equal(sessions.getSnapshot().lastCancelReason, 'permission_changed'); assert.equal(sessions.begin('view', [target('a')]).code, 'forbidden'); sessions.dispose(); }); test('several commands are one history unit; data undo preserves a later resource acknowledgment', () => { const editor = setup(); editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'changed' } }); tx.dispatch({ type: 'element.geometry', target: target('a'), patch: { x: 30 } }); }); editor.dispatch({ type: 'element.data', target: target('a'), patch: { asset: 'stored-asset' } }, { history: false, origin: 'asset-resolution' }); assert.equal(editor.getSnapshot().history.undo.length, 1); assert.equal(editor.history.undo().status, 'applied'); const value = editor.getSnapshot().document.elements.get('a'); assert.equal(value.data.title, 'a'); assert.equal(value.geometry.x, 10); assert.equal(value.data.asset, 'stored-asset'); assert.equal(editor.history.redo().status, 'applied'); assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored-asset'); }); test('same-field conflict does not pop history or overwrite remote edits', () => { const editor = setup(); editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'local' } }); editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'remote' } }, { history: false, origin: 'remote' }); const before = editor.getSnapshot(); assert.equal(editor.history.undo().status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); }); test('delete undo restores a new incarnation and the original connections; stale targets remain invalid', () => { const edge = { id: 'e', incarnation: 1, ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, structure: 'connection', source: { elementId: 'a' }, target: { elementId: 'b' }, path: null }; const editor = setup([node('a'), node('b'), edge]); assert.equal(editor.dispatch({ type: 'element.remove', target: target('a') }).status, 'applied'); assert.equal(editor.getSnapshot().document.elements.has('e'), false); assert.equal(editor.history.undo().status, 'applied'); assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 2); assert.equal(editor.getSnapshot().document.elements.has('e'), true); assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'late result' } }).status, 'rejected'); assert.equal(editor.history.redo().status, 'applied'); assert.equal(editor.history.undo().status, 'applied'); assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 3); }); test('new user edit clears redo, bounded history evicts old entries, no-op creates no operation', () => { const editor = setup(undefined, { historyLimit: 2 }); for (const title of ['one', 'two', 'three']) editor.dispatch({ type: 'element.data', target: target('a'), patch: { title } }); assert.equal(editor.getSnapshot().history.undo.length, 2); assert.equal(editor.getSnapshot().history.entries.size, 2); editor.history.undo(); editor.dispatch({ type: 'element.data', target: target('b'), patch: { title: 'new branch' } }); assert.equal(editor.getSnapshot().history.redo.length, 0); const before = editor.getSnapshot(); editor.dispatch({ type: 'element.data', target: target('b'), patch: { title: 'new branch' } }); assert.strictEqual(editor.getSnapshot(), before); }); test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => { const editor = setup(), before = editor.getSnapshot(); let captured; const result = editor.transaction(async tx => { captured = tx; tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'before await' } }); await Promise.resolve(); tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'after await' } }); }); assert.equal(result.status, 'rejected'); await Promise.resolve(); await Promise.resolve(); assert.strictEqual(editor.getSnapshot(), before); assert.throws(() => captured.dispatch({ type: 'element.remove', target: target('a') }), /transaction_closed/); }); test('unknown and future components survive known-object editing but cannot use uninstalled behavior', () => { const unknown = { ...node('future', 'root', 'future/audio'), dataVersion: 99, data: { novel: ['keep', { every: 'field' }] } }; const editor = setup([node('a'), unknown]); editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'edited' } }); assert.deepEqual(editor.getSnapshot().document.elements.get('future'), unknown); assert.equal(editor.dispatch({ type: 'element.data', target: target('future'), patch: { title: 'overwrite' } }).status, 'rejected'); }); test('capability conflicts are configuration errors; custom resize handlers can use a different constraint', () => { assert.throws(() => createRegistry([defineComponent({ id: 'bad', dataVersion: 1, structure: 'node', validate: () => true, features: [movable(), movable()] })]), /duplicate_feature/); const registry = createRegistry([defineComponent({ id: 'ratio', dataVersion: 1, structure: 'node', validate: () => true, features: [resizable({ aspect: true })] })]); const editor = setup([{ ...node('a', 'root', 'ratio'), geometry: { ...geometry, width: 200, height: 100 } }], { registry }); assert.equal(editor.dispatch({ type: 'element.geometry', target: target('a'), patch: { width: 400 } }).status, 'applied'); assert.equal(editor.getSnapshot().document.elements.get('a').geometry.height, 200); }); test('migration validates a staged copy and retains stable identity and ownership', () => { const registry = createRegistry([defineComponent({ id: 'text', dataVersion: 2, structure: 'node', features: [editable()], validate: data => typeof data.heading === 'string', migrations: { 1: data => ({ heading: data.title }) } })]); const original = node('a'); const upgraded = migrateElement(original, registry); assert.equal(upgraded.id, original.id); assert.equal(upgraded.ownerCanvasId, original.ownerCanvasId); assert.equal(upgraded.dataVersion, 2); assert.equal(original.dataVersion, 1); }); test('cross-child connection belongs to the common ancestor; cyclic canvas ownership rejects atomically', () => { const elements = [node('f'), node('g'), node('a', 'child'), node('b', 'sibling')]; const canvases = [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'f' } }, { id: 'sibling', parent: { canvasId: 'root', entryElementId: 'g' } }]; const registry = createRegistry(builtinComponents()); const document = createDocument('p', canvases, elements); assert.equal(commonCanvas(document, 'child', 'sibling'), 'root'); const editor = setup(elements, { registry, document }); const before = editor.getSnapshot(); assert.equal(editor.dispatch({ type: 'canvas.reparent', canvasId: 'root', parent: { canvasId: 'child', entryElementId: 'a' } }).status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); }); test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => { const editor = setup(); assert.equal(editor.getSnapshot().document.elements.set, undefined); editor.subscribe(() => { throw new Error('observer'); }); assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'committed' } }).status, 'applied'); assert.equal(editor.getSnapshot().document.elements.get('a').data.title, 'committed'); }); test('gesture preview is transient, cancel preserves an upload ACK, commit creates one history entry', async () => { const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view'); const initial = editor.getSnapshot(); assert.equal(sessions.begin('view', [target('a')]).enabled, true); sessions.update('view', target('a'), { x: 30 }); sessions.update('view', target('a'), { x: 60 }); assert.strictEqual(editor.getSnapshot(), initial); assert.equal(sessions.effectiveGeometry('a').x, 60); editor.dispatch({ type: 'element.data', target: target('a'), patch: { asset: 'stored' } }, { origin: 'asset-resolution', history: false }); assert.equal(sessions.getSnapshot().gesture.phase, 'active'); sessions.cancel('escape'); assert.equal(sessions.effectiveGeometry('a').x, 10); assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored'); sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 90 }); assert.equal(sessions.commit('view').status, 'applied'); assert.equal(editor.getSnapshot().history.undo.length, 1); assert.equal(editor.getSnapshot().document.elements.get('a').geometry.x, 90); detach(); sessions.dispose(); }); test('geometry conflict and view detach cancel only that gesture, without deleting model content', async () => { const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view'); sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 30 }); editor.dispatch({ type: 'element.geometry', target: target('a'), patch: { x: 70 } }, { origin: 'remote', history: false }); assert.equal(sessions.getSnapshot().gesture, null); assert.equal(sessions.effectiveGeometry('a').x, 70); sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 100 }); detach(); assert.equal(sessions.getSnapshot().gesture, null); assert.equal(editor.getSnapshot().document.elements.get('a').geometry.x, 70); sessions.dispose(); });