Files
feuerwerk/tools/e2e/52-hierarchy-groups.mjs
adminandClaude Opus 5.5 38f009e035
game-maker / gate (push) Successful in 3m42s
game-maker / suite (push) Successful in 10m55s
Make e2e 40, 46, 51 and 52 pass in the full run again
The suite job failed on every push since 6015869. Each module passed on
its own, but the full run showed two kinds of fault.

- e2e 40 still expected the object menu and the empty-cell menu from
  before #240 and #241. It now expects Copy, Paste, Group... and Remove
  from group, and Paste alone on an empty cell.
- e2e 46g read the outline of the first collider in the scene. In the
  full run that is a leftover of module 43, off screen, so it read as
  hidden before any switch. The check now places a collider of its own
  next to its tilemap and removes both at the end.
- e2e 51 copied the seed's red and blue crate. In the full run module 09
  has packed both into a container. The module now places two crates
  with colliders of its own and removes them at the end.
- e2e 52 compared whole row lists against the seed scene. In the full
  run that scene holds about 50 more rows. The module now works in a
  scene of its own, which it removes at the end. It had never run in CI
  because 51 died before it.

The check in e2e 41 that failed once (task 347, a dragged body 15 px
off in its first sample) is left alone. It failed in 1 of the last 60
logs, while a local e2e run loaded the same host.

Tests:
- full e2e run in the game-maker-ci image with GPU, as the runner does:
  ALL PASSED, 1452 checks, E2E EXIT 0 (the README sum is now measured)
- GM_STEPS=1,46,51,52 on the host: ALL PASSED, 51 checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:12:40 +02:00

