Files
Endoleap/tests/headless/bindings.test.mjs

125 lines
16 KiB
JavaScript

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