import { instanceChild } from '../lib/builders.mjs'; /** * The Container tab: editing a template with the scene view's tools. Relies on the container step * having left two instances of a two-crate container in the scene. */ const NAME = 'Crate pair'; export async function containerTab(t) { const { check, sleep, json, evaluate, mutate, select, shot, click, clickText, drag, dragAsset, mouse, press, undo, state, object, objects, storedObjects, } = t; await press('Escape'); const containerId = await json("(__gm.state.project.assets.find(a => a.type === 'container') ?? {}).id"); // The overflow step renames assets to absurd lengths; a readable name makes the header checkable. await evaluate( `(() => { __gm.state.project.assets.find(a => a.id === ${JSON.stringify(containerId)}).name = ${JSON.stringify(NAME)}; __gm.state.preview(); })()`, ); await sleep(200); // 16a. The tab opens on the container the project has, and its header says which one. await clickText('Container'); await sleep(500); const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`); const count = template.length; check( 'the Container tab opens showing a container', (await json('__gm.tab()')) === 'container' && (await json('__gm.container.current()')) === containerId, JSON.stringify({ tab: await json('__gm.tab()'), shown: await json('__gm.container.current()') }), ); check( 'the header names the container and counts its template objects', Boolean(await evaluate(`__gm.locate(${JSON.stringify(NAME)})`)) && Boolean(await evaluate(`__gm.locate(${JSON.stringify(`${count} objects`)})`)), JSON.stringify({ name: NAME, count }), ); check( 'the hierarchy panel retitles itself for the container it lists', // A panel draws its title in capitals, so that is what there is to find. Boolean(await evaluate(`__gm.locate(${JSON.stringify(`Container: ${NAME}`.toUpperCase())})`)), ); await shot('30-container-tab'); // 16b. The hierarchy follows the visible tab: it lists the template, not the scene. const rows = await json('__gm.hierarchyOrder()'); check( 'the hierarchy lists the template objects while the tab is open', rows.length > 0 && rows.every((id) => template.some((o) => o.id === id)), JSON.stringify(rows), ); // 16c. Clicking a template object selects it against the container as its holder – the same // id set as the scene would give, but a different holder. const root = template.find((o) => o.parentId === ''); const at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`); await click(at.x, at.y); let s = await state(); check( 'clicking a template object selects it against the container, not the scene', s.selection?.kind === 'object' && s.selection.holderId === containerId && s.selection.objectId === root.id, JSON.stringify(s.selection), ); // 16d. The point of the tab: a drag writes the template, and every instance follows it – except // one that was moved there on its own, whose deviation the instance keeps. const templateX = () => json( `__gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}').position.x`, ); // The instances resolve this template object under `~${root.id}`; nothing of it is // stored, so the resolved list is the only place to read it. const instanceXs = () => json( `Object.fromEntries(__gm.sceneObjects().filter(o => o.id.endsWith('~${root.id}')).map(o => [o.id, o.position.x]))`, ); const shown = await instanceXs(); const [dirtyId, ...cleanIds] = Object.keys(shown); const dirtyInstance = dirtyId.slice(0, dirtyId.indexOf('~')); const setDeviation = (body) => mutate( `const edits = __gm.state.activeScene.objects.find(o => o.id === '${dirtyInstance}').instanceEdits; ${body}`, ); await setDeviation(`edits['${root.id}'] = { position: { x: ${shown[dirtyId] + 7} }, overrides: {} }`); const before = await templateX(); await drag(at.x, at.y, at.x + 90, at.y); const after = await templateX(); const afterDrag = await instanceXs(); check( 'dragging in the Container tab moves the template', after === before + 90, JSON.stringify({ before, after }), ); check( 'the instances that still matched the template follow it', cleanIds.length > 0 && cleanIds.every((id) => afterDrag[id] === shown[id] + 90), JSON.stringify({ shown, afterDrag, cleanIds }), ); check( 'the instance that was moved on its own keeps its own position', afterDrag[dirtyId] === shown[dirtyId] + 7, JSON.stringify({ was: shown[dirtyId], now: afterDrag[dirtyId] }), ); await undo(); const undone = await instanceXs(); check('undo takes the template move back', (await templateX()) === before); check( 'and takes the instances back with it', cleanIds.every((id) => undone[id] === shown[id]), JSON.stringify({ shown, undone }), ); await setDeviation(`delete edits['${root.id}']`); // 16e. An asset dragged from the panel lands in the view it is dropped on: dropping on this // tab adds one object to the template, not to the scene. // What the scene *stores* – that must not move. What it *lists* has to grow by one per placed // instance instead, since every one of them resolves the new template object too. const sceneStored = (await storedObjects()).length; const sceneListed = (await objects()).length; const instanceCount = Object.keys(await instanceXs()).length; const canvas = await json('__gm.container.center()'); await dragAsset('crate', canvas.x + 40, canvas.y + 40); const grown = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`); s = await state(); check( 'Place in container puts one object into the template and selects it there', grown.length === count + 1 && s.selection?.holderId === containerId && grown.some((o) => o.id === s.selection.objectId), JSON.stringify({ before: count, after: grown.length, selection: s.selection }), ); check('the scene stores nothing more for it', (await storedObjects()).length === sceneStored); check( 'but every placed instance shows the new template object at once', (await objects()).length === sceneListed + instanceCount, JSON.stringify({ listed: (await objects()).length, was: sceneListed, instances: instanceCount }), ); await shot('31-container-object-added'); // 16f. Delete works on a template object like on any other, as one undo step. await press('Delete'); await sleep(250); check( 'Delete removes a template object', (await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length === count, ); await undo(); check( 'undo puts it back', (await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length === count + 1, ); await press('Delete'); await sleep(250); // 16g. Fit frames the content. Proven against an object parked far outside the view: without // fitting it is nowhere near the canvas. await evaluate( `(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = 3000; __gm.state.preview(); })()`, ); await sleep(300); const area = await json('__gm.container.rect()'); const inside = (p) => p.x >= area.x && p.x <= area.x + area.w && p.y >= area.y && p.y <= area.y + area.h; const far = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`); await clickText('Fit'); await sleep(300); const fitted = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`); check( 'Fit brings an object parked far outside back into view', !inside(far) && inside(fitted), JSON.stringify({ area, far, fitted }), ); await evaluate( `(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = ${before}; __gm.state.preview(); })()`, ); await clickText('Fit'); await sleep(300); await shot('32-container-fitted'); // 16h. A template object exists only in this tab, so selecting one from elsewhere switches to // it – the rule a graph node and a texture frame already follow. A scene object does not. await evaluate("__gm.showTab('scene')"); await sleep(300); await evaluate( `__gm.state.select({ kind: 'object', holderId: ${JSON.stringify(containerId)}, objectId: ${JSON.stringify(root.id)} })`, ); await sleep(300); check( 'selecting a template object switches to the Container tab', (await json('__gm.tab()')) === 'container', ); await evaluate("__gm.showTab('scene')"); await sleep(300); await evaluate("__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'otex' })"); await sleep(300); check('selecting a scene object leaves the Scene tab alone', (await json('__gm.tab()')) === 'scene'); check( 'the hierarchy is back on the scene with it', (await json('__gm.hierarchyOrder()')).includes('otex'), JSON.stringify(await json('__gm.hierarchyOrder()')), ); // 16i. Switching to another container is a plain asset selection – the same thing the header's // picker does. The hierarchy has to follow it, title and rows both. await evaluate("__gm.showTab('container')"); await sleep(300); await mutate(`const src = __gm.state.project.assets.find(a => a.id === '${containerId}'); const copy = JSON.parse(JSON.stringify(src)); copy.id = 'c2'; copy.name = 'Spare box'; copy.objects = copy.objects.slice(0, 1).map((o) => ({ ...o, id: 'c2o1', parentId: '' })); __gm.state.project.assets.push(copy)`); await select({ kind: 'asset', assetId: 'c2' }); await sleep(400); check( 'picking another container moves the tab and the hierarchy to it', (await json('__gm.container.current()')) === 'c2' && JSON.stringify(await json('__gm.hierarchyOrder()')) === JSON.stringify(['c2o1']) && Boolean(await evaluate("__gm.locate('CONTAINER: SPARE BOX')")), JSON.stringify({ shown: await json('__gm.container.current()'), rows: await json('__gm.hierarchyOrder()'), }), ); await shot('33-container-switched'); // 22i2. Dragging the open container onto its own template would nest it in itself. The drag // has to say so while it hovers – an outline that only turns out to be a lie on release // is worse than none – and the drop has to change nothing. const canvas2 = await json('__gm.container.center()'); const own = await json("__gm.assetRow('c2')"); await mouse('mouseMoved', own.x, own.y); await mouse('mousePressed', own.x, own.y); for (let i = 1; i <= 8; i++) { await mouse( 'mouseMoved', own.x + ((canvas2.x - own.x) * i) / 8, own.y + ((canvas2.y - own.y) * i) / 8, 'left', { buttons: 1, }, ); await sleep(16); } const refused = await json('__gm.container.dropPreview()'); check( 'a container dragged over its own template is refused while it hovers', Boolean(refused) && /nests forever/.test(refused.rejection ?? ''), JSON.stringify(refused), ); // Picking it up selects it; the tab must not follow that, or the target would swap mid-drag. check( 'and the tab keeps showing the container being dropped into', (await json('__gm.container.current()')) === 'c2', JSON.stringify({ shown: await json('__gm.container.current()') }), ); await shot('33b-container-cycle-refused'); const ownBefore = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects"); await mouse('mouseReleased', canvas2.x, canvas2.y); await sleep(200); const ownAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects"); check( 'releasing it changes nothing', ownAfter.length === ownBefore.length, JSON.stringify({ before: ownBefore.length, after: ownAfter.length }), ); // 16j. Nesting: another container, though, may go in – and the tab it lands in stays put. const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects"); await dragAsset(containerId, canvas2.x - 40, canvas2.y + 40); const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects"); check( 'and placing it nests it inside that one', nestedAfter.length === nested.length + 1 && nestedAfter.some((o) => o.assetId === containerId) && (await json('__gm.container.current()')) === 'c2', JSON.stringify({ before: nested.length, after: nestedAfter.length }), ); // 16k. Nesting resolves all the way down: placing the outer container in the scene stores one // object, and the inner container's content appears under `~/`. const innerObject = nestedAfter.find((o) => o.assetId === containerId); const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`); await evaluate("__gm.showTab('scene')"); await sleep(300); const storedBefore = (await storedObjects()).length; const listedBefore = (await objects()).length; const spot = await json('__gm.viewportCenter()'); await dragAsset('c2', spot.x + 80, spot.y + 70); const outer = (await storedObjects()).find((o) => o.assetId === 'c2'); const deep = innerTemplate.map((o) => instanceChild(outer.id, innerObject.id, o.id)); const listedIds = (await objects()).map((o) => o.id); check( 'a container nested in a container template resolves through both levels', (await storedObjects()).length === storedBefore + 1 && (await objects()).length === listedBefore + 1 + nestedAfter.length + innerTemplate.length && deep.every((id) => listedIds.includes(id)), JSON.stringify({ stored: (await storedObjects()).length - storedBefore, listed: (await objects()).length - listedBefore, deep: deep.filter((id) => !listedIds.includes(id)), }), ); check( 'and the nested content hangs below the nested container object', (await object(deep[0])).parentId === instanceChild(outer.id, innerObject.id), JSON.stringify(await object(deep[0])), ); await shot('34-container-nested'); await undo(); // the placement await undo(); // the nesting await sleep(200); }