Files
feuerwerk/tools/e2e/52-hierarchy-groups.mjs
T
adminandClaude Opus 5.5 2eeabf7c23
game-maker / gate (push) Successful in 3m26s
game-maker / suite (push) Failing after 9m12s
Drag a whole selection and join existing groups in the hierarchy (#241)
Three follow-ups to the hierarchy groups. Dragging a row that belonged
to a multi-selection moved only that one row. The group dialog could
only create a new group. The group row drew its folder in front of the
label, unlike every other row in the hierarchy.

- Dragging a selected row moves every selected object. This holds for
  reordering, dropping into a group or between its members, dragging
  out of a group and dropping onto an object as new parent.
- The moved objects land side by side in hierarchy order, whichever
  row was grabbed. The whole move is one undo step.
- A selected child of a selected object travels with its parent, not
  on its own. Dropping the selection onto its own rows does nothing.
- Dragging a row that is not selected still moves only that row.
- hierarchyDrop.moveObjects replaces the single-object rule. One object
  is the case with one element, so the old drop tests hold unchanged.
- The group dialog has a "Group" dropdown: New group or an existing
  group. Picking a group greys out the name and turns the button into
  "Add". A group that holds all chosen objects already is not offered.
- Rename keeps using the dialog without the dropdown.
- ListRow draws an end icon at the very right and the hint left of it.
  Only the hierarchy uses iconAtEnd, so other lists do not change.
- New test hook __gm.hierarchyHint() gives the centre of a row's hint.

Not clicked in the browser: dragging a selection in the container tab
and onto an object as parent. Only the unit tests cover these.

Tests:
- ./check.sh: 178 files, 2564 tests, CHECK EXIT 0
- e2e 1 + 52: ALL PASSED, 27 checks
- 7 new unit tests in hierarchy-drop.test.ts, 4 new e2e checks in
  52i and 52j with real clicks and a real drag
- 4 counter-probes bite: drop rule ignores the selection (5 unit tests
  fail), icon column not kept free, dialog ignores the picked group,
  panel ignores the selection (only o2 lands in the group)

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

245 lines
10 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.
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 fixture's top level is o1, o2, o3, o5; the order rules and
* drops are unit-tested, this checks that the panel and the dialog do what those rules say.
* Everything is taken back by undo, so the fixture leaves as it came.
*/
export async function hierarchyGroups(t) {
const { check, json, evaluate, click, clickText, drag, press, typeText, select, showTab, undo, shot } = t;
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: 'scene1', objectId: 'oc2', objectIds: ['oc2'] });
const child = await rowAt('oc2');
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: 'scene1', objectId: 'o1', objectIds: ['o1', 'o3'] });
const first = await rowAt('o1');
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() === 'o1,o3',
JSON.stringify({ entry, dialogOpened, groups: made.groups }),
);
// o3 is listed with o1 now; with both members selected the group stays folded.
check(
'the group row stands where o1 stood, folded',
same(await rowIds(), [row, 'o2', 'o5']),
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(), ['o1', 'o3']) && same(await rowIds(), [row, 'o2', 'o5']),
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, 'o1', 'o3', 'o2', 'o5']),
JSON.stringify(await rowIds()),
);
await shot('52-group-open');
// 52d. Dragging o5 onto the group row puts it in behind the last member; undo takes it out.
const from = await rowAt('o5');
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, 'o1', 'o3', 'o5', 'o2']) &&
(await scene()).objects.find((o) => o.id === 'o5')?.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(), ['o1', 'o2', 'o3', 'o5']),
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: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2', 'o3'] });
const other = await rowAt('o2');
await click(other.x, other.y);
const narrowedToOther = await selected();
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2', 'o3'] });
const primary = await rowAt('o1');
await click(primary.x, primary.y);
const narrowedToPrimary = await selected();
check(
'clicking a selected row narrows the selection to that row',
same(narrowedToOther, ['o2']) && same(narrowedToPrimary, ['o1']),
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 o2: 52g's last click hit o1, 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('o2');
await clickRow('o5', { shift: true });
const down = await selected();
const primaryAfter = (await json('__gm.state.selection'))?.objectId;
await clickRow('o1', { shift: true });
const up = await selected();
check(
'Shift-click selects the rows in between',
same([...down].sort(), ['o2', 'o3', 'o5']) &&
primaryAfter === 'o5' &&
same([...up].sort(), ['o1', 'o2']) &&
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
JSON.stringify({ down, primaryAfter, up, rows: await rowIds() }),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
// 52i. A group "Pile" of o5 alone; its folder stands in the icon column of the objects, the
// count left of it. Then o1 and o2 join it through the dialog's group dropdown.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o5', objectIds: ['o5'] });
const lone = await rowAt('o5');
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("o1")');
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: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2'] });
const joiner = await rowAt('o1');
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),
['o1', 'o2', 'o5'],
),
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: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2'] });
const grab = await rowAt('o2');
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, ['o5', 'o1', 'o2']) &&
same(
undone.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id),
['o5'],
),
JSON.stringify({ inPile, undone: undone.objects.map((o) => [o.id, o.groupId]) }),
);
await undo();
check(
'undo takes the group of o5 back too',
(await scene()).groups === undefined,
JSON.stringify((await scene()).groups),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
}