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