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>
245 lines
10 KiB
JavaScript
245 lines
10 KiB
JavaScript
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()');
|
||
}
|