import { same } from '../lib/harness.mjs'; /** * Hierarchy / parenting. Gives both crates unique names (later steps locate them by these), * then parents by drag, by the inspector menu and unparents from the context menu – the world * position is kept every time. */ export async function parenting(t) { const { check, json, shot, click, clickText, drag, locate, typeText, press, undo, mutate, select, object, entity, stop, } = t; // 7a. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world // position (local becomes the difference) and is listed right below its parent. await mutate(` const objs = __gm.state.activeScene.objects; const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2'); o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 }; o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = ''; __gm.state.select(null)`); const redRow = await locate('Red crate'); const blueRow = await locate('Blue crate'); await drag(blueRow.x, blueRow.y, redRow.x, redRow.y); let o2 = await object('o2'); check( 'dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2), ); const order = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())'); // o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider. const o1At = order.indexOf('o1'); check( 'children are listed directly below their parent', same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order), ); await shot('20-parented'); // 7b. Moving the parent (X field of the red crate) drags the child along in world space. await clickText('Red crate'); const xField = await locate('X'); await click(xField.x + 160, xField.y); await press('a', { ctrl: true }); await typeText('-50'); await press('Enter'); o2 = await object('o2'); const parent = await object('o2').then((o) => object(o.parentId)); const worldOfO2 = { x: o2.position.x + parent.position.x, y: o2.position.y + parent.position.y }; check( 'moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }), `${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`, ); // 7c. Play: entities mirror the hierarchy; undo restores parent and position. await select(null); await press('Enter', { ctrl: true }); const e2 = await entity('o2'); check( 'play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2), ); await stop(); await undo(); // move await undo(); // parent o2 = await object('o2'); check( 'undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2), ); // 7d. Parent via the inspector menu: the picker is searchable, Enter takes the single match. await clickText('Blue crate'); await clickText('None'); // the Parent dropdown, labelled with the current parent const entries = await json('__gm.overlayTexts()'); check( 'the parent picker offers a search box and lists the candidates', entries.includes('Search…') && entries.includes('None') && entries.includes('Red crate'), JSON.stringify(entries), ); await typeText('red'); const filtered = await json('__gm.overlayTexts()'); check( 'typing filters the picker down to the matching entries', same(filtered, ['red', 'Red crate']), JSON.stringify(filtered), ); await shot('30-parent-search'); await press('Enter'); // takes the first match o2 = await object('o2'); check( 'inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2), ); check('the picker closed after Enter', same(await json('__gm.overlayTexts()'), [])); const blueRow2 = await locate('Blue crate'); await click(blueRow2.x, blueRow2.y, 'right'); await clickText('Unparent (move to top level)'); o2 = await object('o2'); check( 'Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2), ); // 7e. The gap above a row is a different drop than the row itself: it orders the dragged // object among that row's siblings instead of putting it inside, and moves nothing. const rowsBefore = await json('__gm.hierarchyOrder()'); check( 'the blue crate is listed after the red one to begin with', rowsBefore.indexOf('o2') > rowsBefore.indexOf('o1'), JSON.stringify(rowsBefore), ); const redRow3 = await locate('Red crate'); const blueRow3 = await locate('Blue crate'); // 10 px above the centre of a 26 px row is its top quarter – the gap, not the row. await drag(blueRow3.x, blueRow3.y, redRow3.x, redRow3.y - 10); const rowsAfter = await json('__gm.hierarchyOrder()'); o2 = await object('o2'); check( 'dropping into the gap reorders instead of parenting', rowsAfter.indexOf('o2') < rowsAfter.indexOf('o1') && o2.parentId === '', JSON.stringify({ rowsAfter, parentId: o2.parentId }), ); check( 'and a reorder moves nothing in the world', o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2.position), ); await shot('31-reordered'); await undo(); check( 'undo puts the order back', same(await json('__gm.hierarchyOrder()'), rowsBefore), JSON.stringify(await json('__gm.hierarchyOrder()')), ); }