import { colourDistance } from '../lib/harness.mjs'; import { pickAssetType } from './lib/scaffold.mjs'; /** One per entry of `ASSET_TYPES` – a new asset type counts here too. */ const ASSET_TYPE_COUNT = 33; /** The categories of the asset panel and the `+` menu, in their order (`ASSET_CATEGORIES`). */ const CATEGORIES = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio']; /** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */ export async function assets(t) { const { check, sleep, evaluate, json, shot, click, doubleClick, clickText, mouse, mutate, press, redo, undo, select, objects, asset, showTab, has, } = t; // 4a. Context menu on the Crate asset. Placing left the menu when it became a drag. The groups // start folded (#176) – the hook unfolds Crate's. await json("__gm.assetRow('crate')"); await clickText('Crate', 'right'); check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`))); check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`))); await shot('11-context-menu'); await clickText('Duplicate'); const copy = await json(`(() => { const assets = __gm.state.project.assets; const c = assets[assets.findIndex((a) => a.id === 'crate') + 1]; const s = __gm.state.selection; return c ? { name: c.name, type: c.type, open: s?.kind === 'asset' && s.assetId === c.id } : null; })()`); check( 'Duplicate puts "Crate 2" right behind the Crate and opens it', copy?.name === 'Crate 2' && copy.type === 'sprite' && copy.open, JSON.stringify(copy), ); await undo(); check( 'one undo takes the copy back out', !(await json("__gm.state.project.assets.some((a) => a.name === 'Crate 2')")), ); await showTab('scene'); await addMenuOnRightClick(t); await emptyGroupsOnSwitch(t); // 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than // with `drag()`, because the preview only exists while the button is still down. const from = await json("__gm.assetRow('crate')"); const centre = await json('__gm.viewportCenter()'); const to = { x: centre.x + 70, y: centre.y - 50 }; await mouse('mouseMoved', from.x, from.y); await mouse('mousePressed', from.x, from.y); for (let i = 1; i <= 8; i++) { const x = from.x + ((to.x - from.x) * i) / 8; const y = from.y + ((to.y - from.y) * i) / 8; await mouse('mouseMoved', x, y, 'left', { buttons: 1 }); await sleep(16); } const preview = await json('__gm.dropPreview()'); check( 'dragging an asset over the view previews where it would land', Boolean(preview) && preview.assetId === 'crate' && preview.rejection === '', JSON.stringify(preview), ); await shot('11b-drop-preview'); await mouse('mouseReleased', to.x, to.y); await sleep(200); const count = (await objects()).length; check('dropping the asset adds an object', count === 8, `objects=${count}`); const placed = await json( '(() => { const id = __gm.state.selectedObjectIds[0]; return __gm.sceneObjects().find(o => o.id === id) ?? null; })()', ); // The preview is a promise: whatever it drew has to be where the object then sits, or the // outline was decoration rather than a prediction. check( 'the object lands where the preview promised, and is selected', Boolean(placed) && placed.assetId === 'crate' && placed.position.x === preview.x && placed.position.y === preview.y, JSON.stringify({ placed: placed && placed.position, preview }), ); check('the preview is gone once the asset is placed', (await json('__gm.dropPreview()')) === null); await undo(); const afterUndo = (await objects()).length; check('one undo takes the dropped object back out', afterUndo === 7, `objects=${afterUndo}`); await redo(); check('redo puts it back', (await objects()).length === 8); // 4c. Create a new asset through the + menu. The menu lists the categories of the asset panel, in // the panel's order; each opens the types filed under it, sorted by label, beside the menu. await clickText('+'); const rows = await json('__gm.menuRows()'); const opens = await json('__gm.menuSubmenus()'); const menuBox = await json('__gm.overlayRect()'); const title = await json('__gm.menuTitle()'); check( 'the + menu lists the categories of the asset panel, each opening a submenu, under "Create asset"', String(rows) === String(CATEGORIES) && opens.every(Boolean) && title === 'Create asset', JSON.stringify({ rows, opens, title }), ); const offered = {}; for (const category of CATEGORIES) { await clickText(category); offered[category] = await json('__gm.menuRows()'); } const flat = Object.values(offered).flat(); check( 'every asset type is offered once, each category sorted by label', flat.length === ASSET_TYPE_COUNT && new Set(flat).size === flat.length && Object.values(offered).every((list) => String(list) === String([...list].sort())), JSON.stringify(offered), ); check( 'a type sits under the category the asset panel files it under', offered.Basics.includes('Scene') && offered.Visuals.includes('Sprite') && offered.Logics.includes('Behavior') && offered.Physics.includes('Collider') && offered.Audio.includes('Song'), JSON.stringify(offered), ); // The submenu opens right of the menu, its first entry level with the row that opened it. The // pointer resting on another category swaps it after a short wait. await clickText('Visuals'); const visualsRow = await json("__gm.locate('Visuals')"); const subBox = await json('__gm.overlayRect()'); const firstEntry = await json(`__gm.locate(${JSON.stringify(offered.Visuals[0])}, true)`); await shot('tmp-plus-menu'); const screenH = await json('__gm.app.screen.height'); check( 'the submenu stands right of the menu, its first entry level with its category, all on screen', subBox.x >= menuBox.x + menuBox.w - 4 && Math.abs(firstEntry.y - visualsRow.y) <= 2 && subBox.y + subBox.h <= screenH - Math.round(screenH * 0.1) + 1, JSON.stringify({ menuBox, subBox, visualsRow, firstEntry, screenH }), ); const audioRow = await json("__gm.locate('Audio')"); await mouse('mouseMoved', audioRow.x, audioRow.y); await sleep(400); const hovered = await json('__gm.menuRows()'); check( 'resting the pointer on another category swaps the submenu', String(hovered) === String(offered.Audio), JSON.stringify(hovered), ); await clickText('Visuals'); await clickText('Sprite'); const names = await json('__gm.state.project.assets.map(a => a.name)'); const leftOpen = await json('__gm.menuRows()'); check( '+ menu creates a sprite asset, and picking it closes menu and submenu', names.includes('Sprite') && leftOpen === null, JSON.stringify({ names, leftOpen }), ); await shot('12-new-asset'); // 4d. Double-click on a scene row: the one thing activating an asset still does since placing // became a drag. The second scene goes in through the model – the "+" menu's "Scene" entry // shares its label with the Scene tab, so a click by text could land on either. await mutate( `__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`, ); const secondScene = await json("__gm.assetRow('scene2')"); await evaluate('__gm.lookAt(5000, 5000)'); const pannedAway = await json(`__gm.objectRect('o1')`); await showTab('graph'); await doubleClick(secondScene.x, secondScene.y); check( 'double-clicking a scene asset makes it the active scene', (await json('__gm.state.project.activeSceneId')) === 'scene2', await json('__gm.state.project.activeSceneId'), ); check('and opens the Scene tab', (await json('__gm.tab()')) === 'scene', await json('__gm.tab()')); const firstScene = await json("__gm.assetRow('scene1')"); await doubleClick(firstScene.x, firstScene.y); check( 'double-clicking the first one switches back', (await json('__gm.state.project.activeSceneId')) === 'scene1', await json('__gm.state.project.activeSceneId'), ); // An opened scene comes up framed, as if Fit had been clicked (#295) – not where the view stood. const opened = await json(`__gm.objectRect('o1')`); const fitAt = await json(`__gm.sceneHeaderAt('fit')`); await click(fitAt.x, fitAt.y); const fitted = await json(`__gm.objectRect('o1')`); const sameRect = (a, b) => Boolean(a && b) && ['x', 'y', 'w', 'h'].every((k) => Math.abs(a[k] - b[k]) < 0.01); check( 'a scene that is opened comes up framed like after a click on Fit', sameRect(opened, fitted) && !sameRect(opened, pannedAway), JSON.stringify({ pannedAway, opened, fitted }), ); await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`); // 4d′. Opening an asset: a click on its row only selects, a double-click opens the tab it // lives in – every type that has one. The fixture lacks five of them; they go in through // the model, empty. await mutate(`__gm.state.project.assets.push( { id: 'sm-open', type: 'statemachine', name: 'Machine', props: {}, graph: { nodes: [], edges: [] } }, { id: 'tex-open', type: 'texture', name: 'Sheet', props: {}, file: '', width: 0, height: 0, frames: [] }, { id: 'anim-open', type: 'animation', name: 'Walk', props: {}, root: '', tracks: [] }, { id: 'box-open', type: 'container', name: 'Box', props: {}, objects: [] }, { id: 'at-open', type: 'tile', name: 'Grass', props: {}, pictures: [] }, )`); const openable = [ ['mover', 'graph', '__gm.graph.current()'], ['sm-open', 'statemachine', '__gm.stateMachine.current()'], ['adjust-colors', 'shader', '__gm.shader.current()'], ['tex-open', 'texture', '__gm.texture.current()'], ['anim-open', 'animation', '__gm.animation.current()'], ['box-open', 'container', '__gm.container.current()'], ['at-open', 'tile', '__gm.tile.current()'], ]; for (const [id, tab, shown] of openable) { await showTab('scene'); const row = await json(`__gm.assetRow(${JSON.stringify(id)})`); await click(row.x, row.y); await sleep(500); // past the double-click window, so the next press starts a fresh pair const clicked = { tab: await json('__gm.tab()'), selected: (await json('__gm.state.selection'))?.assetId, }; check( `a click on the ${tab} asset selects it and leaves the Scene tab alone`, clicked.tab === 'scene' && clicked.selected === id, JSON.stringify(clicked), ); await doubleClick(row.x, row.y); const opened = { tab: await json('__gm.tab()'), shown: await json(shown) }; check( `a double-click opens the ${tab} tab on it`, opened.tab === tab && opened.shown === id, JSON.stringify(opened), ); } // Enter goes the same way (`onActivate`), but only once the list holds keyboard focus, which a // click does not give it – so it is not driven here. await showTab('scene'); await clickText('+'); await pickAssetType(t, 'Behavior'); const created = await json( "(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''", ); const fresh = { created, tab: await json('__gm.tab()'), shown: await json('__gm.graph.current()') }; check( 'creating an asset through + opens its tab on the new one', Boolean(created) && fresh.tab === 'graph' && fresh.shown === created, JSON.stringify(fresh), ); await undo(); await mutate( `__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open', 'at-open'].includes(a.id))`, ); await showTab('scene'); // 4e. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour, // so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's // reset is the first on screen and colour's the last. await select({ kind: 'asset', assetId: 'crate' }); const heightReset = await evaluate(`__gm.locateIcon('reset')`); const colorReset = await evaluate(`__gm.locateIcon('reset', true)`); check('asset inspector shows a reset per deviating property', Boolean(heightReset)); // The reset is a picture since #214, no longer a character from the system font: its circle // passes 4.8 units right of its centre, drawn in the muted text colour. const onResetRing = await json(`__gm.pixelAt(${heightReset.x + 4}, ${heightReset.y + 1})`); const muted = await json("__gm.themeColor('textMuted')"); check( 'the reset button draws its picture', colourDistance(onResetRing, muted) < 40, JSON.stringify({ onResetRing, muted }), ); check( 'the two deviations have two separate resets', Boolean(colorReset) && colorReset.y > heightReset.y, JSON.stringify({ heightReset, colorReset }), ); await click(heightReset.x, heightReset.y); check( 'reset puts the asset property back to its default', (await asset('crate')).props.height === 100, `height=${(await asset('crate')).props.height}`, ); const remaining = await evaluate(`__gm.locateIcon('reset')`); check( 'the reset of the property now at its default is gone, the other one stays', Boolean(remaining) && remaining.y === colorReset.y && remaining.y === (await evaluate(`__gm.locateIcon('reset', true)`)).y, JSON.stringify({ remaining, colorReset }), ); await shot('15-asset-reset'); await undo(); check( 'undo restores the asset value', (await asset('crate')).props.height === 60, `height=${(await asset('crate')).props.height}`, ); check( 'the height reset is back after undo', (await evaluate(`__gm.locateIcon('reset')`))?.y === heightReset.y, JSON.stringify(await evaluate(`__gm.locateIcon('reset')`)), ); // 4f. The same gold in the asset list: a row is marked while the asset carries anything the // inspector marks. The Crate deviates twice, so it takes two resets to turn it plain. const GOLD = '#f2c84b'; const PLAIN = '#e6e8ee'; const assetColor = (id) => json(`__gm.assetRowColor('${id}')`); check('a deviating asset is marked gold in the list', (await assetColor('crate')) === GOLD); check('an asset at its defaults is not', (await assetColor('cam')) === PLAIN, await assetColor('cam')); // The Mover's `speed` is 200 against a declared variable default of 0. The inspector marks no // variable, so neither may the list – the two must say the same thing. check( 'a behavior is not marked for a variable value, which the inspector does not mark either', (await assetColor('mover')) === PLAIN, await assetColor('mover'), ); await click(heightReset.x, heightReset.y); check( 'the mark stays while the second deviation is left', (await assetColor('crate')) === GOLD, await assetColor('crate'), ); const colorOnly = await evaluate(`__gm.locateIcon('reset')`); await click(colorOnly.x, colorOnly.y); check( 'resetting the last deviation clears the mark, without a reload', (await assetColor('crate')) === PLAIN, `${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`, ); await shot('15b-asset-marks'); await undo(); await undo(); check( 'undo brings the mark back', (await assetColor('crate')) === GOLD, `${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`, ); // 4g. The list is grouped by category and in it by type (#176): a heading per category, under it // a heading per type, groups and the assets in them alphabetical, each type heading with an // icon of its own. A freshly opened project shows every category open and every group folded // – reloaded here, since the modules before have unfolded groups by selecting. await evaluate('__gm.state.loadProject(__gm.state.project, __gm.state.revision)'); await sleep(100); const groups = await json('__gm.assetGroups()'); const categories = await json('__gm.assetCategories()'); const nameOf = Object.fromEntries( (await json('__gm.state.project.assets.map((a) => [a.id, a.name])')).map(([id, name]) => [id, name]), ); const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); const sorted = (list) => list.every((v, i) => i === 0 || collator.compare(list[i - 1], v) <= 0); check( 'the assets panel groups by category and type, groups and assets sorted by name', groups.length >= 4 && categories.length >= 2 && sorted(categories.map((c) => CATEGORIES.indexOf(c.category)).map(String)) && categories.every((c) => sorted(groups.filter((g) => g.category === c.category).map((g) => g.label))) && groups.every((g) => sorted(g.ids.map((id) => nameOf[id]))) && groups.flatMap((g) => g.ids).length === Object.keys(nameOf).length, JSON.stringify({ categories, groups: groups.map((g) => [g.category, g.label, g.ids.map((id) => nameOf[id])]), }), ); check( 'a loaded project shows every category open and every group folded', categories.every((c) => c.expanded) && groups.every((g) => !g.expanded) && (await json("__gm.assetRowShown('crate')")) === null, JSON.stringify({ categories, groups: groups.map((g) => [g.label, g.expanded]) }), ); // Read in the drawn picture: every icon inks its 16 units in the muted colour, and no two alike. // Every pixel against the row's own background (`+`), since a 1.5 unit outline on half pixels is // smoothed and never quite the colour; the exact colour (`#`) still shows where the ink is solid. const MUTED = '#8a90a0'; const icons = await json(`(() => Object.fromEntries(__gm.assetGroups().map((g) => { const c = __gm.assetGroupIcon(g.type); const grid = []; for (let dy = -7; dy <= 7; dy++) for (let dx = -7; dx <= 7; dx++) grid.push({ x: c.x + dx, y: c.y + dy }); const [background, ...ink] = __gm.pixels([{ x: c.x, y: c.y - 9 }, ...grid]); const mask = ink.map((p) => (p === '${MUTED}' ? '#' : p === background ? '.' : '+')).join(''); return [g.type, mask]; })))()`); const masks = Object.values(icons); check( 'every heading draws its icon in the muted colour, and no two icons look alike', masks.every((m) => m.replaceAll('.', '').length >= 20 && m.includes('#')) && new Set(masks.map((m) => m.replaceAll('#', '+'))).size === masks.length, JSON.stringify(icons), ); // A press on the heading unfolds the group, a second folds it again: rows come and go, the // heading and its count stay, and the selection is left where it was. await select({ kind: 'asset', assetId: 'cam' }); const sprites = await json("__gm.assetRow('group:sprite')"); const spriteGroup = async () => ({ expanded: (await json('__gm.assetGroups()')).find((g) => g.type === 'sprite').expanded, crate: await json("__gm.assetRowShown('crate')"), heading: await json("__gm.assetRow('group:sprite')"), selected: (await json('__gm.state.selection'))?.assetId, }); await click(sprites.x, sprites.y); const unfolded = await spriteGroup(); check( 'a click on a folded heading unfolds its group below it and selects nothing', unfolded.expanded === true && Boolean(unfolded.crate) && Boolean(unfolded.heading) && unfolded.heading.y < unfolded.crate.y && unfolded.selected === 'cam' && (await has('Sprite')), JSON.stringify(unfolded), ); await shot('15c-asset-groups'); await click(sprites.x, sprites.y); const folded = await spriteGroup(); check( 'a second click folds it again', folded.expanded === false && folded.crate === null && Boolean(folded.heading) && folded.selected === 'cam', JSON.stringify(folded), ); // Selecting a folded asset from elsewhere – a picker, a graph – unfolds its group. await select({ kind: 'asset', assetId: 'crate' }); check( 'selecting an asset in a folded group unfolds it', (await json('__gm.assetGroups()')).find((g) => g.type === 'sprite').expanded === true && Boolean(await json("__gm.assetRowShown('crate')")), JSON.stringify(await json('__gm.assetGroups()')), ); // Folding the group of the selected asset lets go of the selection – it would unfold it again. const heading = await json("__gm.assetRow('group:sprite')"); await click(heading.x, heading.y); const released = await spriteGroup(); check( 'folding the group of the selected asset folds it and clears the selection', released.expanded === false && released.crate === null && released.selected === undefined, JSON.stringify(released), ); // A category folds the same way: its type headings go, and selecting an asset in it opens it again. const visual = await json("__gm.assetRowShown('category:Visuals')"); await click(visual.x, visual.y); const visualFolded = { category: (await json('__gm.assetCategories()')).find((c) => c.category === 'Visuals'), sprites: await json("__gm.assetRowShown('group:sprite')"), }; await select({ kind: 'asset', assetId: 'crate' }); const visualAgain = { category: (await json('__gm.assetCategories()')).find((c) => c.category === 'Visuals'), crate: await json("__gm.assetRowShown('crate')"), }; check( 'a click on a category heading folds its groups away, selecting an asset in it unfolds it', visualFolded.category?.expanded === false && visualFolded.sprites === null && visualAgain.category?.expanded === true && Boolean(visualAgain.crate), JSON.stringify({ visualFolded, visualAgain }), ); await select(null); const spritesAgain = await json("__gm.assetRow('group:sprite')"); await click(spritesAgain.x, spritesAgain.y); // 4h. The same icons elsewhere: in front of every type in the `+` menu, and at the right edge of // a scene object's row, where its type used to be spelled out. await clickText('+'); const menuIcons = {}; for (const category of CATEGORIES) { await clickText(category); menuIcons[category] = await json('__gm.menuIcons()'); } await shot('15d-add-menu'); await press('Escape'); const iconRows = Object.values(menuIcons).flat(); check( 'every type in the + menu has its icon in front', iconRows.length === ASSET_TYPE_COUNT && iconRows.every((icon) => icon === true), JSON.stringify(menuIcons), ); await showTab('scene'); const first = (await json('__gm.hierarchyOrder()'))[0]; const typeOfFirst = await json(`(() => { const scene = __gm.state.activeScene; const o = scene.objects.find((x) => x.id === ${JSON.stringify(first)}); return __gm.state.project.assets.find((a) => a.id === o.assetId).type; })()`); const rowAt = await json(`__gm.hierarchyRow(${JSON.stringify(first)})`); const iconAt = await json(`__gm.hierarchyIcon(${JSON.stringify(first)})`); const ink = await json(`(() => { const c = __gm.hierarchyIcon(${JSON.stringify(first)}); const grid = []; for (let dy = -7; dy <= 7; dy++) for (let dx = -7; dx <= 7; dx++) grid.push({ x: c.x + dx, y: c.y + dy }); const [background, ...ink] = __gm.pixels([{ x: c.x - 12, y: c.y }, ...grid]); return ink.filter((p) => p !== background).length; })()`); const typeWords = (await json(`__gm.locateAll(${JSON.stringify(typeOfFirst)})`)).filter( (p) => Math.abs(p.y - rowAt.y) < 8, ); check( 'a scene object shows its type as an icon at the right edge, no longer as a word', Boolean(iconAt) && iconAt.x > rowAt.x && ink >= 20 && typeWords.length === 0, JSON.stringify({ first, typeOfFirst, rowAt, iconAt, ink, typeWords }), ); } /** * 4a-ii. Where no menu of its own opens, a right-click brings up the + menu (#244): on the empty * space below the rows all of it, on a category heading the types of that category, on a group * heading only its type (#361). The heading stays folded as it was. */ async function addMenuOnRightClick(t) { const { check, json, shot, click, clickText, press, undo, showTab } = t; const addRows = (rows) => String(rows) === String(CATEGORIES); const heading = await json("__gm.assetRowShown('group:scene')"); const sceneFolded = async () => !(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded; const foldedBefore = await sceneFolded(); await click(heading.x, heading.y, 'right'); const onHeading = await json('__gm.menuRows()'); const headingTitle = await json('__gm.menuTitle()'); check( 'a right-click on a group heading offers only its type, under "Create asset", and leaves the group as it was', String(onHeading) === 'Scene' && headingTitle === 'Create asset' && (await sceneFolded()) === foldedBefore, JSON.stringify({ onHeading, headingTitle, foldedBefore }), ); await press('Escape'); const basics = await json("__gm.assetRowShown('category:Basics')"); await click(basics.x, basics.y, 'right'); const onCategory = { rows: await json('__gm.menuRows()'), submenus: await json('__gm.menuSubmenus()'), }; onCategory.filedUnder = await json( `${JSON.stringify(onCategory.rows)}.map((label) => __gm.addMenuCategory(label))`, ); check( 'a right-click on a category heading offers the types of that category, sorted, without submenus', onCategory.rows.length >= 4 && onCategory.rows.includes('Scene') && onCategory.filedUnder.every((c) => c === 'Basics') && String(onCategory.rows) === String([...onCategory.rows].sort()) && onCategory.submenus.every((open) => !open) && (await json('__gm.assetCategories()')).find((c) => c.category === 'Basics').expanded, JSON.stringify(onCategory), ); await press('Escape'); const groups = await json('__gm.assetGroups()'); const lastGroup = groups.at(-1); const lastShown = await json( `__gm.assetRowShown(${JSON.stringify(lastGroup.expanded ? lastGroup.ids.at(-1) : `group:${lastGroup.type}`)})`, ); const panel = await json('__gm.assetsRect()'); const empty = { x: panel.x + panel.w / 2, y: panel.y + panel.h - 12 }; await click(empty.x, empty.y, 'right'); const onEmpty = await json('__gm.menuRows()'); check( 'a right-click on the empty space below the rows opens the + menu', empty.y > lastShown.y + 20 && addRows(onEmpty), JSON.stringify({ empty, lastShown, onEmpty }), ); await shot('11c-add-menu-on-right-click'); const before = (await json('__gm.state.project.assets')).length; // The headings of the panel read like the menu's categories and entries and are drawn first: the // menu still wins, for the category as for the type. await clickText('Logics'); const behaviorWords = (await json("__gm.locateAll('Behavior')")).length; await clickText('Behavior'); const fromMenu = await json('__gm.state.project.assets.at(-1)'); check( 'an entry of that menu creates the asset like the + button does, even under a heading of the same word', (await json('__gm.state.project.assets')).length === before + 1 && fromMenu.type === 'behavior' && behaviorWords >= 2, JSON.stringify({ fromMenu: fromMenu && { type: fromMenu.type, name: fromMenu.name }, behaviorWords }), ); await undo(); await showTab('scene'); } /** * 4a-iii. A switch in "General settings…" lists every category and type, the empty ones faint * (#361); off again, only what holds assets is left. A right-click on an empty type's heading * creates the first asset of it. */ async function emptyGroupsOnSwitch(t) { const { check, json, shot, click, clickText, clickToolbar, sleep, undo, showTab } = t; const flip = async () => { await clickToolbar('menu'); await clickText('General settings…'); const box = await json('__gm.generalSettingsEmptyGroupsAt()'); await click(box.x, box.y); await clickText('Apply'); await sleep(100); }; const shape = (groups) => JSON.stringify(groups.map((g) => [g.type, g.ids])); const before = await json('__gm.assetGroups()'); await flip(); const all = await json('__gm.assetGroups()'); const categories = await json('__gm.assetCategories()'); // One that an entry creates at once – a texture, a font or a sound opens a picker instead. const empty = all.find( (g) => g.ids.length === 0 && !['texture', 'font', 'sound', 'pixelart'].includes(g.type), ); const full = all.find((g) => g.ids.length > 0); const emptyCategory = CATEGORIES.find((c) => all.filter((g) => g.category === c).every((g) => g.ids.length === 0), ); const seen = { stored: await json(`JSON.parse(localStorage.getItem('feuerwerk.general-settings') ?? 'null')`), emptyAlpha: await json(`__gm.assetRowAlpha('group:${empty?.type}')`), fullAlpha: await json(`__gm.assetRowAlpha('group:${full?.type}')`), categoryAlpha: emptyCategory ? await json(`__gm.assetRowAlpha('category:${emptyCategory}')`) : null, filledCategoryAlpha: await json(`__gm.assetRowAlpha('category:${full?.category}')`), }; check( 'the switch lists every category and type, the empty ones faint, the rest as before', all.length === ASSET_TYPE_COUNT && String(categories.map((c) => c.category)) === String(CATEGORIES) && before.every( (g) => JSON.stringify(all.find((a) => a.type === g.type)?.ids) === JSON.stringify(g.ids), ) && seen.stored?.showEmptyAssetGroups === true && Boolean(empty) && seen.emptyAlpha === 0.5 && seen.fullAlpha === 1 && seen.filledCategoryAlpha === 1 && (!emptyCategory || seen.categoryAlpha === 0.5), JSON.stringify({ ...seen, empty: empty?.type, emptyCategory, all: all.length }), ); await shot('11d-empty-asset-groups'); // Scrolled into view first: with every type listed the panel is longer than it is high. const heading = await json(`__gm.assetRow('group:${empty.type}')`); await click(heading.x, heading.y, 'right'); const offered = await json('__gm.menuRows()'); await clickText(offered[0]); const made = await json('__gm.state.project.assets.at(-1)'); const after = { offered, made: made && { type: made.type, name: made.name }, alpha: await json(`__gm.assetRowAlpha('group:${empty.type}')`), }; check( 'a right-click on an empty type offers just that type, and its first asset makes the heading solid', offered.length === 1 && made?.type === empty.type && after.alpha === 1, JSON.stringify(after), ); await undo(); await showTab('scene'); await flip(); const off = await json('__gm.assetGroups()'); check( 'switched off again, only the types with assets are listed', shape(off) === shape(before) && (await json(`JSON.parse(localStorage.getItem('feuerwerk.general-settings'))`)).showEmptyAssetGroups === false, JSON.stringify({ before: shape(before), off: shape(off) }), ); }