import { same } from '../lib/harness.mjs'; /** * Multi-selection and containers: Ctrl-click, Create container, dragging the group, rubber * band, group move/delete, placing and spawning the container asset, and apply/revert of * instance values against the template and the asset. */ export async function containers(t) { const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, press, undo, mutate, select, state, object, objects, asset, world, play, stop } = t; const center = await json('__gm.viewportCenter()'); const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`); // 16. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy: // both are selected, the inspector shows the multi view. 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.position = { x: -150, y: 0, z: 0 }; o1.parentId = ''; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = ''; objs.find(o => o.id === 'oc2').overrides = { width: 140 }; __gm.state.select(null)`); await clickText('Red crate'); const blueRow = await locate('Blue crate'); await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); let s = await state(); check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1', 'o2']) && s.selection.objectId === 'o2', JSON.stringify(s.selection)); check('inspector shows the multi-selection', await has('2 OBJECTS SELECTED')); await shot('23-multi-selected'); await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); s = await state(); check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']), JSON.stringify(s.selection)); await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); // 16a. Create container: both crates (with their colliders and the Mover) become the content // of a new container asset; the scene gets one container object at the selection's // centre (-15,-40), the crates hang below it with recomputed local positions, world // positions are unchanged, and the container is selected. const assetsBefore = (await json('__gm.state.project.assets')).length; const objectsBefore = (await objects()).length; await clickText('Create container'); await sleep(100); s = await state(); const containerObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const containerAsset = containerObj ? await asset(containerObj.assetId) : null; check( 'Create container adds one container asset and one placed container, selected', containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await json('__gm.state.project.assets')).length === assetsBefore + 1 && (await objects()).length === objectsBefore + 1, JSON.stringify({ containerObj, type: containerAsset?.type }), ); check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }), JSON.stringify(containerObj?.position)); let o1c = await object('o1'); const o2c = await object('o2'); check( 'the crates are re-parented below the container with local positions relative to its centre', o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position }), ); check( 'the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((o) => o.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((o) => o.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])), ); const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())'); const cAt = orderC.indexOf(containerObj?.id); check('hierarchy lists the container with its content indented below', cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC)); check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay'); await shot('24-container-created'); // 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it. const cScreen = await json(`__gm.objectScreen('${containerObj.id}')`); await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y); const containerMoved = await object(containerObj.id); o1c = await object('o1'); check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position })); await undo(); check('undo moves the container back', (await object(containerObj.id)).position.x === -15); // 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks // the objects whose position lies inside – the crate and its collider, not the Mover // (at y = 110), not the camera (at the origin). await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50); s = await state(); check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']), JSON.stringify(s.selection)); await shot('25-marquee'); // 16d. Dragging one of several selected objects moves all of them as one undo step. const redScreen = await json(`__gm.objectScreen('o1')`); await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20); o1c = await object('o1'); const o2d = await object('o2'); check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position })); await undo(); o1c = await object('o1'); check('one undo restores both', o1c.position.x === -135 && (await object('o2')).position.x === 135); // 16e. Delete with a multi-selection removes all of them (with subtrees) as one step. const objectsBeforeDelete = (await objects()).length; await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await press('Delete'); await sleep(150); check('Delete removes the whole multi-selection with its subtrees', (await objects()).length === objectsBeforeDelete - 5); await undo(); check('undo restores all of them', (await objects()).length === objectsBeforeDelete); // 16f. Placing the container asset again from the asset panel creates a second instance: // the root plus 5 content objects with fresh ids, the crate copy at the same offset. await clickText(containerAsset.name, 'right'); await clickText('Place in scene'); await sleep(100); const afterPlace = await objects(); const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id); const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId); check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate })); await shot('26-container-placed'); // 16f-ii. Spawning the container at runtime instantiates its content the same way: point the // Spawner at the container asset, press Space while playing – a whole instance appears // in the runtime copy and in the world, and Stop throws it away with the rest. const spawnParam = (assetId) => mutate(`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`); await spawnParam(containerAsset.id); const beforeSpawn = await objects(); await play(); await press('Space'); await sleep(250); const fresh = (await objects()).filter((o) => !beforeSpawn.some((b) => b.id === o.id)); const spawnedRoot = fresh.find((o) => o.parentId === ''); check( 'Spawn of a container asset creates the container object plus its content', fresh.length === 6 && spawnedRoot?.assetId === containerAsset.id && fresh.filter((o) => o.templateId).length === 5, JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))), ); const spawnWorld = await world(); check('the spawned instance lives in the world and its name reached the Log node', spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) })); await stop(); check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length); await spawnParam('crate'); // 16g. Apply / revert on a container instance. The red crate (o1) is part of the first // container; the second instance has an unmodified copy of it. Moving o1 on x shows // ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes // both back; ↺ puts o1 back to the template value. const templateA = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === o1c.templateId); await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' }); await sleep(200); check('inspector names the container the object is part of', await has(containerAsset.name)); check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺'))); await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100`); const applyButton = await evaluate(`__gm.locate('↑')`); check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await has('↺'))); await shot('27-apply-buttons'); await click(applyButton.x, applyButton.y); await sleep(100); check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position })); check('apply hides the buttons again', !(await has('↑'))); await undo(); check('undo restores template and copy', (await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135); const revertButton = await evaluate(`__gm.locate('↺')`); await click(revertButton.x, revertButton.y); await sleep(100); check('revert puts the instance back to the template position', (await object('o1')).position.x === -135); // 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset". await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77`); const applyMenu = await evaluate(`__gm.locate('↑ ▾')`); check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu)); await click(applyMenu.x, applyMenu.y); check('the menu offers container and asset', (await has('Apply to container')) && (await has('Apply to asset'))); await shot('28-apply-menu'); await clickText('Apply to asset'); await sleep(100); const crateAfterApply = (await asset('crate')).props; check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply)); await undo(); check('undo restores asset and override', (await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77); await click(applyMenu.x, applyMenu.y); await clickText('Apply to container'); await sleep(100); check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77); // 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases // the value really differs from are offered, so "Reset to container" is left out and the // menu collapses to a direct ↺. Deviating from both brings the menu back. await sleep(200); check('a reset that would change nothing is left out – a direct button instead of a menu', (await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾'))); await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55`); const resetMenu = await evaluate(`__gm.locate('↺ ▾')`); check('deviating from template and asset brings the reset menu back', Boolean(resetMenu)); await click(resetMenu.x, resetMenu.y); check('the reset menu offers container and asset', (await has('Reset to container')) && (await has('Reset to asset'))); await shot('28b-reset-menu'); await clickText('Reset to container'); await sleep(100); check('Reset to container puts the override back on the template value', (await object('o1')).overrides.width === 77); const singleReset = await evaluate(`__gm.locate('↺')`); check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await has('↺ ▾'))); await click(singleReset.x, singleReset.y); await sleep(100); check('↺ drops the override so the object follows the asset again', (await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77); // Dropping the override left the object on the asset value (100) while the template still // says 77 – that is a real deviation, so exactly one entry remains: "Reset to container". check('the remaining deviation from the template keeps a single button, not a menu', (await has('↺')) && !(await has('↺ ▾'))); await undo(); check('undo restores the override', (await object('o1')).overrides.width === 77); }