Files
feuerwerk/tools/e2e/52-hierarchy-groups.mjs
T
adminandClaude Opus 5.5 893b81f798
game-maker / gate (push) Successful in 3m21s
game-maker / suite (push) Failing after 9m5s
Select a range of rows with shift-click in the hierarchy (#241)
Ctrl- and shift-click only added single rows to the selection. With
many objects spread over the hierarchy, picking a run of them meant
clicking each one. Shift-click now selects everything between the
anchor and the clicked row, as in a file explorer.

- The anchor is the row last clicked without shift, plain or with
  ctrl. A second shift-click redraws the range from the same anchor.
- Ctrl+shift-click adds the range to the current selection instead
  of replacing it.
- A collapsed group inside the range brings all its members. Children
  hidden under a collapsed parent stay out, since they are not shown.
- Without an anchor from the hierarchy, the primary object picked in
  the scene view serves as anchor. With no selection at all,
  shift-click adds the single row as before.
- The range rule is a pure function in hierarchyRange.ts, so it is
  tested without a browser. HierarchyPanel keeps the anchor.

Not checked in the browser: ctrl+shift-click and the anchor taken from
the scene view. Only the code path covers them.

Tests:
- ./check.sh: 178 files, 2557 tests, CHECK EXIT 0
- e2e 1 + 52: ALL PASSED, 23 checks
- 5 new unit tests in tests/editor/hierarchy-range.test.ts
- New e2e check 52h with real clicks
- Counter-check: without the range, 52h fails with ["o2","o5"]

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

173 lines
7.3 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()');
}