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>
319 lines
14 KiB
JavaScript
319 lines
14 KiB
JavaScript
/**
|
||
* 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 = ''");
|
||
}
|