/** * The top bar after its rework (#183): five icon buttons and no captions at all. What it used to * spell out moved into the one menu – the project list into "Open project…", the project name and * the start-scene switch into "Project settings…" next to the collision layers. * * Runs last, and on purpose: the project picker is opened here and left again with Cancel. Opening * or deleting a project for real would pull the project out from under every step before this one. */ export async function topbar(t) { const { check, json, has, shot, clickText, clickToolbar, typeText, press, sleep, waitUntil } = t; await press('Escape'); // 46a. Nothing in the bar is written any more. The five words it used to carry are asked for as // drawn text: the buttons are still there, only their captions are gone. const captions = { project: await has('Project'), undo: await has('Undo'), redo: await has('Redo'), play: await has('Play'), pause: await has('Pause'), }; check( 'the top bar carries no captions any more – menu, undo, redo, play and pause are icons', Object.values(captions).every((shown) => shown === false), JSON.stringify(captions), ); const buttons = await json('__gm.runButtons()'); check( 'in edit mode the run buttons offer play and nothing to pause', buttons.play === 'play' && buttons.pause === 'pause' && buttons.playEnabled === true && buttons.pauseEnabled === false, JSON.stringify(buttons), ); // The pair is centred on the bar itself. The row cannot do that on its own: the spacers around // the two buttons share the free space with the flexible notice and so get a third of it each, // which used to leave the pair well left of the middle. const play = await json('__gm.toolbarAt("play")'); const pause = await json('__gm.toolbarAt("pause")'); const barCentre = await json('window.innerWidth / 2'); check( 'play and pause sit on the middle of the bar, not on the middle of what is left over', Math.abs((play.x + pause.x) / 2 - barCentre) <= 2, JSON.stringify({ middle: (play.x + pause.x) / 2, barCentre }), ); await shot('46-topbar'); // 46b. The one menu, with the separator between what opens a project and what changes the one // that is open. Clearing the saved data is checked in 32. await clickToolbar('menu'); const rows = await json('__gm.menuRows()'); const expected = [ 'New project', 'Open project…', null, 'Snapshots…', 'Release…', 'Project settings…', 'Master routing', 'Clear saved data', null, 'Start recording…', null, 'General settings…', 'Plugins', ]; check( 'the menu button opens new project, the project picker, the settings, the master routing, clearing saves, recording the editor, the general settings and the plugins', JSON.stringify(rows) === JSON.stringify(expected), JSON.stringify(rows), ); // 46c. The picker lists what the account owns, with the open project picked, and Cancel leaves // that project exactly where it was. await clickText('Open project…'); // The picker fetches the list before it can open, so it is waited for rather than read at once. await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' }); const picker = await json('__gm.projectPicker()'); const projectId = await json('__gm.state.project.id'); const projectName = await json('__gm.state.project.name'); check( '"Open project…" lists the projects with the open one picked', picker !== null && picker.picked === projectId && picker.names.includes(projectName), JSON.stringify({ picker, projectId, projectName }), ); await clickText('Cancel'); check( 'Cancel closes the picker and leaves the project loaded', (await json('__gm.projectPicker()')) === null && (await json('__gm.state.project.id')) === projectId, JSON.stringify({ picker: await json('__gm.projectPicker()'), id: await json('__gm.state.project.id') }), ); // 46d. The settings dialog holds the name, the start-scene switch and the collision layers of // #152 – the three things the bar itself used to carry. await clickToolbar('menu'); await clickText('Project settings…'); const settings = await json('__gm.projectSettings()'); const layers = await json('__gm.collisionLayers()'); check( '"Project settings…" opens on the project as it stands', settings?.name === projectName && (settings.playFrom === 'start' || settings.playFrom === 'active') && JSON.stringify(layers.dialog) === JSON.stringify(layers.project), JSON.stringify({ settings, layers }), ); // 46e. Renaming through it, and the icon buttons undoing and redoing it. The name is read off the // project rather than written down here: CI seeds the same project under a different name. const nameField = await t.locate(projectName); await t.doubleClick(nameField.x, nameField.y); await press('a', { ctrl: true }); await typeText('Renamed by 46'); await press('Enter'); await clickText('Apply'); await sleep(200); check( 'Apply renames the project', (await json('__gm.state.project.name')) === 'Renamed by 46', await json('__gm.state.project.name'), ); await clickToolbar('undo'); await sleep(200); const undone = await json('__gm.state.project.name'); await clickToolbar('redo'); await sleep(200); const redone = await json('__gm.state.project.name'); check( 'the undo and redo buttons of the bar walk the rename back and forth', undone === projectName && redone === 'Renamed by 46', JSON.stringify({ undone, redone, was: projectName }), ); // Back to the name the rest of the world knows it by. await clickToolbar('undo'); await sleep(200); check( 'the project is handed back under its own name', (await json('__gm.state.project.name')) === projectName, await json('__gm.state.project.name'), ); // 46h. The icon buttons say what they do (#209). The pointer has to rest for the sign to show; it // names the step redo would take and the keys, and sits under its button, inside the window. const tooltip = () => json('__gm.tooltip()'); const redoAt = await json('__gm.toolbarAt("redo")'); const redoLabel = await json('__gm.state.commands.redoLabel'); await t.mouse('mouseMoved', redoAt.x - 1, redoAt.y); await t.mouse('mouseMoved', redoAt.x, redoAt.y); const atOnce = await tooltip(); await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the redo button' }); const redoTip = await tooltip(); const windowSize = await json('({ w: window.innerWidth, h: window.innerHeight })'); await shot('46h-tooltip'); check( 'resting on the redo button shows "Redo (Ctrl+Y)" under it, after a wait and not at once', atOnce === null && typeof redoLabel === 'string' && redoLabel.length > 0 && redoTip.text === `Redo ${redoLabel} (Ctrl+Y)` && redoTip.box.y >= redoTip.anchor.y + redoTip.anchor.h && redoTip.box.x === Math.round(redoTip.anchor.x) && redoTip.box.x + redoTip.box.w <= windowSize.w && (await has(redoTip.text)) === false, JSON.stringify({ atOnce, redoLabel, redoTip }), ); // Off the button it goes. The wheel takes it away as well, and the button stays quiet until the // pointer has left it; a click does the same, here with the menu opening under it. await t.mouse('mouseMoved', redoAt.x + 90, redoAt.y); await sleep(100); const afterLeaving = await tooltip(); const menuAt = await json('__gm.toolbarAt("menu")'); const restOnMenu = async () => { await t.mouse('mouseMoved', menuAt.x - 1, menuAt.y); await t.mouse('mouseMoved', menuAt.x, menuAt.y); await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the menu button' }); return tooltip(); }; const menuTip = await restOnMenu(); await t.wheel(menuAt.x, menuAt.y, 40); const afterWheel = await tooltip(); await t.mouse('mouseMoved', menuAt.x + 1, menuAt.y); await sleep(800); const afterWaiting = await tooltip(); await t.mouse('mouseMoved', menuAt.x + 200, menuAt.y); const again = await restOnMenu(); await clickToolbar('menu'); const afterClick = await tooltip(); const menuOpen = (await json('__gm.menuRows()')) !== null; await press('Escape'); check( 'the tooltip goes when the pointer leaves, and the wheel or a click takes it away until the pointer comes back', afterLeaving === null && menuTip.text === 'Menu' && afterWheel === null && afterWaiting === null && again.text === 'Menu' && afterClick === null && menuOpen === true, JSON.stringify({ afterLeaving, menuTip, afterWheel, afterWaiting, again, afterClick, menuOpen }), ); // At the right border the sign turns left: flush with the right edge of its button. The button // nearest that border is the reset of an inspector row, which an override brings up. const edgeObject = await json(`(() => { const p = __gm.state.project; const scene = __gm.state.activeScene; const o = scene.objects.find((o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite'); return o ? { scene: scene.id, id: o.id, overrides: o.overrides } : null; })()`); const setOverrides = (overrides) => t.mutate( `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(edgeObject.id)}).overrides = ${JSON.stringify(overrides)}`, ); await setOverrides({ ...edgeObject.overrides, width: 77 }); await t.select({ kind: 'object', holderId: edgeObject.scene, objectId: edgeObject.id }); const resetAt = await t.findIcon('reset', false); await t.mouse('mouseMoved', resetAt.x - 1, resetAt.y); await t.mouse('mouseMoved', resetAt.x, resetAt.y); await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the reset button' }); const edgeTip = await tooltip(); await shot('46h-tooltip-edge'); check( 'a tooltip that would leave the window on the right turns left, and names the one thing its button does', edgeTip.text === 'Reset to asset' && edgeTip.anchor.x + edgeTip.box.w > windowSize.w && edgeTip.box.x + edgeTip.box.w === Math.round(edgeTip.anchor.x + edgeTip.anchor.w) && edgeTip.box.x + edgeTip.box.w <= windowSize.w, JSON.stringify({ edgeTip, windowSize }), ); await t.mouse('mouseMoved', windowSize.w / 2, windowSize.h / 2); await setOverrides(edgeObject.overrides); await t.select(null); // 46f. The symbol scale in the same dialog: 1 by default, typed down to 0.5, and every icon of the // scene drawn at half its side (18 → 9 world units), undone in one step. const iconSize = () => json(`(() => { const p = __gm.state.project; const scene = p.assets.find((a) => a.id === p.activeSceneId); const id = (scene?.objects ?? []).map((o) => o.id).find((id) => __gm.sceneIcon(id)?.visible); return id ? __gm.sceneIcon(id).size : null; })()`); await clickToolbar('menu'); await clickText('Project settings…'); const opened = await json('__gm.projectSettings()'); const field = await json('__gm.projectSettingsSymbolScaleAt()'); await t.click(field.x, field.y); await press('a', { ctrl: true }); await typeText('0.5'); await press('Enter'); await clickText('Apply'); await sleep(200); const scaled = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() }; await clickToolbar('undo'); await sleep(200); const restored = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() }; check( 'the symbol scale starts at 1, Apply halves every scene icon, and undo gives the size back', opened?.symbolScale === 1 && scaled.project === 0.5 && Math.abs(scaled.icon - 9) < 1e-9 && restored.project === 1 && Math.abs(restored.icon - 18) < 1e-9, JSON.stringify({ opened, scaled, restored }), ); // 46g. The eye in the scene tab's header: every placeable type with a switch for its symbol and its name, and a // outline of a collider and of a tilemap's solid cells besides. Sprite, Text and Tilemap have no symbol, so that switch is // greyed out. Real clicks flip the scene view, and the switches are saved but no undo step. await json('(__gm.showTab("scene"), true)'); const firstOf = (type) => json(`(() => { const p = __gm.state.project; const scene = p.assets.find((a) => a.id === p.activeSceneId); const o = (scene?.objects ?? []).find((o) => p.assets.find((a) => a.id === o.assetId)?.type === ${JSON.stringify(type)} && o.enabled !== false); return o ? o.id : null; })()`); const ids = { sprite: await firstOf('sprite'), collider: 'vis-col-obj', behavior: await firstOf('behavior'), tilemap: 'vis-map-obj', }; // A map of its own with one solid row, laid over the sprite so it is on screen: the seed has none. // A collider of its own there too: in the full run the first one in the scene is a leftover of an // earlier module, off screen, and its outline reads as hidden before any switch is touched. await t.mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; const at = scene.objects.find((o) => o.id === ${JSON.stringify(ids.sprite)}).position; assets.push({ id: 'vis-wall', type: 'tile', name: 'Vis wall', props: { kind: 'none', solid: true }, pictures: [] }); assets.push({ id: 'vis-map', type: 'tilemap', name: 'Vis map', props: { columns: 3, rows: 1, tileSize: 32 }, layers: [{ id: 'vis-layer', name: 'Layer 1', cells: { '0,0': 'vis-wall', '1,0': 'vis-wall', '2,0': 'vis-wall' } }] }); const o = structuredClone(scene.objects[0]); Object.assign(o, { id: 'vis-map-obj', assetId: 'vis-map', name: 'Vis map', position: { ...at }, parentId: '', overrides: {}, instanceEdits: {} }); scene.objects.push(o); assets.push({ id: 'vis-col', type: 'collider', name: 'Vis collider', props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } }); scene.objects.push(Object.assign(structuredClone(o), { id: 'vis-col-obj', assetId: 'vis-col', name: 'Vis collider' })); __gm.state.select(null)`); const undoBefore = await json('__gm.state.commands.canUndo'); await t.showTab('scene'); const eye = await json(`__gm.sceneHeaderAt('visibility')`); await t.click(eye.x, eye.y); const popup = await json('__gm.visibilityPopup()'); const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part); const types = [...new Set((popup ?? []).map((e) => e.type))]; check( 'the eye lists every placeable type, Symbol greyed out where there is none and Shape on collider, tilemap and clip area', JSON.stringify(types) === JSON.stringify([ 'behavior', 'camera', 'clip', 'collider', 'composition', 'container', 'joint', 'particles', 'rigidbody', 'sprite', 'text', 'tilemap', ]) && entry(popup, 'sprite', 'symbol')?.enabled === false && entry(popup, 'tilemap', 'label')?.enabled === true && entry(popup, 'sprite', 'label')?.on === false && entry(popup, 'collider', 'shape')?.on === true && entry(popup, 'tilemap', 'shape')?.on === true && entry(popup, 'clip', 'shape')?.on === true && popup.filter((e) => e.part === 'shape').length === 3, JSON.stringify(popup), ); const flip = async (type, part) => { const at = entry(await json('__gm.visibilityPopup()'), type, part); await t.click(at.x, at.y); await sleep(150); }; const labelIds = async () => (await json('__gm.sceneLabels()')).map((l) => l.id); const mapLooks = async () => ({ solids: (await json(`__gm.tilemap.solids(${JSON.stringify(ids.tilemap)})`))?.visible ?? null, drawn: (await json(`__gm.tilemap.info(${JSON.stringify(ids.tilemap)})`)) !== null, }); const before = { shape: await json(`__gm.sceneShape(${JSON.stringify(ids.collider)})`), labels: await labelIds(), icon: (await json(`__gm.sceneIcon(${JSON.stringify(ids.behavior)})`))?.visible, map: await mapLooks(), }; await flip('collider', 'shape'); await flip('tilemap', 'shape'); await flip('behavior', 'label'); await flip('behavior', 'symbol'); await flip('sprite', 'label'); const after = { shape: await json(`__gm.sceneShape(${JSON.stringify(ids.collider)})`), labels: await labelIds(), icon: (await json(`__gm.sceneIcon(${JSON.stringify(ids.behavior)})`))?.visible, popup: await json('__gm.visibilityPopup()'), saved: await json('__gm.state.project.sceneVisibility'), map: await mapLooks(), }; await shot('46g-visibility'); check( "a tilemap's Shape hides the outline of its solid cells and leaves the map itself drawn", before.map.solids === true && before.map.drawn === true && after.map.solids === false && after.map.drawn === true && after.saved.tilemap.shape === false, JSON.stringify({ before: before.map, after: after.map }), ); check( 'switching hides the collider outline and the behavior icon and name, and puts the sprite name on', before.shape?.visible === true && after.shape?.visible === false && after.shape?.clickable === false && before.icon === true && after.icon === false && before.labels.includes(ids.behavior) && !after.labels.includes(ids.behavior) && !before.labels.includes(ids.sprite) && after.labels.includes(ids.sprite) && entry(after.popup, 'collider', 'shape')?.on === false, JSON.stringify({ ids, before, after: { ...after, popup: undefined } }), ); check( 'the switches go into the project and not onto the undo stack', after.saved.collider.shape === false && after.saved.behavior.label === false && after.saved.sprite.label === true && (await json('__gm.state.commands.canUndo')) === undoBefore, JSON.stringify({ saved: after.saved, undoBefore }), ); // Handed back as found, for whatever runs after this. await flip('collider', 'shape'); await flip('tilemap', 'shape'); await flip('behavior', 'label'); await flip('behavior', 'symbol'); await flip('sprite', 'label'); await press('Escape'); await t.mutate(` const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'vis-map-obj' && o.id !== 'vis-col-obj'); const assets = __gm.state.project.assets; for (const id of ['vis-map', 'vis-wall', 'vis-col']) assets.splice(assets.findIndex((a) => a.id === id), 1)`); await t.saved(); // 46i. "General settings…" (#309): the zoom transition in ms, kept in this browser. The harness // sets 0 on load. At 1000 ms one wheel step in the scene view is sampled on its way – the zoom // between the two steps, the world point under the pointer still under it – and it lands on // the very view a jump of 0 ms reaches. await clickToolbar('menu'); await clickText('General settings…'); const general = await json('__gm.generalSettingsDialog()'); const msField = await json('__gm.generalSettingsZoomAt()'); await t.click(msField.x, msField.y); await press('a', { ctrl: true }); await typeText('1000'); await press('Enter'); await clickText('Apply'); await sleep(100); const kept = { dialog: await json('__gm.generalSettingsDialog()'), ms: await json('__gm.zoomTransition()'), stored: await json(`JSON.parse(localStorage.getItem('game-maker.general-settings') ?? 'null')`), }; check( '"General settings…" opens on the kept transition, and Apply keeps the one typed in the browser', general?.zoomTransitionMs === 0 && kept.dialog === null && kept.ms === 1000 && kept.stored?.zoomTransitionMs === 1000, JSON.stringify({ general, kept }), ); // 46i-ii. The custom mouse pointer, on by default: the canvas shows the cursor file an editor // recording draws as its CSS cursor (Chrome took the value, the picture loads, the keyword // stays at the end). Switched off in the dialog the system's keyword is back, on again the file. // `json` would stringify the promise itself: this one hands back its JSON once it is settled. const pictureLoads = async () => JSON.parse( await t.evaluate(`(async () => { const url = /^url\\("([^"]*)"\\)/.exec(__gm.canvasCursor())?.[1]; if (!url) return 'null'; const image = new Image(); image.src = url; try { await image.decode(); return JSON.stringify([image.naturalWidth, image.naturalHeight]); } catch { return '"broken"'; } })()`), ); const switchCursor = async () => { await clickToolbar('menu'); await clickText('General settings…'); const box = await json('__gm.generalSettingsCustomCursorAt()'); await t.click(box.x, box.y); await clickText('Apply'); await sleep(100); }; const cursorOn = { css: await json('__gm.canvasCursor()'), size: await pictureLoads() }; await switchCursor(); const cursorOff = { css: await json('__gm.canvasCursor()') }; const storedOff = await json( `JSON.parse(localStorage.getItem('game-maker.general-settings')).customCursor`, ); await switchCursor(); const cursorBack = await json('__gm.canvasCursor()'); check( 'the custom mouse pointer is on by default as the CSS cursor, General settings switch it off and on', general?.customCursor === true && /^url\("data:image\/svg\+xml,.*"\) \d+ \d+, default$/.test(cursorOn.css) && Array.isArray(cursorOn.size) && cursorOn.size[0] > 0 && !cursorOff.css.startsWith('url(') && storedOff === false && cursorBack === cursorOn.css, JSON.stringify({ cursorOn: { ...cursorOn, css: cursorOn.css.slice(0, 40) }, cursorOff, storedOff }), ); await t.showTab('scene'); const rect = await json('__gm.viewportRect()'); const at = { x: Math.round(rect.x + rect.w * 0.7), y: Math.round(rect.y + rect.h * 0.4) }; const viewNow = async () => ({ ...(await json('__gm.sceneView()')), world: await json(`__gm.sceneWorldAt(${at.x}, ${at.y})`), }); await t.evaluate('__gm.zoomTransition(0)'); const start = await viewNow(); await t.wheel(at.x, at.y, -100); const jumped = await viewNow(); await t.wheel(at.x, at.y, 100); const back = await viewNow(); await t.evaluate('__gm.zoomTransition(1000)'); await t.mouse('mouseMoved', at.x, at.y); await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 }); await t.settle(2); const midway = await viewNow(); await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended' }); const landed = await viewNow(); const samePoint = (a, b) => Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6; check( 'a frame after the wheel the scene view is on its way: zoom between the steps, the point under the pointer kept', back.zoom === start.zoom && jumped.zoom > start.zoom && midway.moving === true && midway.zoom > start.zoom && midway.zoom < jumped.zoom && samePoint(midway.world, start.world), JSON.stringify({ start, jumped, back, midway }), ); check( 'the transition lands on the view a jump reaches: the same step and the same centre', landed.moving === false && landed.zoom === jumped.zoom && samePoint(landed, jumped) && samePoint(landed.world, start.world), JSON.stringify({ jumped, landed }), ); // A pan during the transition takes it along instead of jumping to its end. const by = { x: 40, y: 30 }; const moved = { x: at.x + by.x, y: at.y + by.y }; await t.mouse('mouseMoved', at.x, at.y); await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 }); await t.drag(at.x, at.y, moved.x, moved.y, { steps: 4, button: 'middle' }); const panned = await json('__gm.sceneView()'); await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended after the pan' }); const settled = await json('__gm.sceneView()'); const heldAfter = await json(`__gm.sceneWorldAt(${moved.x}, ${moved.y})`); check( 'a pan during the transition carries it on: still on its way, then landed one step further, moved by the pan', panned.moving === true && panned.zoom > landed.zoom && panned.zoom < settled.zoom && settled.zoom > landed.zoom && samePoint(heldAfter, start.world), JSON.stringify({ landed, panned, settled, heldAfter, world: start.world }), ); // Handed back as found: no transition for the steps after this one, and the view it had. await t.evaluate('__gm.zoomTransition(0)'); await t.drag(moved.x, moved.y, at.x, at.y, { steps: 4, button: 'middle' }); await t.wheel(at.x, at.y, 100); await t.wheel(at.x, at.y, 100); }