274 lines
12 KiB
JavaScript
Raw Permalink 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.
import { same } from '../lib/harness.mjs';
/**
* Groups in the hierarchy (#241), made, opened, filled, renamed and dissolved with the real menus,
* clicks, keys and a real drag. The order rules and drops are unit-tested, this checks that the
* panel and the dialog do what those rules say.
* It works in a scene of its own, whose top level is g1, g2 (with the collider gc2), g3 and g5: the
* checks compare whole lists of rows, and in the full run the fixture's scene holds whatever the
* modules before left there. The scene is removed at the end.
*/
export async function hierarchyGroups(t) {
const { check, json, evaluate, click, clickText, drag, press, typeText, select, showTab, undo, shot } = t;
const SCENE = 'groups-52';
const home = await json('__gm.state.project.activeSceneId');
await t.mutate(`
const p = __gm.state.project;
const base = __gm.state.activeScene.objects[0];
const make = (id, assetId, parentId, x, name) => {
const o = structuredClone(base);
Object.assign(o, { id, assetId, parentId, name, position: { x, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
if ('enabled' in o) o.enabled = true;
return o;
};
p.assets.push({ id: ${JSON.stringify(SCENE)}, type: 'scene', name: 'Groups 52', props: { backgroundColor: 0x22252c },
objects: [
make('g1', 'crate', '', -200, 'Crate 1'),
make('g2', 'crate', '', 0, 'Crate 2'),
make('gc2', 'collider', 'g2', 0, 'Collider'),
make('g3', 'crate', '', 200, 'Crate 3'),
make('g5', 'mover', '', 300, 'Mover'),
] });
p.activeSceneId = ${JSON.stringify(SCENE)}`);
const rowIds = () => json('__gm.hierarchyOrder()');
const scene = () => json('__gm.state.activeScene');
const selected = async () => (await json('__gm.state.selection'))?.objectIds ?? [];
const rowAt = (id) => json(`__gm.hierarchyRow(${JSON.stringify(id)})`);
const menuRow = async (label) => {
const rows = (await json('__gm.menuRows()')) ?? [];
return { rows, enabled: ((await json('__gm.menuEnabled()')) ?? [])[rows.indexOf(label)] };
};
await press('Escape');
await showTab('scene');
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
// 52a. A child cannot go into a group: the entry is there, greyed out.
await select({ kind: 'object', holderId: SCENE, objectId: 'gc2', objectIds: ['gc2'] });
const child = await rowAt('gc2');
await click(child.x, child.y, 'right');
const childEntry = await menuRow('Group…');
check('Group… is greyed out for a child', childEntry.enabled === false, JSON.stringify(childEntry));
await press('Escape');
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
// 52b. Two top-level objects, the row menu's "Group 2 objects…", a name typed into the dialog.
await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g3'] });
const first = await rowAt('g1');
await click(first.x, first.y, 'right');
const entry = await menuRow('Group 2 objects…');
await clickText('Group 2 objects…');
const dialogOpened = await json('__gm.groupDialogOpen()');
await typeText('Crates');
await press('Enter');
const made = await scene();
const group = made.groups?.[0];
const row = `group:${group?.id}`;
check(
'Group 2 objects… asks for a name and makes the group of both',
entry.enabled === true &&
dialogOpened === true &&
(await json('__gm.groupDialogOpen()')) === false &&
made.groups?.length === 1 &&
group.name === 'Crates' &&
made.objects
.filter((o) => o.groupId === group.id)
.map((o) => o.id)
.join() === 'g1,g3',
JSON.stringify({ entry, dialogOpened, groups: made.groups }),
);
// g3 is listed with g1 now; with both members selected the group stays folded.
check(
'the group row stands where g1 stood, folded',
same(await rowIds(), [row, 'g2', 'g5']),
JSON.stringify(await rowIds()),
);
// 52c. A click on the group row selects the members, the chevron unfolds them.
await evaluate('__gm.state.select(null)');
const groupRow = await rowAt(row);
await click(groupRow.x, groupRow.y);
check(
'clicking the group row selects all its members and leaves it folded',
same(await selected(), ['g1', 'g3']) && same(await rowIds(), [row, 'g2', 'g5']),
JSON.stringify({ selected: await selected(), rows: await rowIds() }),
);
const chevron = await json(`__gm.hierarchyChevron(${JSON.stringify(row)})`);
await click(chevron.x, chevron.y);
check(
'its chevron lists the members below it',
same(await rowIds(), [row, 'g1', 'g3', 'g2', 'g5']),
JSON.stringify(await rowIds()),
);
await shot('52-group-open');
// 52d. Dragging g5 onto the group row puts it in behind the last member; undo takes it out.
const from = await rowAt('g5');
const onto = await rowAt(row);
await drag(from.x, from.y, onto.x, onto.y);
check(
'dragging an object onto the group row adds it to the group',
same(await rowIds(), [row, 'g1', 'g3', 'g5', 'g2']) &&
(await scene()).objects.find((o) => o.id === 'g5')?.groupId === group?.id,
JSON.stringify(await rowIds()),
);
await undo();
// 52e. Rename… from the group row's menu, typed over the selected old name.
await click(groupRow.x, groupRow.y, 'right');
const groupMenu = (await json('__gm.menuRows()')) ?? [];
await clickText('Rename…');
await typeText('Boxes');
await press('Enter');
check(
"the group row's menu renames it",
['Select members', 'Rename…', 'Ungroup'].every((label) => groupMenu.includes(label)) &&
(await scene()).groups?.[0]?.name === 'Boxes',
JSON.stringify({ groupMenu, groups: (await scene()).groups }),
);
// 52f. Ungroup keeps the objects and gives back the list of before.
await click(groupRow.x, groupRow.y, 'right');
await clickText('Ungroup');
const ungrouped = await scene();
check(
'Ungroup dissolves the group and keeps its members',
ungrouped.groups === undefined &&
!ungrouped.objects.some((o) => o.groupId) &&
same(await rowIds(), ['g1', 'g2', 'g3', 'g5']),
JSON.stringify({ groups: ungrouped.groups, rows: await rowIds() }),
);
// Ungroup, rename and group: three steps back to the fixture.
await undo();
await undo();
await undo();
const back = await scene();
check(
'undo takes each step back on its own, down to no group at all',
back.groups === undefined && !back.objects.some((o) => 'groupId' in o),
JSON.stringify(back.groups),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
// 52g. A plain click on a row that is already part of a multi-selection keeps only that row –
// the other member as well as the primary one.
await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2', 'g3'] });
const other = await rowAt('g2');
await click(other.x, other.y);
const narrowedToOther = await selected();
await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2', 'g3'] });
const primary = await rowAt('g1');
await click(primary.x, primary.y);
const narrowedToPrimary = await selected();
check(
'clicking a selected row narrows the selection to that row',
same(narrowedToOther, ['g2']) && same(narrowedToPrimary, ['g1']),
JSON.stringify({ narrowedToOther, narrowedToPrimary }),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
// 52h. Shift-click takes every row from the last plain click to the clicked one; a second
// Shift-click redraws the range from the same anchor, and it runs upwards just as well.
// It starts on g2: 52g's last click hit g1, and a second one there would count as a double click.
const clickRow = async (id, modifiers) => {
const at = await rowAt(id);
await click(at.x, at.y, 'left', modifiers);
};
await clickRow('g2');
await clickRow('g5', { shift: true });
const down = await selected();
const primaryAfter = (await json('__gm.state.selection'))?.objectId;
await clickRow('g1', { shift: true });
const up = await selected();
check(
'Shift-click selects the rows in between',
same([...down].sort(), ['g2', 'g3', 'g5']) &&
primaryAfter === 'g5' &&
same([...up].sort(), ['g1', 'g2']) &&
same(await rowIds(), ['g1', 'g2', 'g3', 'g5']),
JSON.stringify({ down, primaryAfter, up, rows: await rowIds() }),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
// 52i. A group "Pile" of g5 alone; its folder stands in the icon column of the objects, the
// count left of it. Then g1 and g2 join it through the dialog's group dropdown.
await select({ kind: 'object', holderId: SCENE, objectId: 'g5', objectIds: ['g5'] });
const lone = await rowAt('g5');
await click(lone.x, lone.y, 'right');
await clickText('Group…');
await typeText('Pile');
await press('Enter');
const pile = (await scene()).groups?.[0];
const pileRow = `group:${pile?.id}`;
const folder = await json(`__gm.hierarchyIcon(${JSON.stringify(pileRow)})`);
const count = await json(`__gm.hierarchyHint(${JSON.stringify(pileRow)})`);
const objectIcon = await json('__gm.hierarchyIcon("g1")');
check(
"a group's folder stands in the objects' icon column, its count left of it",
folder !== null &&
objectIcon !== null &&
count !== null &&
Math.abs(folder.x - objectIcon.x) < 1 &&
count.x < folder.x - 8,
JSON.stringify({ folder, objectIcon, count }),
);
await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2'] });
const joiner = await rowAt('g1');
await click(joiner.x, joiner.y, 'right');
await clickText('Group 2 objects…');
await clickText('New group');
const choices = (await json('__gm.menuRows()')) ?? [];
// The last "Pile" drawn is the menu's, above the hierarchy row of the same name.
const pick = await json('__gm.locate("Pile", true)');
await click(pick.x, pick.y);
await shot('52-join-group');
await clickText('Add');
const joined = await scene();
check(
'the Group dialog puts the objects into an existing group picked from its dropdown',
same(choices, ['New group', 'Pile']) &&
joined.groups?.length === 1 &&
same(
joined.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id),
['g1', 'g2', 'g5'],
),
JSON.stringify({ choices, groups: joined.groups, objects: joined.objects.map((o) => [o.id, o.groupId]) }),
);
await undo();
// 52j. Dragging one row of a multi-selection takes the others along – here onto the group row.
await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2'] });
const grab = await rowAt('g2');
const into = await rowAt(pileRow);
await drag(grab.x, grab.y, into.x, into.y);
const dragged = await scene();
const inPile = dragged.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id);
await undo();
const undone = await scene();
check(
'dragging a selected row moves the whole selection, in one undo step',
same(inPile, ['g5', 'g1', 'g2']) &&
same(
undone.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id),
['g5'],
),
JSON.stringify({ inPile, undone: undone.objects.map((o) => [o.id, o.groupId]) }),
);
await undo();
check(
'undo takes the group of g5 back too',
(await scene()).groups === undefined,
JSON.stringify((await scene()).groups),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
await t.mutate(`
const p = __gm.state.project;
p.activeSceneId = ${JSON.stringify(home)};
p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(SCENE)})`);
await t.saved();
}