Files
feuerwerk/tools/e2e/02-inspector.mjs
T
adminandClaude Opus 5.5 393caadc9d
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m10s
Head the groups of the inspector with bands that fold (#290)
The groups of the inspector had a small grey label as their title, in
the same grid as a property row, so with many rows one group hardly
stood apart from the next. Now every group has a band as its head,
from edge to edge of the panel, and a click on it folds the group.

- A band runs across the padding of its column in the header colour,
  with the title and a mark that says whether the group is open. The
  titles stay as they are, as answered in the issue.
- A click on a band folds its group: the rows go, the band stays and
  the next band moves up against it. A control in the band (Open,
  + Variable, + Property) does its own job and folds nothing.
- A placed object has a group SWITCHES under its name, asset and
  parent: Enabled, Keep on scene load and the switches of a behavior.
  An asset has SWITCHES and PROPERTIES, and a group without rows is
  left out.
- The same bands head the tilemap panel, the Variables tab, the side
  of the Pixel art tab and the shape list of vector art. A list with a
  title (Layers, Frames, Shapes) folds with a click on its header,
  clear of its plus.
- ui/sections.ts holds the rules as pure functions, SectionStack is
  the column and SectionHeader the band. Every title of the inspector
  goes through addTitle or sectionHeader in inspector/host.ts.

Decided here:

- A fold is kept for good, in localStorage of this browser. The issue
  asked whether for the session or for good and the answer left it
  open.
- A fold is remembered by title, per panel. PROPERTIES folded once is
  folded on every object, asset and node. A number in a title does
  not count.
- A behavior asset has all its rows under SWITCHES. A Variables asset
  and a state machine get a group SCOPE.
- A greyed out control in a band takes the click too. Without that the
  click fell through to the band and folded the group.
- The Curve tab has no column of this kind and is unchanged.

Found on the way:

- The inspector of a sprite is taller than the 800 pixel window of
  the checks now. The hooks that hand out a row scroll it into view
  first, and module 28 scrolls before it looks for a row by its text.

Tests:
- Gate: 202 files, 3008 tests (2982 + 26), exit 0
- e2e: no complete run for this state. Modules 28 to 61 passed alone
  (749 checks). A full run was cut off in the accounts module at 1492
  passes without a failure, the modules after it and the smokes are
  missing. The sum of 1639 (1635 + 4) is reckoned, CI measures it.
- 26 unit tests in tests/editor/sections.test.ts
- Four e2e checks with a real mouse, three in module 02 and one in
  module 48
- 25 unit counter-probes all fail tests, run on stein2
- Screenshots of the bands looked at before the last build, none
  after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:08:28 +02:00

319 lines
14 KiB
JavaScript
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.
/**
* Selecting, renaming and scrubbing in the inspector, dragging in the viewport, autosave to
* the server, and undo/redo of all of it.
*/
export async function inspector(t) {
const {
check,
sleep,
json,
locate,
shot,
click,
mouse,
evaluate,
settle,
doubleClick,
clickText,
drag,
press,
typeText,
mutate,
undo,
redo,
state,
object,
api,
sceneOf,
projectId,
} = t;
// 2a. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = await state();
check(
'hierarchy click selects object',
s.selection?.kind === 'object' && s.selection.objectId === 'o2',
JSON.stringify(s.selection),
);
await shot('02-object-selected');
// 2a-ii. Groups with a band as their head (#290). The band runs from edge to edge of the panel in
// the header colour, the rows under it stand on the panel's own; the switches have a group.
const section = (title) => json(`__gm.section(${JSON.stringify(title)})`);
const panel = await json(`__gm.inspectorRect()`);
let transform = await section('TRANSFORM');
const switches = await section('SWITCHES');
const position = await section('POSITION');
const enabledRow = await json(`__gm.inspectorField('Enabled')`);
const colors = transform
? await json(
`[__gm.pixelAt(${Math.round(transform.rect.x + 3)}, ${Math.round(transform.y)}),` +
` __gm.pixelAt(${Math.round(transform.rect.x + transform.rect.w - 6)}, ${Math.round(transform.y)}),` +
` __gm.pixelAt(${Math.round(transform.rect.x + 3)}, ${Math.round(transform.rect.y + transform.rect.h + 12)}),` +
` __gm.themeColor('panelHeader'), __gm.themeColor('panel')]`,
)
: [];
check(
'a group head is a band from edge to edge, in a colour the rows under it do not have',
transform &&
transform.rect.x === panel.x &&
panel.w - transform.rect.w <= 4 &&
colors[0] === colors[3] &&
colors[1] === colors[3] &&
colors[2] === colors[4] &&
colors[3] !== colors[4],
JSON.stringify({ panel, rect: transform?.rect, colors }),
);
check(
'the switches of an object stand in a group of their own, above its position',
switches && position && enabledRow && switches.y < enabledRow.y && enabledRow.y < position.y,
JSON.stringify({ switches: switches?.y, enabled: enabledRow?.y, position: position?.y }),
);
// A click on the band folds the group: its rows go, and the next band moves up against it.
const folds = () => json(`localStorage.getItem('game-maker.folded-sections')`);
await click(transform.x, transform.y);
await settle();
transform = await section('TRANSFORM');
let offset = await section('EDITOR OFFSET (SCENE VIEW ONLY)');
const folded = {
collapsed: transform.collapsed,
rotation: await json(`__gm.inspectorField('Rotation')`),
gap: offset.rect.y - (transform.rect.y + transform.rect.h),
stored: await folds(),
};
await shot('02-section-folded');
// Folded once, the group is folded on the next object too.
await evaluate(
`__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1'] })`,
);
await settle();
const onNext = (await section('TRANSFORM'))?.collapsed;
await clickText('Blue crate');
transform = await section('TRANSFORM');
await click(transform.x, transform.y);
await settle();
transform = await section('TRANSFORM');
offset = await section('EDITOR OFFSET (SCENE VIEW ONLY)');
const rotationBack = await json(`__gm.inspectorField('Rotation')`);
check(
'a click on a band folds its group away, for every object, and another click brings it back',
folded.collapsed === true &&
folded.rotation === null &&
folded.gap === -1 &&
String(folded.stored).includes('inspector:TRANSFORM') &&
onNext === true &&
transform.collapsed === false &&
rotationBack !== null &&
offset.rect.y > rotationBack.y &&
!String(await folds()).includes('inspector:TRANSFORM'),
JSON.stringify({
folded,
onNext: onNext ?? null,
open: transform.collapsed,
rotationBack,
stored: await folds(),
}),
);
// 2b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
// crate overrides width and color, the camera and the red crate's subtree nothing.
const GOLD = '#f2c84b';
const PLAIN = '#e6e8ee';
const rowColor = (id) => json(`__gm.hierarchyRowColor('${id}')`);
check('an overridden object is marked gold in the hierarchy', (await rowColor('o2')) === GOLD);
check('an untouched object is not', (await rowColor('o3')) === PLAIN, await rowColor('o3'));
check('nor is a parent whose subtree is untouched', (await rowColor('o1')) === PLAIN);
check(
'the overridden field is gold, an inherited one is not',
(await json(`__gm.textColor('Width')`)) === GOLD &&
(await json(`__gm.textColor('Height')`)) === '#8a90a0',
JSON.stringify(await json(`[__gm.textColor('Width'), __gm.textColor('Height')]`)),
);
// The mark also travels up: with the crate's own overrides taken away the row must stay gold
// for the sake of its collider child, which keeps one. Put back right after, byte for byte.
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = {}`);
check('a parent carries the mark of a deviating child', (await rowColor('o2')) === GOLD);
await mutate(
`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = { width: 140, color: 0x4488ff }`,
);
// 2c. Rename via inspector: the name field sits right of the "Name" label.
const nameLabel = await locate('Name');
await click(nameLabel.x + 160, nameLabel.y);
await press('a', { ctrl: true });
await typeText('Renamed crate');
await shot('03-typing');
// The focus border runs all the way round, not only through the rounded corners: the field's
// clip once took in its own border and cut the straight edges away.
const field = await json(`__gm.focusedRect()`);
const edges = field
? await json(
`[[${field.x + field.w / 2}, ${field.y}], [${field.x + field.w / 2}, ${field.y + field.h - 1}],` +
` [${field.x}, ${field.y + field.h / 2}], [${field.x + field.w - 1}, ${field.y + field.h / 2}]]` +
`.map(([x, y]) => __gm.pixelAt(Math.round(x), Math.round(y)))`,
)
: [];
check(
'the focused field is outlined along all four edges',
edges.length === 4 && edges.every((c) => c === '#4c8dff'),
JSON.stringify({ field, edges }),
);
await press('Enter');
check(
'typing + Enter renamed the object',
(await object('o2')).name === 'Renamed crate',
JSON.stringify((await object('o2')).name),
);
await shot('04-renamed');
// 2d. A double-click into a text field selects its content, so the next keystroke replaces the
// whole name instead of inserting into it. Renamed back, since later checks go by the name.
await doubleClick(nameLabel.x + 160, nameLabel.y);
await typeText('Twice');
await press('Enter');
check(
'double-clicking a text field selects all – typing replaces the name',
(await object('o2')).name === 'Twice',
JSON.stringify((await object('o2')).name),
);
// Undone rather than typed back: check 2h further down counts its undos, so this must not
// leave a step on the stack.
await undo();
check(
'undo takes that rename back',
(await object('o2')).name === 'Renamed crate',
JSON.stringify((await object('o2')).name),
);
// 2d-ii. A drag from right to left selects the whole name (#236). The hidden input reported its
// range ordered, and its selectionchange echo made the caret the anchor after the first move.
await click(nameLabel.x + 160, nameLabel.y);
const nameField = await json('__gm.focusedRect()');
await press('Escape');
const midY = nameField.y + nameField.h / 2;
await drag(nameField.x + nameField.w - 4, midY, nameField.x + 3, midY);
const backward = await json(
'({ start: document.activeElement?.selectionStart, end: document.activeElement?.selectionEnd,' +
' direction: document.activeElement?.selectionDirection })',
);
await press('Escape');
check(
'dragging from right to left selects the whole name',
backward.start === 0 && backward.end === 'Renamed crate'.length && backward.direction === 'backward',
JSON.stringify(backward),
);
// 2e. Scrub the X field (right of the "X" label) by 100 px. The model follows every move, the
// editor hears of it once per frame at most (#274): counted while the button is still held.
const xLabel = await locate('X');
await evaluate(`(() => {
const seen = (window.__scrub02 = { events: 0, frames: 0, done: false });
seen.stop = __gm.state.on('project', () => (seen.events += 1));
const frame = () => { if (seen.done) return; seen.frames += 1; requestAnimationFrame(frame); };
requestAnimationFrame(frame);
})()`);
await mouse('mouseMoved', xLabel.x + 160, xLabel.y);
await mouse('mousePressed', xLabel.x + 160, xLabel.y);
for (let i = 1; i <= 25; i++) {
await mouse('mouseMoved', xLabel.x + 160 + i * 4, xLabel.y, 'left', { buttons: 1 });
await sleep(8);
}
await settle();
const held = await json(
'(() => { const s = window.__scrub02; s.done = true; s.stop(); return { events: s.events, frames: s.frames }; })()',
);
const xHeld = (await object('o2')).position.x;
await mouse('mouseReleased', xLabel.x + 260, xLabel.y);
await settle();
const xNow = (await object('o2')).position.x;
check('scrubbing X moved the object', xNow > 120, `x=${xNow}`);
check(
'a held scrub writes every move and previews once per frame at most',
xHeld === 220 && xNow === 220 && held.events >= 1 && held.events <= held.frames,
JSON.stringify({ xHeld, xNow, ...held }),
);
await shot('05-scrubbed');
// 2f. Type into X, then click straight into the empty viewport: the typed value must be
// committed (not reverted) even though the click deselects and rebuilds the inspector.
const center = await json('__gm.viewportCenter()');
// Searched, not guessed: with the console panel below, a fixed offset from the centre can
// land on an object instead of empty space.
const emptySpot = await json('__gm.emptySpot()');
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
await typeText('300');
await click(emptySpot.x, emptySpot.y);
const xTyped = (await object('o2')).position.x;
s = await state();
check(
'typed X survives clicking into the viewport',
xTyped === 300 && s.selection === null,
`x=${xTyped} selection=${JSON.stringify(s.selection)}`,
);
// 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// on the origin, so compute its screen position from the viewport centre. Pressed right of
// its middle: the crate wears a collider, whose icon stands on the middle.
await drag(center.x - 120, center.y, center.x - 20, center.y - 70);
const o1Now = (await object('o1')).position;
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
await shot('06-dragged');
await sleep(1800);
const saved = await api(`projects/${projectId}`);
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
check('rename persisted', renamed.name === 'Renamed crate', renamed.name);
check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position));
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
check('save status is saved', (await state()).save === 'saved');
// 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
await click(emptySpot.x, emptySpot.y); // click empty viewport → deselect, drop focus
for (let i = 0; i < 4; i++) await undo();
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
check(
'undo reverted drag, typed X, scrub and rename',
afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate',
JSON.stringify(afterUndo),
);
await redo();
check('redo restored the rename', (await object('o2')).name === 'Renamed crate');
await shot('07-undo-redo');
// 2i. A change to the *structure* of what is shown normally rebuilds the inspector, which
// destroys the widget being typed in. While a field in here has focus the rebuild has to
// wait – otherwise a field whose value decides the structure cannot be typed into at all.
await clickText('Renamed crate');
const xRow = await locate('X');
await click(xRow.x + 160, xRow.y);
// The value it already has: Escape cancels the widget but not the preview it wrote, so
// typing anything else here would leave that value behind for the later steps.
await typeText('120');
const before = await json('__gm.inspectorInfo()');
// The parent is part of the object's structure signature, so this asks for a rebuild.
await mutate("__gm.state.activeScene.objects.find(o => o.id === 'o2').parentId = 'o1'");
const during = await json('__gm.inspectorInfo()');
const stillTyping = await json(
'({ tag: document.activeElement?.tagName, value: document.activeElement?.value })',
);
check(
'a structure change while typing does not rebuild the inspector',
during.rebuilds === before.rebuilds && during.postponed,
JSON.stringify(during),
);
check(
'the field being typed in survives it',
stillTyping.tag === 'INPUT' && stillTyping.value === '120',
JSON.stringify(stillTyping),
);
await press('Escape');
const after = await json('__gm.inspectorInfo()');
check(
'leaving the field runs the postponed rebuild',
after.rebuilds === before.rebuilds + 1 && !after.postponed,
JSON.stringify(after),
);
await mutate("__gm.state.activeScene.objects.find(o => o.id === 'o2').parentId = ''");
}