Files
feuerwerk/tests/editor/plugins.test.ts
T
adminandClaude Opus 5 5ba796a2b6
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Failing after 8m21s
Plugins belong to the project, and they load before it: the project file gained the field plugins: ["demo-plugin.js"] (default empty, no formatVersion bump), the Plugins tab's switch writes into it as an ordinary undo step (SetPluginActiveCommand) instead of into a server-wide plugins.json, and the start order is turned around - startPlugins only fetches the list of files, then editor.boot() runs and ProjectStore.load(id, prepare) hands the raw JSON through before it is deserialized, where pluginsOf(raw) reads the list and PluginManager.syncTo() activates exactly those; that is the point of the issue, because what a plugin registers has to stand there before normalizeGraph would throw it away as unknown (#126, #127), a project switch first stops what the new project does not name and then starts what it does, a file the server does not have is passed over silently, and after signing out the manager is dead so a late event cannot start anything in a torn-down session; on the server plugins.json, PluginStore::setActive, isActive, the route PUT ?r=plugins/{file} and the 403 for inactive modules are gone and GET ?r=plugins only answers {file,bytes,updatedAt}, which makes every plugin file fetchable - the consequence accepted in #117, written down in the wiki for #115 and #116; publishPlugins(root) only copies files now, the seeder switches the demo plugin on in the fixture project, and the Plugins tab carries a puzzle piece as its icon while only a plugin's own tab still goes without one (the knob and the socket are cubic curves, not arcs, which Pixi's parser draws as a spike after an L); 3 new e2e checks in module 38 and 5 new unit tests cover the project as the owner of the switch, its undo, the order, pluginsOf against crude input and an unknown file name (#119)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:24:27 +02:00

619 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Plugins (#113, #114): what the loader does with every way a file can fail to be a plugin, what
* the API v1 lets a plugin see and do, and what switching one on and off in the Plugins tab does.
* The node board itself draws text and is checked in the browser (e2e step 38); only its reading
* of plugin input is here.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
CreateAssetCommand,
RenameAssetCommand,
SetAssetPropertyCommand,
SetPluginActiveCommand,
} from '../../src/core/commands/assetCommands';
import { createEmptyProject } from '../../src/core/model/Project';
import { zoomedAbout } from '../../src/editor/graph/graphView';
import { PluginManager, type PluginRecord, PluginScope, readMeta } from '../../src/editor/plugins/loader';
import { boardNodes } from '../../src/editor/plugins/NodeBoard';
import { displayName, statusText } from '../../src/editor/plugins/PluginsPane';
import { createPluginApi, type PluginHost, scopedHost } from '../../src/editor/plugins/pluginApi';
import { EditorState } from '../../src/editor/state';
import { Widget } from '../../src/ui';
const plugin = (id: string, activate: (api: unknown) => unknown = () => {}, api: unknown = 1) => ({
meta: { id, name: `Plugin ${id}`, version: '1.0.0', api },
activate,
});
/**
* A manager on made-up modules. `start` hands it the files the server has and then the list the
* open project switches on – everything but `off`, which stands for what the project leaves out.
*/
function loaderDeps(modules: Record<string, () => unknown>, off: string[] = []) {
const reports: string[] = [];
const activated: string[] = [];
const stored: [string, boolean][] = [];
const loads: [string, number][] = [];
const deps = {
load: (file: string, updatedAt: number) => {
loads.push([file, updatedAt]);
const make = modules[file];
return make ? Promise.resolve(make()) : Promise.reject(new Error('404'));
},
api: (meta: { id: string }) => {
activated.push(meta.id);
return { for: meta.id };
},
store: (file: string, active: boolean) => void stored.push([file, active]),
report: (message: string) => reports.push(message),
};
const manager = new PluginManager(deps);
const start = async (files = Object.keys(modules)) => {
await manager.start(files.map((file, i) => ({ file, updatedAt: i + 1 })));
await manager.syncTo(files.filter((file) => !off.includes(file)));
};
return { reports, activated, stored, loads, deps, manager, start };
}
const byFileOf = (records: readonly PluginRecord[]) =>
Object.fromEntries(records.map((r) => [r.file, r])) as Record<string, PluginRecord | undefined>;
describe('the plugin loader', () => {
it('activates in file-name order, whatever order the list came in', async () => {
const order: string[] = [];
const { manager, start } = loaderDeps({
'b.js': () => plugin('b', () => order.push('b')),
'a.js': () => plugin('a', () => order.push('a')),
});
await start(['b.js', 'a.js']);
expect(order).toEqual(['a', 'b']);
expect(manager.records.map((r) => [r.file, r.status])).toEqual([
['a.js', 'active'],
['b.js', 'active'],
]);
});
it('hands every plugin its own API and waits for an async activate before the next', async () => {
const seen: unknown[] = [];
let first = false;
const { start } = loaderDeps({
'a.js': () =>
plugin('a', async (api) => {
seen.push(api);
await Promise.resolve();
first = true;
}),
'b.js': () => plugin('b', (api) => seen.push(api, first)),
});
await start();
expect(seen).toEqual([{ for: 'a' }, { for: 'b' }, true]);
});
it('reports each broken plugin and goes on with the rest', async () => {
const { manager, start, reports, activated } = loaderDeps({
'good.js': () => plugin('good'),
'no-meta.js': () => ({ activate: () => {} }),
'no-activate.js': () => ({ meta: { id: 'x', api: 1 } }),
'future.js': () => plugin('future', () => {}, 2),
'throws.js': () =>
plugin('throws', () => {
throw new Error('boom');
}),
});
await start(['missing.js', 'no-meta.js', 'no-activate.js', 'future.js', 'throws.js', 'good.js']);
const byFile = byFileOf(manager.records);
expect(byFile['good.js']?.status).toBe('active');
expect(byFile['missing.js']).toMatchObject({ status: 'failed', error: 'does not load (404)' });
expect(byFile['no-meta.js']).toMatchObject({ status: 'failed', error: 'exports no meta' });
expect(byFile['no-activate.js']).toMatchObject({
status: 'failed',
error: 'exports no activate function',
});
expect(byFile['future.js']).toMatchObject({
status: 'failed',
error: 'needs plugin API 2, this editor offers 1',
});
expect(byFile['throws.js']).toMatchObject({ status: 'failed', error: 'activate failed (boom)' });
expect(reports).toHaveLength(5);
expect(reports).toContain('Plugin throws.js not activated: activate failed (boom)');
// A plugin of the wrong version is never given an API at all.
expect(activated).toEqual(['good', 'throws']);
});
it('turns away a second plugin with an id that is active already', async () => {
const { manager, start, activated } = loaderDeps({
'a.js': () => plugin('same'),
'b.js': () => plugin('same'),
});
await start();
expect(manager.records.map((r) => r.status)).toEqual(['active', 'failed']);
expect(manager.records[1]?.error).toBe('a plugin with the id "same" is active already');
expect(activated).toEqual(['same']);
});
it('lets a failed plugin keep its id free for a later one', async () => {
const { manager, start } = loaderDeps({
'a.js': () =>
plugin('same', () => {
throw new Error('no');
}),
'b.js': () => plugin('same'),
});
await start();
expect(manager.records.map((r) => r.status)).toEqual(['failed', 'active']);
});
it('fills in a missing name and version from what is there', () => {
expect(readMeta({ meta: { id: 'bare', api: 1 }, activate: () => {} })).toEqual({
id: 'bare',
name: 'bare',
version: '',
api: 1,
});
expect(readMeta({ meta: { id: 'x', api: 1 }, activate: () => {}, deactivate: 'no' })).toBe(
'deactivate must be a function',
);
expect(readMeta(null)).toBe('exports no meta');
expect(readMeta({ meta: { id: '', api: 1 }, activate: () => {} })).toBe(
'meta.id must be a non-empty string',
);
});
});
class Pane extends Widget {}
function editorHost(): {
state: EditorState;
host: PluginHost;
tabs: Map<string, { label: string; content: Widget }>;
} {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const tabs = new Map<string, { label: string; content: Widget }>();
const host: PluginHost = {
assets: () => state.project.assets,
onProjectChange: (listener) => state.on('project', listener),
hasTab: (id) => id === 'scene' || tabs.has(id),
addTab: (id, label, content) => tabs.set(id, { label, content }),
removeTab: (id) => void tabs.delete(id),
};
return { state, host, tabs };
}
describe('the plugin API', () => {
afterEach(() => vi.restoreAllMocks());
it('lists the assets as id, name and type – copies, so a plugin cannot write the model', () => {
const { state, host } = editorHost();
const create = new CreateAssetCommand('behavior', 'Walker');
state.execute(create);
const api = createPluginApi('demo', host);
const assets = api.project.assets();
expect(assets.at(-1)).toEqual({ id: create.asset.id, name: 'Walker', type: 'behavior' });
assets.at(-1)!.name = 'Changed';
expect(state.project.assets.find((a) => a.id === create.asset.id)?.name).toBe('Walker');
expect(Object.isFrozen(api) && Object.isFrozen(api.project) && Object.isFrozen(api.tabs)).toBe(true);
});
it('calls a change listener when the asset list changes, not on every model change', () => {
const { state, host } = editorHost();
const api = createPluginApi('demo', host);
const calls = vi.fn();
const off = api.project.on('change', calls);
const create = new CreateAssetCommand('sprite', 'Crate');
state.execute(create);
expect(calls).toHaveBeenCalledTimes(1);
state.execute(new SetAssetPropertyCommand(create.asset.id, 'width', 42));
state.preview();
expect(calls).toHaveBeenCalledTimes(1);
state.execute(new RenameAssetCommand(create.asset.id, 'Box'));
expect(calls).toHaveBeenCalledTimes(2);
// Back through rename, width and create: the width step leaves the list as it was.
state.undo();
state.undo();
state.undo();
expect(calls).toHaveBeenCalledTimes(4);
off();
state.redo();
expect(calls).toHaveBeenCalledTimes(4);
});
it('reports a listener that throws and keeps it subscribed', () => {
const { state, host } = editorHost();
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const api = createPluginApi('demo', host);
let calls = 0;
api.project.on('change', () => {
calls++;
throw new Error('listener broke');
});
state.execute(new CreateAssetCommand('sprite', 'A'));
state.execute(new CreateAssetCommand('sprite', 'B'));
expect(calls).toBe(2);
expect(error).toHaveBeenCalledTimes(2);
expect(String(error.mock.calls[0]?.[0])).toContain('[plugin demo]');
});
it('knows only the "change" event', () => {
const api = createPluginApi('demo', editorHost().host);
expect(() => api.project.on('save', () => {})).toThrow('Unknown project event "save"');
});
it('adds a tab with the widget its create returns', () => {
const { host, tabs } = editorHost();
const api = createPluginApi('demo', host);
const pane = new Pane();
expect(api.tabs.add({ id: 'assets', label: 'Assets', create: () => pane })).toBe(true);
expect(tabs.get('assets')).toEqual({ label: 'Assets', content: pane });
});
it('turns away a taken tab id with a warning, before anything is created', () => {
const { host, tabs } = editorHost();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const api = createPluginApi('demo', host);
const create = vi.fn(() => new Pane());
expect(api.tabs.add({ id: 'scene', label: 'Mine', create })).toBe(false);
api.tabs.add({ id: 'assets', label: 'Assets', create });
expect(createPluginApi('other', host).tabs.add({ id: 'assets', label: 'Again', create })).toBe(false);
expect(create).toHaveBeenCalledTimes(1);
expect(tabs.get('assets')?.label).toBe('Assets');
expect(warn).toHaveBeenCalledTimes(2);
expect(String(warn.mock.calls[1]?.[0])).toBe('[plugin other] a tab "assets" exists already – not added');
});
it('throws on a tab without id, label or create, or whose content is not a widget', () => {
const { host, tabs } = editorHost();
const api = createPluginApi('demo', host);
expect(() => api.tabs.add({ id: '', label: 'X', create: () => new Pane() })).toThrow('id must be');
expect(() => api.tabs.add({ id: 'x', label: 3, create: () => new Pane() })).toThrow('label must be');
expect(() => api.tabs.add({ id: 'x', label: 'X', create: 'no' })).toThrow('create must be a function');
expect(() => api.tabs.add({ id: 'x', label: 'X', create: () => ({ w: 10, h: 10 }) })).toThrow(
'widget from api.ui',
);
expect(tabs.size).toBe(0);
});
});
/**
* Switching plugins on and off (#114, #119). The switch belongs to the open project: it is written
* there first and applied afterwards, and the editor follows the project – so undo takes the switch
* back as well. Applying it means loading and activating, or running `deactivate` and taking back
* everything the plugin registered through its API.
*/
describe('switching a plugin on and off', () => {
/**
* A manager over a real API, host and project, wired the way `startPlugins` wires it: the switch
* runs through a command, and what runs follows the project's list.
*/
function switchable(modules: Record<string, () => unknown>, off: string[] = []) {
const { state, host, tabs } = editorHost();
const reports: string[] = [];
const stored: [string, boolean][] = [];
const loads: [string, number][] = [];
let refuse = false;
const manager = new PluginManager({
load: (file, updatedAt) => {
loads.push([file, updatedAt]);
const make = modules[file];
return make ? Promise.resolve(make()) : Promise.reject(new Error('404'));
},
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
store: (file, active) => {
if (refuse) throw new Error('Project is read-only');
stored.push([file, active]);
state.execute(new SetPluginActiveCommand(file, active));
},
report: (message) => reports.push(message),
});
state.on('project', () => void manager.syncTo(state.project.plugins));
const on = Object.keys(modules).filter((file) => !off.includes(file));
const start = async () => {
state.project.plugins = [...on];
await manager.start(Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1 })));
await manager.syncTo(state.project.plugins);
};
return { state, tabs, manager, start, reports, stored, loads, refuse: (no: boolean) => (refuse = no) };
}
/** A plugin that puts a tab and a change listener in place, and says when it was let go. */
const tabPlugin = (id: string, log: string[] = []) => ({
meta: { id, name: `Plugin ${id}`, version: '2.0', api: 1 },
activate: (api: ReturnType<typeof createPluginApi>) => {
api.project.on('change', () => log.push(`${id}: change`));
api.tabs.add({ id, label: id, create: () => new Pane() });
},
deactivate: () => log.push(`${id}: deactivate`),
});
it('loads only what the project asks for – a plugin it leaves out is never even fetched', async () => {
const { manager, start, loads } = switchable(
{ 'on.js': () => plugin('on'), 'off.js': () => plugin('off') },
['off.js'],
);
await start();
expect(loads.map(([file]) => file)).toEqual(['on.js']);
expect(manager.records.map((r) => [r.file, r.status, r.meta?.name])).toEqual([
['off.js', 'inactive', undefined],
['on.js', 'active', 'Plugin on'],
]);
});
it('writes the switch into the project first, then loads and activates the plugin', async () => {
const log: string[] = [];
const { manager, start, state, stored, loads, tabs } = switchable(
{ 'demo.js': () => tabPlugin('demo', log) },
['demo.js'],
);
await start();
expect(tabs.size).toBe(0);
await manager.setActive('demo.js', true);
expect(stored).toEqual([['demo.js', true]]);
expect(state.project.plugins).toEqual(['demo.js']);
// The file's own time goes into the load, so a replaced file is not taken from the module cache.
expect(loads).toEqual([['demo.js', 1]]);
expect(tabs.has('demo')).toBe(true);
expect(manager.records[0]).toMatchObject({ status: 'active', meta: { name: 'Plugin demo' } });
});
it('is an undo step: undoing the switch stops the plugin again', async () => {
const log: string[] = [];
const { manager, start, state, tabs } = switchable({ 'demo.js': () => tabPlugin('demo', log) });
await start();
expect(tabs.has('demo')).toBe(true);
await manager.setActive('demo.js', false);
expect(state.project.plugins).toEqual([]);
expect(tabs.size).toBe(0);
state.undo();
await manager.syncTo(state.project.plugins);
expect(state.project.plugins).toEqual(['demo.js']);
expect(tabs.has('demo')).toBe(true);
expect(manager.records[0]?.status).toBe('active');
state.redo();
await manager.syncTo(state.project.plugins);
expect(tabs.size).toBe(0);
expect(manager.records[0]?.status).toBe('inactive');
});
it('follows a project change: the one it leaves goes before the one it opens arrives', async () => {
const log: string[] = [];
const { manager, start, tabs } = switchable(
{ 'a.js': () => tabPlugin('a', log), 'b.js': () => tabPlugin('b', log) },
['b.js'],
);
await start();
expect([...tabs.keys()]).toEqual(['a']);
// What a different project switches on, as the raw file says it before it is deserialized.
await manager.syncTo(['b.js']);
expect(log).toEqual(['a: deactivate']);
expect([...tabs.keys()]).toEqual(['b']);
expect(manager.records.map((r) => [r.file, r.status])).toEqual([
['a.js', 'inactive'],
['b.js', 'active'],
]);
// Asking for what is running already does nothing at all.
await manager.syncTo(['b.js']);
expect(log).toEqual(['a: deactivate']);
});
it('passes over a file the server does not have, and says nothing about it', async () => {
const { manager, start, reports, loads } = switchable({ 'a.js': () => plugin('a') }, ['a.js']);
await start();
await manager.syncTo(['gone.js']);
expect(loads).toEqual([]);
expect(reports).toEqual([]);
expect(manager.records.map((r) => r.file)).toEqual(['a.js']);
});
it('signing out stops every plugin without touching the switches on the server', async () => {
const log: string[] = [];
const { manager, start, state, tabs, stored } = switchable({
'a.js': () => tabPlugin('a', log),
'b.js': () => tabPlugin('b', log),
'off.js': () => tabPlugin('off', log),
});
await start();
expect(tabs.size).toBe(3);
await manager.stopAll();
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
expect(tabs.size).toBe(0);
// The project keeps its list: signing out stops the plugins, it does not switch them off (#177).
expect(stored).toEqual([]);
expect(state.project.plugins).toEqual(['a.js', 'b.js', 'off.js']);
state.execute(new CreateAssetCommand('sprite', 'Crate'));
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
});
it('switching off runs deactivate and takes the tab and the listeners with it', async () => {
const log: string[] = [];
const { manager, start, state, tabs, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) });
await start();
state.execute(new CreateAssetCommand('sprite', 'Crate'));
expect(log).toEqual(['demo: change']);
await manager.setActive('demo.js', false);
expect(log).toEqual(['demo: change', 'demo: deactivate']);
expect(tabs.size).toBe(0);
expect(stored).toEqual([['demo.js', false]]);
expect(manager.records[0]).toMatchObject({ status: 'inactive' });
// Nothing of it runs any more, while the name it had stays on the row.
state.execute(new CreateAssetCommand('sprite', 'Box'));
expect(log).toEqual(['demo: change', 'demo: deactivate']);
expect(manager.records[0]?.meta?.name).toBe('Plugin demo');
await manager.setActive('demo.js', true);
state.execute(new CreateAssetCommand('sprite', 'Barrel'));
expect(log.at(-1)).toBe('demo: change');
expect(tabs.has('demo')).toBe(true);
});
it('leaves the plugin as it was when the project does not take the switch', async () => {
const { manager, start, state, reports, tabs, refuse } = switchable({
'demo.js': () => tabPlugin('demo'),
});
await start();
refuse(true);
await manager.setActive('demo.js', false);
expect(manager.records[0]?.status).toBe('active');
expect(state.project.plugins).toEqual(['demo.js']);
expect(tabs.has('demo')).toBe(true);
expect(reports).toEqual(['Plugin demo.js not switched off: Project is read-only']);
});
it('takes back what a plugin registered before its activate threw', async () => {
const { manager, start, tabs, reports } = switchable({
'half.js': () => ({
meta: { id: 'half', name: 'Half', version: '1', api: 1 },
activate: (api: ReturnType<typeof createPluginApi>) => {
api.tabs.add({ id: 'half', label: 'Half', create: () => new Pane() });
throw new Error('boom');
},
}),
});
await start();
expect(manager.records[0]).toMatchObject({ status: 'failed', error: 'activate failed (boom)' });
expect(tabs.size).toBe(0);
expect(reports).toEqual(['Plugin half.js not activated: activate failed (boom)']);
// Switched off it is simply off: nothing left to tidy, nothing to report, and the name it
// managed to give stays on its row.
await manager.setActive('half.js', false);
expect(manager.records[0]).toEqual({
file: 'half.js',
status: 'inactive',
meta: { id: 'half', name: 'Half', version: '1', api: 1 },
});
});
it('switches off even when the plugin throws on the way out, and says so', async () => {
const { manager, start, tabs, reports } = switchable({
'rude.js': () => ({
meta: { id: 'rude', name: 'Rude', version: '1', api: 1 },
activate: (api: ReturnType<typeof createPluginApi>) =>
api.tabs.add({ id: 'rude', label: 'Rude', create: () => new Pane() }),
deactivate: () => {
throw new Error('not going');
},
}),
});
await start();
await manager.setActive('rude.js', false);
expect(manager.records[0]).toMatchObject({
status: 'inactive',
error: 'deactivate failed (not going)',
});
expect(tabs.size).toBe(0);
expect(reports).toEqual(['Plugin rude.js switched off, but: deactivate failed (not going)']);
});
it('runs switches one at a time, in the order they were asked for', async () => {
const log: string[] = [];
const { manager, start, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) });
await start();
await Promise.all([
manager.setActive('demo.js', false),
manager.setActive('demo.js', true),
manager.setActive('demo.js', false),
]);
expect(stored).toEqual([
['demo.js', false],
['demo.js', true],
['demo.js', false],
]);
expect(manager.records[0]?.status).toBe('inactive');
});
it('tells its listeners after every change', async () => {
const { manager, start } = switchable({ 'demo.js': () => tabPlugin('demo') });
const seen = vi.fn();
const off = manager.onChange(seen);
// The list of files and the first sync against the project are two changes.
await start();
expect(seen).toHaveBeenCalledTimes(2);
await manager.setActive('demo.js', false);
expect(seen).toHaveBeenCalledTimes(3);
off();
await manager.setActive('demo.js', true);
expect(seen).toHaveBeenCalledTimes(3);
});
it('runs a scope’s cleanups newest first, all of them even when one throws', () => {
const scope = new PluginScope();
const order: string[] = [];
scope.add(() => order.push('first'));
scope.add(() => {
throw new Error('middle broke');
});
scope.add(() => order.push('last'));
expect(scope.dispose()).toEqual(['middle broke']);
expect(order).toEqual(['last', 'first']);
// Disposed twice (switched off, then the editor tidies up) runs nothing a second time.
expect(scope.dispose()).toEqual([]);
});
it('says on the row what became of each plugin', () => {
const row = (record: Partial<PluginRecord>): PluginRecord => ({
file: 'x.js',
status: 'inactive',
meta: null,
...record,
});
expect(statusText(row({ status: 'active' }))).toBe('Active');
expect(statusText(row({}))).toBe('Off');
expect(statusText(row({ status: 'failed', error: 'does not load (404)' }))).toBe(
'Failed: does not load (404)',
);
expect(statusText(row({ error: 'deactivate failed (x)' }))).toBe(
'Switched off, but: deactivate failed (x)',
);
expect(displayName(row({}))).toBe('—');
expect(displayName(row({ meta: { id: 'demo', name: 'Demo plugin', version: '1', api: 1 } }))).toBe(
'Demo plugin',
);
});
});
describe('a node board reading plugin input', () => {
it('takes titles and positions as given', () => {
expect(boardNodes([{ id: 'a', title: 'Crate', x: 10, y: -20 }])).toEqual([
{ id: 'a', title: 'Crate', x: 10, y: -20 },
]);
});
it('makes do with what plain JavaScript may hand over', () => {
expect(boardNodes([{ id: 7, title: 12 }, { x: NaN, y: '5', title: {} }, null])).toEqual([
{ id: '7', title: '12', x: 0, y: 0 },
{ id: '1', title: '', x: 0, y: 0 },
{ id: '2', title: '', x: 0, y: 0 },
]);
});
it('throws on something that is not a list', () => {
expect(() => boardNodes({ id: 'a' })).toThrow('expects an array');
});
});
describe('zooming a graph view about a point', () => {
it('keeps the graph point under the pointer where it was', () => {
const view = { pan: { x: 100, y: 50 }, zoom: 1 };
const at = { x: 300, y: 250 };
const next = zoomedAbout(view, at, 2);
expect(next.zoom).toBe(2);
// Graph point (200, 200) sat under the pointer before and has to after.
expect(200 * next.zoom + next.pan.x).toBe(at.x);
expect(200 * next.zoom + next.pan.y).toBe(at.y);
});
it('stops at the zoom limits', () => {
expect(zoomedAbout({ pan: { x: 0, y: 0 }, zoom: 2 }, { x: 0, y: 0 }, 10).zoom).toBe(2.5);
expect(zoomedAbout({ pan: { x: 0, y: 0 }, zoom: 0.3 }, { x: 0, y: 0 }, 0.1).zoom).toBe(0.25);
});
});