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 order rules and drops are unit-tested, this checks that the * panel and the dialog do what those rules say. * It works in a scene of its own, whose top level is g1, g2 (with the collider gc2), g3 and g5: the * checks compare whole lists of rows, and in the full run the fixture's scene holds whatever the * modules before left there. The scene is removed at the end. */ export async function hierarchyGroups(t) { const { check, json, evaluate, click, clickText, drag, press, typeText, select, showTab, undo, shot } = t; const SCENE = 'groups-52'; const home = await json('__gm.state.project.activeSceneId'); await t.mutate(` const p = __gm.state.project; const base = __gm.state.activeScene.objects[0]; const make = (id, assetId, parentId, x, name) => { const o = structuredClone(base); Object.assign(o, { id, assetId, parentId, name, position: { x, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); delete o.groupId; if ('enabled' in o) o.enabled = true; return o; }; p.assets.push({ id: ${JSON.stringify(SCENE)}, type: 'scene', name: 'Groups 52', props: { backgroundColor: 0x22252c }, objects: [ make('g1', 'crate', '', -200, 'Crate 1'), make('g2', 'crate', '', 0, 'Crate 2'), make('gc2', 'collider', 'g2', 0, 'Collider'), make('g3', 'crate', '', 200, 'Crate 3'), make('g5', 'mover', '', 300, 'Mover'), ] }); p.activeSceneId = ${JSON.stringify(SCENE)}`); 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: SCENE, objectId: 'gc2', objectIds: ['gc2'] }); const child = await rowAt('gc2'); 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: SCENE, objectId: 'g1', objectIds: ['g1', 'g3'] }); const first = await rowAt('g1'); 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() === 'g1,g3', JSON.stringify({ entry, dialogOpened, groups: made.groups }), ); // g3 is listed with g1 now; with both members selected the group stays folded. check( 'the group row stands where g1 stood, folded', same(await rowIds(), [row, 'g2', 'g5']), 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(), ['g1', 'g3']) && same(await rowIds(), [row, 'g2', 'g5']), 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, 'g1', 'g3', 'g2', 'g5']), JSON.stringify(await rowIds()), ); await shot('52-group-open'); // 52d. Dragging g5 onto the group row puts it in behind the last member; undo takes it out. const from = await rowAt('g5'); 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, 'g1', 'g3', 'g5', 'g2']) && (await scene()).objects.find((o) => o.id === 'g5')?.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(), ['g1', 'g2', 'g3', 'g5']), 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: SCENE, objectId: 'g1', objectIds: ['g1', 'g2', 'g3'] }); const other = await rowAt('g2'); await click(other.x, other.y); const narrowedToOther = await selected(); await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2', 'g3'] }); const primary = await rowAt('g1'); await click(primary.x, primary.y); const narrowedToPrimary = await selected(); check( 'clicking a selected row narrows the selection to that row', same(narrowedToOther, ['g2']) && same(narrowedToPrimary, ['g1']), 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 g2: 52g's last click hit g1, 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('g2'); await clickRow('g5', { shift: true }); const down = await selected(); const primaryAfter = (await json('__gm.state.selection'))?.objectId; await clickRow('g1', { shift: true }); const up = await selected(); check( 'Shift-click selects the rows in between', same([...down].sort(), ['g2', 'g3', 'g5']) && primaryAfter === 'g5' && same([...up].sort(), ['g1', 'g2']) && same(await rowIds(), ['g1', 'g2', 'g3', 'g5']), JSON.stringify({ down, primaryAfter, up, rows: await rowIds() }), ); await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); // 52i. A group "Pile" of g5 alone; its folder stands in the icon column of the objects, the // count left of it. Then g1 and g2 join it through the dialog's group dropdown. await select({ kind: 'object', holderId: SCENE, objectId: 'g5', objectIds: ['g5'] }); const lone = await rowAt('g5'); 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("g1")'); 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: SCENE, objectId: 'g1', objectIds: ['g1', 'g2'] }); const joiner = await rowAt('g1'); 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), ['g1', 'g2', 'g5'], ), 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: SCENE, objectId: 'g1', objectIds: ['g1', 'g2'] }); const grab = await rowAt('g2'); 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, ['g5', 'g1', 'g2']) && same( undone.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id), ['g5'], ), JSON.stringify({ inPile, undone: undone.objects.map((o) => [o.id, o.groupId]) }), ); await undo(); check( 'undo takes the group of g5 back too', (await scene()).groups === undefined, JSON.stringify((await scene()).groups), ); await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); await t.mutate(` const p = __gm.state.project; p.activeSceneId = ${JSON.stringify(home)}; p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(SCENE)})`); await t.saved(); }