Files
feuerwerk/tests/editor/sections.test.ts
T
adminandClaude Opus 5.5 043c224cc3 Rename Game Maker to Feuerwerk and serve it at /feuerwerk/ (#422)
The editor gets a name of its own that fits beside Brandmauer,
Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the
name, the URL and the stored state in the browser. Repository, CI
runners, containers and the GAME_MAKER_* variables follow in stage 3.

- All paths come from SITE_PATH in src/core/site.ts: the API endpoint,
  the data folders, the play page and the base in vite.config.ts. A
  later move is one line instead of fifteen.
- The window title, the sign-in screen, the playground and the README
  say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the
  writing app in Ogg and Matroska follow.
- The session cookie is now feuerwerk_session, so everyone signs in
  once more.
- localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the
  old game-maker.* keys over on the first start, so settings, panel
  sizes and save games survive.
- tools/ci/serve.sh serves the build under /feuerwerk/. The variable
  GAME_MAKER_BASE_URL keeps its name until stage 3.

Outside the repository, nockal.com/feuerwerk/ is a second symlink to
the same tree. nginx sends the bare /game-maker/ folder there with a
301. Everything else under /game-maker/ is still served directly, so
shared play links and the API keep working.

Finding: when nginx redirects a path without a trailing slash or with
a query, the Brandmauer answers with its own 307 to the same URL plus
a slash, placed after the query. A play link would lose its token that
way, which is why only the bare folder is redirected.

Tests:
- Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests
  (4 new in site.test.ts).
- Counter-check: when the migration misses the old prefix, 3 of the
  4 new tests fail.
- e2e 60 under /feuerwerk/: ALL PASSED, 20 checks.
- From outside: /feuerwerk/ 200, /game-maker/ 301, an old play
  link 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:27:43 +02:00

421 lines
14 KiB
TypeScript

/**
* Groups of rows with a band as their head (#290): which rows a folded group hides, where the bands
* and rows of a column go, what a click on a band does, how a fold is remembered, and which groups
* the inspector builds.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CanvasTextMetrics } from 'pixi.js';
import { findAsset } from '../../src/core/model/Project';
import { buildAssetSection } from '../../src/editor/inspector/assetSection';
import { addTitle, type InspectorHost, sectionHeader } from '../../src/editor/inspector/host';
import { buildObjectSection } from '../../src/editor/inspector/objectSection';
import { foldKey, isFolded, listFold, resetFolds, setFolded } from '../../src/editor/sectionFolds';
import { EditorState } from '../../src/editor/state';
import {
Box,
hiddenBySections,
Label,
SECTION_BAND_HEIGHT,
SectionHeader,
sectionOffsets,
SectionStack,
SORTABLE_ROW_HEIGHT,
SortableList,
type Widget,
} from '../../src/ui';
import { makeBehavior, makeSpriteScene, place } from '../helpers';
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
});
const GAP = 4;
const PADDING = 8;
function row(h = 24): Box {
const box = new Box();
box.minHeight = h;
return box;
}
function column(...children: Widget[]): SectionStack {
const stack = new SectionStack({ gap: GAP, padding: PADDING });
for (const child of children) stack.add(child);
const size = stack.measure();
stack.resize(200, size.h);
return stack;
}
function memoryStore(initial: Record<string, string> = {}) {
const items = new Map(Object.entries(initial));
return {
items,
getItem: (key: string) => items.get(key) ?? null,
setItem: (key: string, value: string) => void items.set(key, value),
};
}
describe('the rows a folded group hides', () => {
it('are the ones down to the next head, never a head', () => {
expect(hiddenBySections([false, null, null, true, null, null, false, null])).toEqual([
false,
false,
false,
false,
true,
true,
false,
false,
]);
});
it('are none in front of the first head', () => {
expect(hiddenBySections([null, true, null])).toEqual([false, false, true]);
});
});
describe('where bands and rows go', () => {
const band = { h: SECTION_BAND_HEIGHT, start: true, band: true };
const plain = { h: 24, start: false, band: false };
it('puts the first band on the top edge and keeps the padding under the last row', () => {
const { ys, height } = sectionOffsets([band, plain, plain], GAP, PADDING);
expect(ys).toEqual([0, SECTION_BAND_HEIGHT + GAP + 2, SECTION_BAND_HEIGHT + GAP + 2 + 24 + GAP]);
expect(height).toBe(ys[2]! + 24 + PADDING);
});
it('keeps the padding between a group and the next band', () => {
const { ys } = sectionOffsets([band, plain, band], GAP, PADDING);
expect(ys[2]! - (ys[1]! + 24)).toBe(PADDING);
});
it('stands folded bands on each other with one line between them', () => {
const { ys, height } = sectionOffsets([band, band, band], GAP, PADDING);
expect(ys).toEqual([0, SECTION_BAND_HEIGHT - 1, 2 * (SECTION_BAND_HEIGHT - 1)]);
expect(height).toBe(ys[2]! + SECTION_BAND_HEIGHT);
});
it('shares exactly the line the bands draw, and still ends on a whole pixel', () => {
const line = 2 / 3;
const { ys, height } = sectionOffsets([band, band], GAP, PADDING, line);
expect(ys[1]).toBeCloseTo(SECTION_BAND_HEIGHT - line, 10);
expect(height).toBe(Math.ceil(2 * SECTION_BAND_HEIGHT - line));
});
it('starts inside the padding without a band on top', () => {
expect(sectionOffsets([plain, plain], GAP, PADDING).ys).toEqual([PADDING, PADDING + 24 + GAP]);
});
});
describe('a column of groups', () => {
it('runs a band across the padding and keeps the rows inside it', () => {
const head = new SectionHeader('POSITION');
const first = row();
const stack = column(head, first);
expect([head.x, head.w, head.h]).toEqual([0, 200, SECTION_BAND_HEIGHT]);
expect([first.x, first.w]).toEqual([PADDING, 200 - 2 * PADDING]);
expect(first.y).toBe(SECTION_BAND_HEIGHT + GAP + 2);
expect(stack.measure().h).toBe(first.y + 24 + PADDING);
});
it('hides the rows of a folded group, takes their room and gives both back', () => {
const a = new SectionHeader('A');
const b = new SectionHeader('B');
const rows = [row(), row(), row()];
const stack = column(a, rows[0]!, rows[1]!, b, rows[2]!);
const open = stack.measure().h;
let told = 0;
stack.onFold = () => (told += 1);
a.toggle();
expect(rows.map((r) => r.visible)).toEqual([false, false, true]);
expect(stack.measure().h).toBe(open - 2 * 24 - 2 * GAP - 2 - PADDING - 1);
expect(told).toBe(1);
stack.layout();
expect(b.y).toBe(SECTION_BAND_HEIGHT - 1);
a.toggle();
expect(rows.map((r) => r.visible)).toEqual([true, true, true]);
expect(stack.measure().h).toBe(open);
});
it('leaves a row hidden that was hidden for its own reason', () => {
const head = new SectionHeader('A');
const own = row();
own.visible = false;
const other = row();
column(head, own, other);
head.toggle();
head.toggle();
expect([own.visible, other.visible]).toEqual([false, true]);
});
it('starts with a group folded that is built folded', () => {
const rows = [row()];
column(new SectionHeader('A', { collapsed: true }), rows[0]!);
expect(rows[0]!.visible).toBe(false);
});
});
describe('a band', () => {
it('holds its title and puts a control at its right end, inside it', () => {
const head = new SectionHeader('VARIABLES');
const control = head.add(row());
control.minWidth = 40;
column(head);
const texts = head.children.flatMap((c) => c.children).filter((c): c is Label => c instanceof Label);
expect(texts.map((label) => label.text)).toEqual(['VARIABLES']);
const right = control.parent!.x + control.x + control.w;
expect(right).toBe(200 - PADDING);
expect(control.w).toBe(40);
});
it('knows where its controls stand, so a press a disabled one lets through folds nothing', () => {
const head = new SectionHeader('PROPERTIES');
const control = head.add(row());
control.minWidth = 40;
control.setEnabled(false);
column(head);
const y = SECTION_BAND_HEIGHT / 2;
expect(head.controlAt({ x: 200 - PADDING - 20, y })).toBe(true);
expect(head.controlAt({ x: 200 - PADDING - 41, y })).toBe(false);
expect(head.controlAt({ x: 60, y })).toBe(false);
});
it('tells its owner each time it is folded or unfolded', () => {
const told: boolean[] = [];
const head = new SectionHeader('A', { onToggle: (on) => told.push(on) });
head.toggle();
head.toggle();
expect(told).toEqual([true, false]);
expect(head.sectionCollapsed).toBe(false);
});
});
describe('a list with a title', () => {
const options = { onPick: () => {}, onMove: () => {} };
it('folds down to its band and opens a group in its column', () => {
const told: boolean[] = [];
const list = new SortableList({
...options,
title: 'Layers',
collapsed: true,
onFold: (on) => told.push(on),
});
list.setItems(
[
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
],
[],
);
const note = row();
column(list, note);
expect(list.measure().h).toBe(SECTION_BAND_HEIGHT);
expect(list.sectionStart).toBe('inset');
expect(note.visible).toBe(false);
expect(list.x).toBe(PADDING);
});
it('shows its rows while it is open', () => {
const list = new SortableList({ ...options, title: 'Layers' });
list.setItems([{ id: 'a', label: 'A' }], []);
expect(list.measure().h).toBe(SECTION_BAND_HEIGHT + SORTABLE_ROW_HEIGHT);
});
it('is no group without a title, and cannot start folded', () => {
const list = new SortableList({ ...options, collapsed: true });
expect(list.sectionStart).toBeNull();
expect(list.sectionCollapsed).toBe(false);
});
});
describe('the memory of folded groups', () => {
beforeEach(() => resetFolds());
afterEach(() => {
vi.unstubAllGlobals();
resetFolds();
});
it('names a group by panel and title, whatever the title counts', () => {
expect(foldKey('inspector', 'KEY AT FRAME 12')).toBe(foldKey('inspector', 'KEY AT FRAME 3'));
expect(foldKey('inspector', '2 OBJECTS SELECTED')).toBe(foldKey('inspector', '14 OBJECTS SELECTED'));
expect(foldKey('inspector', 'LAYERS')).not.toBe(foldKey('tilemap', 'LAYERS'));
expect(foldKey('inspector', 'POSITION')).not.toBe(foldKey('inspector', 'TRANSFORM'));
});
it('writes a fold to the browser and reads it back on a new page', () => {
const store = memoryStore();
vi.stubGlobal('window', { localStorage: store });
const key = foldKey('inspector', 'TRANSFORM');
expect(isFolded(key)).toBe(false);
setFolded(key, true);
expect(isFolded(key)).toBe(true);
resetFolds();
expect(isFolded(key)).toBe(true);
setFolded(key, false);
resetFolds();
expect(isFolded(key)).toBe(false);
});
it('remembers for the page where the browser gives no storage, and reads rubbish as nothing', () => {
const key = foldKey('inspector', 'TRANSFORM');
setFolded(key, true);
expect(isFolded(key)).toBe(true);
resetFolds();
vi.stubGlobal('window', { localStorage: memoryStore({ 'feuerwerk.folded-sections': '{broken' }) });
expect(isFolded(key)).toBe(false);
});
it('builds a band folded that was folded before, and remembers a click on it', () => {
const store = memoryStore();
vi.stubGlobal('window', { localStorage: store });
const host = makeHost();
setFolded(foldKey('inspector', 'TRANSFORM'), true);
expect(sectionHeader(host, 'TRANSFORM').sectionCollapsed).toBe(true);
const position = sectionHeader(host, 'POSITION');
expect(position.sectionCollapsed).toBe(false);
position.toggle();
expect(isFolded(foldKey('inspector', 'POSITION'))).toBe(true);
// Another panel's group of the same name is its own.
expect(sectionHeader({ ...host, foldScope: 'tilemap' }, 'TRANSFORM').sectionCollapsed).toBe(false);
});
it('gives a list its fold under the title in capitals', () => {
vi.stubGlobal('window', { localStorage: memoryStore() });
listFold('pixelart', 'Layers').onFold(true);
expect(isFolded(foldKey('pixelart', 'LAYERS'))).toBe(true);
expect(listFold('pixelart', 'Layers').collapsed).toBe(true);
expect(listFold('pixelart', 'Frames').collapsed).toBe(false);
});
});
function makeHost(state = new EditorState()): InspectorHost {
return {
state,
body: new SectionStack({ gap: GAP, padding: PADDING }),
fields: new Map(),
nameField: null,
project: () => state.project,
liveVariables: () => null,
};
}
/** What the body shows, top to bottom: a band by its title in brackets, a row by the label it starts with. */
function outline(host: InspectorHost): string[] {
const firstLabel = (node: Widget): string => {
if (node instanceof Label) return node.text;
for (const child of node.children) {
const found = firstLabel(child as Widget);
if (found) return found;
}
return '';
};
return (host.body.children as Widget[]).map((child) =>
child instanceof SectionHeader ? `[${child.title}]` : firstLabel(child),
);
}
describe('the groups of the inspector', () => {
beforeEach(() => resetFolds());
function fixture() {
const f = makeSpriteScene();
const state = new EditorState();
state.loadProject(f.project, 1);
return { f, state };
}
it('gives the switches of a placed object a group of their own, under its head', () => {
const { f, state } = fixture();
const objectId = place(f, f.spriteId, { x: 0, y: 0 });
const host = makeHost(state);
buildObjectSection(host, f.sceneId, objectId);
const shown = outline(host);
expect(shown.slice(0, 8)).toEqual([
'[SPRITE OBJECT]',
'Name',
'Asset',
'Parent',
'[SWITCHES]',
'Enabled',
'Keep on scene load',
'Render layer',
]);
expect(shown[8]).toBe('[POSITION]');
expect(shown.filter((line) => line.startsWith('['))).toEqual([
'[SPRITE OBJECT]',
'[SWITCHES]',
'[POSITION]',
'[TRANSFORM]',
'[EDITOR OFFSET (SCENE VIEW ONLY)]',
'[PROPERTIES]',
'[PREVIEW]',
]);
});
it('puts the switches of a behavior there too, and its Open button into the band of its variables', () => {
const { f, state } = fixture();
const behavior = makeBehavior(f, 'Mover', ['speed']);
const objectId = place(f, behavior.id, { x: 0, y: 0 });
const host = makeHost(state);
buildObjectSection(host, f.sceneId, objectId);
const shown = outline(host);
const from = shown.indexOf('[SWITCHES]');
const to = shown.indexOf('[POSITION]');
expect(shown.slice(from + 1, to)).toEqual([
'Enabled',
'Keep on scene load',
'Run in editor',
'Run while paused',
]);
const variables = host.body.children.find(
(child): child is SectionHeader => child instanceof SectionHeader && child.title === 'VARIABLES',
)!;
const texts: string[] = [];
const walk = (node: Widget): void => {
if ('text' in node && typeof node.text === 'string') texts.push(node.text);
for (const child of node.children) walk(child as Widget);
};
walk(variables);
expect(texts).toContain('Open');
});
it('splits the rows of an asset into switches and properties', () => {
const { f, state } = fixture();
const host = makeHost(state);
buildAssetSection(host, f.spriteId);
const shown = outline(host);
expect(shown.slice(0, 6)).toEqual([
'[SPRITE ASSET]',
'Name',
'[SWITCHES]',
'Enabled',
'Keep on scene load',
'Render layer',
]);
expect(shown[6]).toBe('[PROPERTIES]');
expect(shown.length).toBeGreaterThan(7);
});
it('heads the rows of a behavior asset as switches, with no empty group after them', () => {
const { f, state } = fixture();
const behavior = makeBehavior(f, 'Mover', ['speed']);
const host = makeHost(state);
buildAssetSection(host, behavior.id);
expect(findAsset(f.project, behavior.id)?.type).toBe('behavior');
expect(outline(host).filter((line) => line.startsWith('['))).toEqual([
'[BEHAVIOR ASSET]',
'[SWITCHES]',
'[VARIABLES]',
]);
});
it('builds a title as a band, in any body', () => {
const host = makeHost();
addTitle(host, 'CONTENT');
expect(host.body.children[0]).toBeInstanceOf(SectionHeader);
});
});