Group scene objects in the hierarchy, for the editor only (#241)
A scene with a hundred crates fills the hierarchy with a hundred rows. Groups bundle top-level objects under one folder row that folds away. They exist for the editor only. Parenting, positions and the game do not see them. - Model: a scene or container template holds a list of groups, an object may carry a groupId. Old projects load unchanged. Loading drops empty groups and members that are no longer top level. - Only top-level objects can join a group. Parenting a member takes it out of the group, and Undo puts it back. There are no groups in groups. - Order: treeOrder lists the members together, where the first one stands. The same order decides drawing at equal z, in the editor and in the game, so the hierarchy and the picture cannot disagree. The stored object list stays untouched when grouping. - Hierarchy: a group row shows a folder icon, the name and the member count. Clicking it selects all members and keeps it folded. Selecting a single member elsewhere unfolds it. Folding lasts for the session. - Create with "Group..." in the object menu. A name dialog asks first, so creating is one Undo step and Cancel creates nothing. - The group menu has Select members, Rename... and Ungroup. The object menu adds "Remove from group". - Drag and drop: onto the group row appends, into a gap between members inserts there, next to a row outside leaves the group. Dragging the group row moves all members as a block. The drop rules live in hierarchyDrop.ts as a pure function. - Copy and paste keeps a top-level copy in the group of its original. A container built from group members takes their place in the group. - Clicking an object that is already selected together with others now keeps only that object selected. ListView narrows the selection on release, so a drag starting on such a row keeps the selection. Not checked in the browser: groups in the container tab, "Remove from group", Ctrl-click on a group row and a drag out of a multi-selection. The code path covers them. Tests: - check.sh: 177 files, 2552 tests, CHECK EXIT 0 - e2e 1, 51, 52: ALL PASSED, 31 checks (new module 52) - 19 new unit tests in object-groups.test.ts and hierarchy-drop.test.ts - 9 counter-probes, all fail as expected (7 unit, 2 in the browser) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b89292eb4
commit
53fa63441b
26 files changed
+1174
-83
No files matched your search
@@ -0,0 +1,149 @@
|
||||
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()');
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { pixelArt } from './48-pixelart.mjs';
|
||||
import { users } from './49-users.mjs';
|
||||
import { snapshots } from './50-snapshots.mjs';
|
||||
import { clipboard } from './51-clipboard.mjs';
|
||||
import { hierarchyGroups } from './52-hierarchy-groups.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -107,4 +108,5 @@ export const STEPS = [
|
||||
users,
|
||||
snapshots,
|
||||
clipboard,
|
||||
hierarchyGroups,
|
||||
];
|
||||
Reference in new issue
Block a user