Files
feuerwerk/tests/editor/plugins.test.ts
T
2026-09-17 19:00:03 +02:00

297 lines
12 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): what the loader does with every way a file can fail to be a plugin, and what the
* API v1 lets a plugin see and do. 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,
} from '../../src/core/commands/assetCommands';
import { createEmptyProject } from '../../src/core/model/Project';
import { zoomedAbout } from '../../src/editor/graph/graphView';
import { activatePlugins, readMeta } from '../../src/editor/plugins/loader';
import { boardNodes } from '../../src/editor/plugins/NodeBoard';
import { createPluginApi, type PluginHost } 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,
});
function loaderDeps(modules: Record<string, () => unknown>) {
const reports: string[] = [];
const activated: string[] = [];
return {
reports,
activated,
deps: {
load: (file: string) => {
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 };
},
report: (message: string) => reports.push(message),
},
};
}
describe('the plugin loader', () => {
it('activates in file-name order, whatever order the list came in', async () => {
const order: string[] = [];
const { deps } = loaderDeps({
'b.js': () => plugin('b', () => order.push('b')),
'a.js': () => plugin('a', () => order.push('a')),
});
const records = await activatePlugins(['b.js', 'a.js'], deps);
expect(order).toEqual(['a', 'b']);
expect(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 { deps } = 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 activatePlugins(['a.js', 'b.js'], deps);
expect(seen).toEqual([{ for: 'a' }, { for: 'b' }, true]);
});
it('reports each broken plugin and goes on with the rest', async () => {
const { deps, 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');
}),
});
const records = await activatePlugins(
['missing.js', 'no-meta.js', 'no-activate.js', 'future.js', 'throws.js', 'good.js'],
deps,
);
const byFile = Object.fromEntries(records.map((r) => [r.file, r]));
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 { deps, activated } = loaderDeps({ 'a.js': () => plugin('same'), 'b.js': () => plugin('same') });
const records = await activatePlugins(['a.js', 'b.js'], deps);
expect(records.map((r) => r.status)).toEqual(['active', 'failed']);
expect(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 { deps } = loaderDeps({
'a.js': () =>
plugin('same', () => {
throw new Error('no');
}),
'b.js': () => plugin('same'),
});
expect((await activatePlugins(['a.js', 'b.js'], deps)).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(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 }),
};
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);
});
});
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);
});
});