diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index b3f090a3..f6249ab5 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -4,7 +4,17 @@ import { resolveObjects } from '../core/model/instance'; import { findObjectHolder } from '../core/model/Project'; import type { FrameRect } from '../core/model/texture'; import { effectiveScale, gameClock } from '../core/time'; -import { activeDrag, Button, currentMenu, isRendering, renderCount, requestRender, ui, Widget } from '../ui'; +import { + activeDrag, + afterFrames, + Button, + currentMenu, + isRendering, + renderCount, + requestRender, + ui, + Widget, +} from '../ui'; import { particleLibrary } from '../viewport/ParticleLibrary'; import { shaderLibrary } from '../viewport/ShaderLibrary'; import { soundLibrary } from '../viewport/SoundLibrary'; @@ -530,6 +540,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E renderStats: () => ({ drawn: renderCount(), awake: isRendering() }), /** Draw again, whatever happens next – what a screenshot needs before it is taken. */ wake: () => requestRender(), + /** + * Resolves once the editor has drawn `count` more frames. The checks await this instead of + * sleeping a guessed number of milliseconds: a frame is the editor saying it has caught up, + * where a deadline is only the harness hoping it has. + */ + afterFrames: (count = 2, timeoutMs = 2000) => afterFrames(count, timeoutMs), /** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */ filterCount: () => { let n = 0; diff --git a/src/ui/index.ts b/src/ui/index.ts index 0293a1d2..a425121e 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -2,7 +2,14 @@ export { Widget, Box, type Size } from './Widget'; export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack'; export { Theme, textStyle, type TextStyleToken } from './Theme'; export { initUI, ui, type UIContext } from './UIContext'; -export { initRenderLoop, isRendering, renderCount, requestRender, resetRenderLoop } from './render'; +export { + afterFrames, + initRenderLoop, + isRendering, + renderCount, + requestRender, + resetRenderLoop, +} from './render'; export { drawBand, drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; diff --git a/src/ui/render.ts b/src/ui/render.ts index 4681c3ed..69241bc4 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -37,6 +37,39 @@ export function renderCount(): number { return rendered; } +/** Resolved once the next frame has been drawn; see `afterFrames`. */ +let frameWaiters: Array<() => void> = []; + +/** + * Resolves once `count` frames have actually been drawn – "the editor has caught up with what I + * just did", said as a fact instead of as a deadline. The checks wait on this rather than on a + * guessed number of milliseconds, which is both faster (a frame is ~16 ms) and truer. + * + * It wakes the loop itself, because an idle editor draws nothing and a promise waiting for a + * frame that nobody asked for never settles. `timeoutMs` is the other half of that promise: a + * wedged renderer must fail the check it belongs to, not hang the whole run. + */ +export function afterFrames(count = 1, timeoutMs = 2000): Promise { + return new Promise((resolve) => { + let left = Math.max(1, count); + let done = false; + const finish = (): void => { + if (done) return; + done = true; + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(finish, timeoutMs); + const tick = (): void => { + if (done) return; + if (--left <= 0) finish(); + else frameWaiters.push(tick); + }; + frameWaiters.push(tick); + requestRender(); + }); +} + /** * Takes the renderer off the ticker and drives it from `requestRender()` instead. Added at the * ticker's lowest priority, so everything that may request a frame has run for this tick already. @@ -49,6 +82,10 @@ export function initRenderLoop(app: Application): void { if (!isRendering()) return; rendered += 1; app.render(); + // Taken first: a waiter that queues itself again must land in the next round, not this one. + const waiters = frameWaiters; + frameWaiters = []; + for (const w of waiters) w(); }, undefined, UPDATE_PRIORITY.LOW, @@ -60,4 +97,5 @@ export function initRenderLoop(app: Application): void { export function resetRenderLoop(): void { deadline = 0; rendered = 0; + frameWaiters = []; } diff --git a/tests/ui/render.test.ts b/tests/ui/render.test.ts index c9935c2e..9d964220 100644 --- a/tests/ui/render.test.ts +++ b/tests/ui/render.test.ts @@ -1,6 +1,7 @@ import type { Application } from 'pixi.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + afterFrames, initRenderLoop, isRendering, renderCount, @@ -93,4 +94,48 @@ describe('render loop', () => { tick(); expect(app.drawn).toBe(1); }); + + it('afterFrames resolves only once that many frames were really drawn', async () => { + const { app, tick } = fakeApp(); + initRenderLoop(app as unknown as Application); + now += 500; + + let done = false; + const waited = afterFrames(2).then(() => (done = true)); + + tick(); + await Promise.resolve(); + expect(done).toBe(false); + tick(); + await waited; + expect(done).toBe(true); + expect(app.drawn).toBe(2); + }); + + it('afterFrames wakes the loop itself, so an idle editor still answers', async () => { + const { app, tick } = fakeApp(); + initRenderLoop(app as unknown as Application); + now += 500; + for (let i = 0; i < 5; i++) tick(); + expect(app.drawn).toBe(0); + + const waited = afterFrames(1); + tick(); + await waited; + expect(app.drawn).toBe(1); + }); + + it('afterFrames gives up rather than hanging when no frame ever comes', async () => { + vi.useFakeTimers(); + try { + const { app } = fakeApp(); + initRenderLoop(app as unknown as Application); + const waited = afterFrames(1, 50); + vi.advanceTimersByTime(60); + await waited; + expect(app.drawn).toBe(0); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index 0b7dc2f2..f0c00636 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -11,7 +11,7 @@ * rely on what earlier ones leave behind, a subset that skips a prerequisite fails on purpose. */ import { STEPS } from './e2e/index.mjs'; -import { runChecks } from './lib/harness.mjs'; +import { runChecks, waited } from './lib/harness.mjs'; import { PROJECTS, cliArgs } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('e2e.mjs', PROJECTS.fixture); @@ -35,8 +35,33 @@ if (steps.length !== STEPS.length) { console.log(`running ${steps.length}/${STEPS.length} steps: ${steps.map((s) => s.name).join(', ')}`); } +/** + * One line per step, printed as it finishes: how long it took and how much of that was the + * harness sitting out a fixed wait. It also puts the run's position into the log, so a run that + * dies half-way says where it stood instead of leaving the last check as the only clue. + */ +const timings = []; +async function timed(step, t) { + const at = Date.now(); + const waitedAt = waited.ms; + await step(t); + const ms = Date.now() - at; + const slept = waited.ms - waitedAt; + timings.push({ name: step.name, ms, slept }); + const no = String(STEPS.indexOf(step) + 1).padStart(2, '0'); + console.log(`STEP ${no} ${step.name} – ${(ms / 1000).toFixed(1)}s (waited ${(slept / 1000).toFixed(1)}s)`); +} + await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async (t) => { await t.navigate(); await t.shot('01-loaded'); - for (const step of steps) await step(t); + for (const step of steps) await timed(step, t); + const total = timings.reduce((sum, s) => sum + s.ms, 0); + const slept = timings.reduce((sum, s) => sum + s.slept, 0); + const worst = [...timings].sort((a, b) => b.ms - a.ms).slice(0, 8); + console.log( + `\nTIMING ${(total / 1000).toFixed(0)}s in steps, ${(slept / 1000).toFixed(0)}s of it waiting ` + + `(${Math.round((slept / total) * 100)}%). Slowest: ` + + worst.map((s) => `${s.name} ${(s.ms / 1000).toFixed(1)}s`).join(', '), + ); }); diff --git a/tools/e2e/01-hierarchy.mjs b/tools/e2e/01-hierarchy.mjs index 54d504c7..02ea61dc 100644 --- a/tools/e2e/01-hierarchy.mjs +++ b/tools/e2e/01-hierarchy.mjs @@ -6,7 +6,7 @@ import { same } from '../lib/harness.mjs'; * without touching the selection, and selecting a nested object unfolds the way down to it. */ export async function hierarchy(t) { - const { check, sleep, evaluate, json, click, doubleClick, select, state, shot } = t; + const { check, evaluate, json, click, doubleClick, select, state, shot } = t; const rowIds = () => json('__gm.hierarchyOrder()'); const rows = async () => JSON.stringify(await rowIds()); const selectionBefore = (await state()).selection; @@ -48,13 +48,11 @@ export async function hierarchy(t) { ); await select({ kind: 'object', holderId: 'scene1', objectId: 'oc1' }); - await sleep(150); check( 'selecting a nested object unfolds its ancestors', same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), await rows(), ); await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); - await sleep(150); check('Collapse all folds everything back', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows()); } diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index 5fd3f8cd..c2c35d28 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -64,7 +64,6 @@ export async function inspector(t) { await typeText('Renamed crate'); await shot('03-typing'); await press('Enter'); - await sleep(200); check( 'typing + Enter renamed the object', (await object('o2')).name === 'Renamed crate', @@ -77,7 +76,6 @@ export async function inspector(t) { await doubleClick(nameLabel.x + 160, nameLabel.y); await typeText('Twice'); await press('Enter'); - await sleep(200); check( 'double-clicking a text field selects all – typing replaces the name', (await object('o2')).name === 'Twice', @@ -86,7 +84,6 @@ export async function inspector(t) { // Undone rather than typed back: check 2h further down counts its undos, so this must not // leave a step on the stack. await undo(); - await sleep(200); check( 'undo takes that rename back', (await object('o2')).name === 'Renamed crate', @@ -172,7 +169,6 @@ export async function inspector(t) { JSON.stringify(stillTyping), ); await press('Escape'); - await sleep(200); const after = await json('__gm.inspectorInfo()'); check( 'leaving the field runs the postponed rebuild', diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index fa85a05e..17761bd7 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -9,7 +9,6 @@ const FIXTURE_IDS = ['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2']; export async function play(t) { const { check, - sleep, evaluate, json, shot, @@ -27,6 +26,7 @@ export async function play(t) { select, mutate, stop, + showTab, } = t; const gameInfo = () => json('__gm.gameInfo()'); const play = t.play; @@ -92,7 +92,6 @@ export async function play(t) { ); await shot('08b-editing-while-playing'); await press('Delete'); - await sleep(150); const liveIds = (await world()).entities.map((e) => e.objectId); check( 'Delete while playing removes the entity and its collider child from the world', @@ -112,7 +111,6 @@ export async function play(t) { // Stop discards it with the rest of the runtime copy. const visibleBeforeSpawn = (await gameInfo()).visibleItems; await press('Space'); - await sleep(200); const spawnedObjects = await objects(); const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id)); check( @@ -162,7 +160,6 @@ export async function play(t) { // 3d. Without a camera nothing is rendered: delete the camera object, play, then undo. await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); await press('Delete'); - await sleep(200); check('camera object deleted', (await objects()).length === 6); await play(); info = await gameInfo(); @@ -185,7 +182,6 @@ export async function play(t) { // Found through its row, not an offset from the label: the label column's width is layout. const enabledRow = await json("__gm.inspectorField('Enabled')"); await click(enabledRow.x, enabledRow.y); - await sleep(150); check( 'the camera has the enabled switch like any other object', (await object('o3')).overrides.enabled === false, @@ -222,7 +218,6 @@ export async function play(t) { await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); const switchBackOn = await json("__gm.inspectorField('Enabled')"); await click(switchBackOn.x, switchBackOn.y); - await sleep(150); await play(); info = await gameInfo(); check( @@ -249,8 +244,7 @@ export async function play(t) { const p = await json(`__gm.gameScreen('o1')`); return json(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`); }; - await evaluate(`__gm.showTab('game')`); - await sleep(150); + await showTab('game'); const preview = await gameInfo(); const previewPixel = await crateSpot(); check( @@ -276,11 +270,10 @@ export async function play(t) { await stop(); // Switching the only camera off leaves nothing to preview – and says so, live on the tab. - await evaluate(`__gm.showTab('game')`); + await showTab('game'); await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); const previewSwitch = await json("__gm.inspectorField('Enabled')"); await click(previewSwitch.x, previewSwitch.y); - await sleep(200); info = await gameInfo(); check( 'switching the camera off empties the preview and names the reason', @@ -289,14 +282,13 @@ export async function play(t) { ); await shot('10d-no-active-camera'); await undo(); - await sleep(200); info = await gameInfo(); check( 'and undo brings the preview back without a reload', info.rendering && info.camera === 'o3' && (await json(`__gm.locate('No active camera')`)) === null, JSON.stringify(info), ); - await evaluate(`__gm.showTab('scene')`); + await showTab('scene'); check( 'the fixture is left as it was found', (await objects()).length === 7 && Object.keys((await object('o3')).overrides).length === 0, diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index c13ffae0..672f5202 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -17,6 +17,7 @@ export async function assets(t) { select, objects, asset, + showTab, } = t; // 4a. Context menu on the Crate asset. Placing left the menu when it became a drag. @@ -25,7 +26,6 @@ export async function assets(t) { check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`))); await shot('11-context-menu'); await press('Escape'); - await sleep(150); // 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. @@ -70,14 +70,12 @@ export async function assets(t) { const afterUndo = (await objects()).length; check('one undo takes the dropped object back out', afterUndo === 7, `objects=${afterUndo}`); await redo(); - await sleep(150); check('redo puts it back', (await objects()).length === 8); // 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups – // what can be placed in a scene, a line, then what only lives in the library – and it is // tall enough to show all fifteen types without scrolling. await clickText('+'); - await sleep(150); const rows = await json('__gm.menuRows()'); const cut = rows.indexOf(null); const placeable = rows.slice(0, cut); @@ -129,7 +127,6 @@ export async function assets(t) { JSON.stringify({ above, below, margin, screenH }), ); await clickText('Sprite'); - await sleep(100); const names = await json('__gm.state.project.assets.map(a => a.name)'); check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names)); await shot('12-new-asset'); @@ -141,8 +138,7 @@ export async function assets(t) { `__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.showTab('graph')"); - await sleep(150); + await showTab('graph'); await doubleClick(secondScene.x, secondScene.y); check( 'double-clicking a scene asset makes it the active scene', @@ -177,8 +173,7 @@ export async function assets(t) { ['box-open', 'container', '__gm.container.current()'], ]; for (const [id, tab, shown] of openable) { - await evaluate("__gm.showTab('scene')"); - await sleep(150); + 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 @@ -192,7 +187,6 @@ export async function assets(t) { JSON.stringify(clicked), ); await doubleClick(row.x, row.y); - await sleep(300); const opened = { tab: await json('__gm.tab()'), shown: await json(shown) }; check( `a double-click opens the ${tab} tab on it`, @@ -202,13 +196,10 @@ export async function assets(t) { } // 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 evaluate("__gm.showTab('scene')"); - await sleep(150); + await showTab('scene'); await clickText('+'); - await sleep(200); const entry = await evaluate(`__gm.locate('Behavior', true)`); await click(entry.x, entry.y); - await sleep(400); const created = await json( "(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''", ); @@ -222,14 +213,12 @@ export async function assets(t) { await mutate( `__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open'].includes(a.id))`, ); - await evaluate("__gm.showTab('scene')"); - await sleep(150); + 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' }); - await sleep(200); const heightReset = await evaluate(`__gm.locate('↺')`); const colorReset = await evaluate(`__gm.locate('↺', true)`); check('asset inspector shows a reset per deviating property', Boolean(heightReset)); @@ -239,7 +228,6 @@ export async function assets(t) { JSON.stringify({ heightReset, colorReset }), ); await click(heightReset.x, heightReset.y); - await sleep(100); check( 'reset puts the asset property back to its default', (await asset('crate')).props.height === 100, @@ -281,7 +269,6 @@ export async function assets(t) { await assetColor('mover'), ); await click(heightReset.x, heightReset.y); - await sleep(150); check( 'the mark stays while the second deviation is left', (await assetColor('crate')) === GOLD, @@ -289,7 +276,6 @@ export async function assets(t) { ); const colorOnly = await evaluate(`__gm.locate('↺')`); await click(colorOnly.x, colorOnly.y); - await sleep(150); check( 'resetting the last deviation clears the mark, without a reload', (await assetColor('crate')) === PLAIN, diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs index f10d6050..f9275f19 100644 --- a/tools/e2e/05-drawing.mjs +++ b/tools/e2e/05-drawing.mjs @@ -1,6 +1,6 @@ /** Draw order: symbols always above sprites, `z` above hierarchy order. */ export async function drawing(t) { - const { check, sleep, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t; + const { check, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t; const orderOfCrates = () => json(`__gm.drawOrder().filter(id => id === 'o1' || id === 'o2')`); // 5a. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate @@ -31,12 +31,10 @@ export async function drawing(t) { JSON.stringify(await orderOfCrates()), ); await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' }); - await sleep(200); const zLabel = await locate('Z'); await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected await typeText('5'); await press('Enter'); - await sleep(200); check( 'Z field sets position.z', (await object('o1')).position.z === 5, diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index aa1775b7..c78e8c21 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -6,6 +6,7 @@ export async function graph(t) { const { check, sleep, + saved, evaluate, json, find, @@ -26,6 +27,7 @@ export async function graph(t) { api, navigate, projectId, + showTab, } = t; const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id); const graphCounts = async () => { @@ -36,8 +38,7 @@ export async function graph(t) { // 6a. Selecting the Mover asset binds the graph; the Behavior tab shows it. await clickText('Mover'); - await evaluate(`__gm.showTab('graph')`); - await sleep(250); + await showTab('graph'); check( 'graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph', @@ -79,13 +80,10 @@ export async function graph(t) { const bare = await json('__gm.graph.emptySpot()'); const beforeClickPick = await graphCounts(); await click(bare.x, bare.y, 'right'); - await sleep(100); check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); await typeText('stop'); - await sleep(150); const stopRow = await locate('Stop game'); // nothing else on screen shows this label await click(stopRow.x, stopRow.y); - await sleep(100); const afterClickPick = await graphCounts(); check( 'clicking a palette row adds exactly one node', @@ -99,7 +97,6 @@ export async function graph(t) { // 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node. const before13 = await graphCounts(); await click(bare.x, bare.y, 'right'); - await sleep(100); check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); // Unfiltered the palette holds far more nodes than fit, so it grows to the full height it may // have – and that height is the canvas minus a tenth at each end. Shifting it up to fit used to @@ -116,9 +113,7 @@ export async function graph(t) { JSON.stringify({ gapAbove, gapBelow, paletteMargin, paletteScreenH }), ); await typeText('log'); - await sleep(100); await press('Enter'); - await sleep(250); const after13 = await graphCounts(); s = await state(); const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; @@ -134,9 +129,7 @@ export async function graph(t) { // Enter the first row alphabetically, which was `Run behavior`. const beforeRank = await graphCounts(); await click(bare.x, bare.y, 'right'); - await sleep(100); await typeText('variable'); - await sleep(150); const ranked = await json('__gm.graph.paletteRows()'); const hits = (row) => `${row.label} ${row.type}`.toLowerCase().includes('variable'); const lastNamed = ranked.map(hits).lastIndexOf(true); @@ -147,7 +140,6 @@ export async function graph(t) { JSON.stringify(ranked), ); await press('Enter'); - await sleep(250); s = await state(); const rankPick = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; check( @@ -156,7 +148,6 @@ export async function graph(t) { JSON.stringify(rankPick), ); await undo(); - await sleep(150); check('undo removes it again', (await graphCounts()).nodes === beforeRank.nodes); // 6e. Connect: drag from the Move node's exec output onto the Log node's exec input. An @@ -240,7 +231,6 @@ export async function graph(t) { await shot('18c-drop-into-empty'); const clampRow = await locate('Clamp'); await click(clampRow.x, clampRow.y); - await sleep(200); const afterDrop = await graphCounts(); const wired = await state(); const clampEdge = (await asset('mover')).graph.edges.find( @@ -272,7 +262,6 @@ export async function graph(t) { await mouse('mouseReleased', empty.x, empty.y); await sleep(150); await press('Escape'); - await sleep(150); const cancelled = await graphCounts(); check( 'escaping the palette clears the edge and adds nothing', @@ -284,10 +273,8 @@ export async function graph(t) { // 6f. Delete removes the selected node together with its edge; undo brings both back. await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id }); - await sleep(200); check('the node inspector has no delete button of its own', !(await t.has('Delete node'))); await press('Delete'); - await sleep(150); const deleted = await graphCounts(); check( 'Delete removes the node and its edge', @@ -324,8 +311,7 @@ export async function graph(t) { typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`, ); - await evaluate(`__gm.showTab('graph')`); - await sleep(100); + await showTab('graph'); const holding = holdKey('ArrowRight', 300); await sleep(250); await shot('19-graph-live'); @@ -334,10 +320,8 @@ export async function graph(t) { // 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it. await select({ kind: 'asset', assetId: 'mover' }); - await sleep(150); await clickText('+ Variable'); await clickText('Number'); - await sleep(100); check( 'adding a variable declares it on the behavior', (await variableKeys()) === 'speed,var1', @@ -358,13 +342,11 @@ export async function graph(t) { ); check('the header has no Add node button – the right-click palette is the way', !(await find('Add node…'))); await select(null); - await sleep(100); check( 'graph editor keeps its behavior when nothing is selected', (await evaluate(`__gm.graph.current()`)) === 'mover', ); await select({ kind: 'asset', assetId: 'cam' }); - await sleep(100); check( 'graph editor keeps its behavior for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === 'mover', @@ -372,17 +354,14 @@ export async function graph(t) { // The picker is the *last* "Mover" on screen: the assets panel shows one too. const pickerAt = await locate('Mover', true); await click(pickerAt.x, pickerAt.y); - await sleep(150); const spawnerRow = await locate('Spawner', true); await click(spawnerRow.x, spawnerRow.y); - await sleep(200); check( 'the header picker switches the graph to another behavior', (await evaluate(`__gm.graph.current()`)) === 'spawner', String(await evaluate(`__gm.graph.current()`)), ); await select({ kind: 'asset', assetId: 'mover' }); - await sleep(100); check( 'graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover', @@ -391,7 +370,6 @@ export async function graph(t) { // 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node // under it included – and the left button no longer pans at all. await clickText('Fit'); - await sleep(250); const positions = async () => (await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' '); const area = await json('__gm.graph.canvasRect()'); @@ -415,7 +393,6 @@ export async function graph(t) { // Fit again: the pan just now pushed a node out of view, and a band takes only what it sees. await clickText('Fit'); - await sleep(250); // An empty spot: after a fit every node sits at least a margin away from the corner. const corner = { x: area.x + 6, y: area.y + 6 }; const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 }; @@ -464,7 +441,6 @@ export async function graph(t) { const snapLabel = await locate('Snap'); // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. await click(snapLabel.x + 30, snapLabel.y); - await sleep(150); check( 'the toggle switches it on without moving a single node', (await json('__gm.graph.snapping()')) === true && (await positions()) === before13i, @@ -473,7 +449,6 @@ export async function graph(t) { // A single node: dropped between two grid lines, it lands on the nearer one. await click(corner.x, corner.y); - await sleep(150); const snapPre = await nodeOf('n-axis'); const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`); const snapBy = await units(50); @@ -521,9 +496,7 @@ export async function graph(t) { // Switching it off has to give the pointer its exact drop position back. await click(snapLabel.x + 30, snapLabel.y); - await sleep(150); await click(corner.x, corner.y); - await sleep(150); const offPre = await nodeOf('n-axis'); const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); const offBy = await units(50); @@ -538,13 +511,11 @@ export async function graph(t) { `${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`, ); await undo(); - await sleep(150); // 6m. Delete acts on the selection, not just the primary node. await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); const beforeDelete = await graphCounts(); await press('Delete'); - await sleep(200); const afterDelete = await graphCounts(); check( 'Delete removes every selected node, edges with them', @@ -563,7 +534,6 @@ export async function graph(t) { // and a click cannot miss one that the earlier pan pushed over the edge. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); - await sleep(250); const axisRow = await json(`__gm.graph.nodeAt('n-axis')`); await click(axisRow.x, axisRow.y); const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis'); @@ -591,7 +561,6 @@ export async function graph(t) { ); await sleep(250); await clickText('Fit'); - await sleep(300); const rects = []; for (const node of (await asset('mover')).graph.nodes) rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`)); @@ -614,14 +583,12 @@ export async function graph(t) { `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= ${park}; __gm.state.preview(); })()`, ); await clickText('Fit'); - await sleep(250); check('the parked node is back where it started', (await positions()) === before13i, await positions()); // 6p. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and // comes back while either of its two nodes is selected. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); - await sleep(250); const allEdges = (await asset('mover')).graph.edges; const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0]; const id = JSON.stringify(edge.id); @@ -633,12 +600,9 @@ export async function graph(t) { `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`, ); await click(outPort.x, outPort.y, 'right'); - await sleep(150); check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`))); await clickText('Hide link'); - await sleep(250); await select({ kind: 'asset', assetId: 'mover' }); - await sleep(150); const hidden = await edgeNow(); check( 'hiding changes nothing but a flag – the connection is still there', @@ -660,21 +624,17 @@ export async function graph(t) { // Selecting either end lends the curve back – the source first, then the target. const sourceAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.from.node)})`); await click(sourceAt.x, sourceAt.y); - await sleep(200); check('selecting the node at one end brings the curve back', (await drawn()) === true); const targetAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.to.node)})`); await click(targetAt.x, targetAt.y); - await sleep(200); check('selecting the node at the other end does too', (await drawn()) === true); await select({ kind: 'asset', assetId: 'mover' }); - await sleep(200); check('deselecting hides it again', (await drawn()) === false); // The mark is the handle: clicking it selects the node at the far end, which is also what // puts the curve back – so it answers "where does this go?" in one click. const mark2 = await json(`__gm.graph.foldMark(${id}, true)`); await click(mark2.x, mark2.y); - await sleep(200); check( 'clicking the mark jumps to the node at the far end', JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]), @@ -684,7 +644,6 @@ export async function graph(t) { await undo(); await select({ kind: 'asset', assetId: 'mover' }); - await sleep(250); const shown = await edgeNow(); check( 'undo unhides the link, with nothing left behind', @@ -737,12 +696,9 @@ export async function graph(t) { ); await click(forkPort.x, forkPort.y, 'right'); - await sleep(150); check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`))); await clickText('Hide 2 links'); - await sleep(250); await select({ kind: 'asset', assetId: 'mover' }); - await sleep(200); const ids = forked.map((e) => JSON.stringify(e.id)); const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`); const markB = await json(`__gm.graph.foldMark(${ids[1]}, true)`); @@ -767,7 +723,6 @@ export async function graph(t) { // The bundled mark is still a handle: one click selects every far end, which is also what // lends all of its curves back at once. await click(markA.x, markA.y); - await sleep(250); const picked = await json('__gm.graph.selected()'); check( 'clicking a bundled mark selects every node the port feeds', @@ -781,15 +736,12 @@ export async function graph(t) { ); await click(markA.x, markA.y, 'right'); - await sleep(150); check( 'the mark menu shows both links back in one go', Boolean(await evaluate(`__gm.locate('Show 2 links')`)), ); await clickText('Show 2 links'); - await sleep(250); await select({ kind: 'asset', assetId: 'mover' }); - await sleep(200); const unfolded = (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ); @@ -801,7 +753,6 @@ export async function graph(t) { // One step of undo takes both back – hiding two links was one command, so it is one undo. await undo(); await select({ kind: 'asset', assetId: 'mover' }); - await sleep(250); const refolded = (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ); @@ -811,7 +762,6 @@ export async function graph(t) { JSON.stringify(refolded.map((e) => e.folded)), ); await undo(); - await sleep(200); const forkedNow = async () => (await asset('mover')).graph.edges.filter( @@ -821,13 +771,11 @@ export async function graph(t) { `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`, ); await click(portNow.x, portNow.y, 'right'); - await sleep(150); const offered = await find('Disconnect 2 links'); if (offered) await click(offered.x, offered.y); await sleep(250); const cut = await forkedNow(); await undo(); - await sleep(250); check( 'disconnecting both links at a port is one undo step', Boolean(offered) && cut === 0 && (await forkedNow()) === 2, @@ -838,12 +786,10 @@ export async function graph(t) { // link lands on it until it is expanded, and the state is saved with the project. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); - await sleep(250); const edgesBefore13q = (await graphCounts()).edges; const openRect = await json(`__gm.graph.nodeRect('n-move')`); const chevron = await json(`__gm.graph.chevronAt('n-move')`); await click(chevron.x, chevron.y); - await sleep(200); const shut = await json(`__gm.graph.nodeRect('n-move')`); const zoom = openRect.w / 176; check( @@ -902,12 +848,9 @@ export async function graph(t) { for (const port of ['x', 'y']) { const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`); await click(at.x, at.y, 'right'); - await sleep(150); await clickText('Hide link'); - await sleep(200); } await select({ kind: 'asset', assetId: 'mover' }); - await sleep(200); const markDx = await json(`__gm.graph.foldMark('e3', false)`); const markDy = await json(`__gm.graph.foldMark('e4', false)`); check( @@ -919,9 +862,7 @@ export async function graph(t) { await undo(); await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); - await sleep(150); await click(title.x, title.y, 'right'); - await sleep(150); check( 'a mixed selection is offered both ways', Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) && @@ -929,14 +870,11 @@ export async function graph(t) { ); await press('Escape'); await undo(); - await sleep(150); check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined); await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); - await sleep(150); const openTitle = await json(`__gm.graph.nodeAt('n-move')`); await click(openTitle.x, openTitle.y, 'right'); - await sleep(150); const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`); check('two expanded nodes are offered as one', collapseBoth !== null); if (collapseBoth) await click(collapseBoth.x, collapseBoth.y); @@ -945,7 +883,6 @@ export async function graph(t) { const both = async () => [(await nodeOf('n-speed')).collapsed, (await nodeOf('n-move')).collapsed]; check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]'); await undo(); - await sleep(150); check( 'and one undo takes all of it back', JSON.stringify(await both()) === '[null,null]', @@ -957,16 +894,15 @@ export async function graph(t) { const again = await json(`__gm.graph.chevronAt('n-move')`); await click(again.x, again.y); const savedMove = async () => { - for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300); - const saved = await api(`projects/${projectId}`); - return saved.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move'); + await saved(); + const doc = await api(`projects/${projectId}`); + return doc.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move'); }; check('the collapse reaches the saved file', (await savedMove()).collapsed === true); await navigate(); await sleep(500); await select({ kind: 'asset', assetId: 'mover' }); - await evaluate(`__gm.showTab('graph')`); - await sleep(250); + await showTab('graph'); const reloaded = await json(`__gm.graph.nodeRect('n-move')`); check( 'and a reloaded project draws it collapsed again', @@ -977,5 +913,5 @@ export async function graph(t) { await click(reopen.x, reopen.y); check('expanding leaves no trace in the file', !('collapsed' in (await savedMove()))); - await evaluate(`__gm.showTab('scene')`); + await showTab('scene'); } diff --git a/tools/e2e/07-parenting.mjs b/tools/e2e/07-parenting.mjs index dacb30ac..65d25f94 100644 --- a/tools/e2e/07-parenting.mjs +++ b/tools/e2e/07-parenting.mjs @@ -8,7 +8,6 @@ import { same } from '../lib/harness.mjs'; export async function parenting(t) { const { check, - sleep, json, shot, click, @@ -36,7 +35,6 @@ export async function parenting(t) { const redRow = await locate('Red crate'); const blueRow = await locate('Blue crate'); await drag(blueRow.x, blueRow.y, redRow.x, redRow.y); - await sleep(100); let o2 = await object('o2'); check( 'dropping a row onto another parents it, keeping the world position', @@ -60,7 +58,6 @@ export async function parenting(t) { await press('a', { ctrl: true }); await typeText('-50'); await press('Enter'); - await sleep(200); 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 }; @@ -73,7 +70,6 @@ export async function parenting(t) { // 7c. Play: entities mirror the hierarchy; undo restores parent and position. await select(null); await press('Enter', { ctrl: true }); - await sleep(300); const e2 = await entity('o2'); check( 'play instantiates the parent link and world positions', @@ -100,7 +96,6 @@ export async function parenting(t) { JSON.stringify(entries), ); await typeText('red'); - await sleep(150); const filtered = await json('__gm.overlayTexts()'); check( 'typing filters the picker down to the matching entries', @@ -109,7 +104,6 @@ export async function parenting(t) { ); await shot('30-parent-search'); await press('Enter'); // takes the first match - await sleep(150); o2 = await object('o2'); check( 'inspector Parent menu parents the object', @@ -120,7 +114,6 @@ export async function parenting(t) { const blueRow2 = await locate('Blue crate'); await click(blueRow2.x, blueRow2.y, 'right'); await clickText('Unparent (move to top level)'); - await sleep(100); o2 = await object('o2'); check( 'Unparent moves the object to the top level, keeping its world position', @@ -140,7 +133,6 @@ export async function parenting(t) { 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); - await sleep(150); const rowsAfter = await json('__gm.hierarchyOrder()'); o2 = await object('o2'); check( @@ -155,7 +147,6 @@ export async function parenting(t) { ); await shot('31-reordered'); await undo(); - await sleep(150); check( 'undo puts the order back', same(await json('__gm.hierarchyOrder()'), rowsBefore), diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs index bca6daac..798f4188 100644 --- a/tools/e2e/08-colliders.mjs +++ b/tools/e2e/08-colliders.mjs @@ -34,7 +34,6 @@ export async function colliders(t) { const redRow = await locate('Red crate'); await click(redRow.x, redRow.y, 'right'); await clickText('Add collider'); - await sleep(100); const s = await state(); const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const colAsset = colObj ? await asset(colObj.assetId) : null; @@ -102,7 +101,6 @@ export async function colliders(t) { ${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''} __gm.state.select(null)`); await press('Enter', { ctrl: true }); - await sleep(300); await holdKey('ArrowRight', 700); const red = await entity('o1'); await stop(); @@ -128,6 +126,5 @@ export async function colliders(t) { ); await mutate(`__gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200`); // later steps expect the seeded speed await select({ kind: 'object', objectId: 'oc2' }); - await sleep(200); await shot('22-trigger-collider'); } diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs index a68c3567..614cd5bb 100644 --- a/tools/e2e/09-containers.mjs +++ b/tools/e2e/09-containers.mjs @@ -7,6 +7,7 @@ import { same } from '../lib/harness.mjs'; */ export async function containers(t) { const { + saved, check, sleep, evaluate, @@ -44,7 +45,6 @@ export async function containers(t) { await press('a', { ctrl: true }); await typeText(String(value)); await press('Enter'); - await sleep(200); }; const notice = async () => { await evaluate("__gm.state.notice = ''"); @@ -52,7 +52,6 @@ export async function containers(t) { }; const selectObject = async (id) => { await select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [id] }); - await sleep(250); }; // 9a. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy: @@ -116,7 +115,6 @@ export async function containers(t) { const listedBeforeDelete = (await objects()).length; await select({ kind: 'object', holderId: '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 === listedBeforeDelete - 5, @@ -131,7 +129,6 @@ export async function containers(t) { const listedBefore = (await objects()).length; await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await clickText('Create container'); - await sleep(150); s = await state(); const instanceA = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const containerAsset = instanceA ? await asset(instanceA.assetId) : null; @@ -304,11 +301,8 @@ export async function containers(t) { const before = (await objects()).length; const noticeNow = await notice(); await click(row.x, row.y, 'right'); - await sleep(150); await clickText('Add collider'); - await sleep(200); await press('Escape'); - await sleep(100); return { added: (await objects()).length - before, notice: await noticeNow() }; }; const refused = JSON.stringify({ added: 0, notice: '' }); @@ -340,11 +334,9 @@ export async function containers(t) { JSON.stringify(fromPlain), ); await undo(); - await sleep(200); await selectObject(redA); let noticeAfter = await notice(); await press('Delete'); - await sleep(200); check( 'removing resolved content is refused with a notice', (await noticeAfter()).includes('container template') && Boolean(await object(redA)), @@ -352,7 +344,6 @@ export async function containers(t) { ); noticeAfter = await notice(); await press('g', { ctrl: true }); - await sleep(200); check( 'making a container out of resolved content is refused too', (await noticeAfter()).includes('container template'), @@ -360,7 +351,6 @@ export async function containers(t) { ); const parentRow = await json("__gm.inspectorField('Parent')"); await click(parentRow.x, parentRow.y); - await sleep(150); check( 'the Parent picker does not even open for resolved content', same(await json('__gm.overlayTexts()'), []), @@ -377,7 +367,6 @@ export async function containers(t) { const redARow = await rowAt(redA); const otherRow = await rowAt(instanceB.id); await drag(redARow.x, redARow.y, otherRow.x, otherRow.y); - await sleep(250); check( 're-parenting resolved content by dragging its row is refused', (await noticeAfter()).includes('container template') && (await object(redA)).parentId === instanceA.id, @@ -400,7 +389,6 @@ export async function containers(t) { await has('Follows a template of 5 object(s).'), ); await clickText('Unpack'); - await sleep(250); check( 'Unpack writes the content into the scene, keeping the ids and the listing', (await storedObjects()).length === storedBeforeUnpack + 5 && @@ -429,19 +417,16 @@ export async function containers(t) { // not freeze the picker any more – the inspector has to read the holder, not the id. const unpackedParentRow = await json("__gm.inspectorField('Parent')"); await click(unpackedParentRow.x, unpackedParentRow.y); - await sleep(150); check( 'unpacked content offers the Parent picker like any ordinary object', (await json('__gm.overlayTexts()')).includes('None'), JSON.stringify(await json('__gm.overlayTexts()')), ); await press('Escape'); - await sleep(100); noticeAfter = await notice(); const redCRow = await rowAt(redC); await click(redCRow.x, redCRow.y, 'right'); await clickText('Unparent (move to top level)'); - await sleep(250); check( 'unpacked content can be re-parented, which was refused while it followed the template', (await object(redC)).parentId === '' && (await noticeAfter()) === '', @@ -450,7 +435,6 @@ export async function containers(t) { noticeAfter = await notice(); await selectObject(redC); await press('Delete'); - await sleep(250); check( 'and it can be removed – the refusal was about following a template, not about the id', !(await object(redC)) && (await noticeAfter()) === '', @@ -458,7 +442,6 @@ export async function containers(t) { ); await selectObject(instanceC.id); await press('Delete'); - await sleep(250); check( 'removing the unpacked container takes the rest of its content with it', (await storedObjects()).length === storedBeforeUnpack - 1, @@ -475,7 +458,6 @@ export async function containers(t) { 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( @@ -506,7 +488,6 @@ export async function containers(t) { 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(150); check( 'apply writes x into the template and the untouched instance follows', (await templateRed()).position.x === -100 && (await object(redA)).position.x === -100, @@ -520,7 +501,6 @@ export async function containers(t) { ); const revertButton = await evaluate(`__gm.locate('↺')`); await click(revertButton.x, revertButton.y); - await sleep(150); const revertedB = (await storedObjects()).find((o) => o.id === instanceB.id); check( 'revert drops the deviation, so the instance follows the template again', @@ -554,7 +534,6 @@ export async function containers(t) { ); await shot('28-apply-menu'); await clickText('Apply to asset'); - await sleep(150); check( 'Apply to asset writes the value into the asset and drops the deviation', (await asset('crate')).props.width === 77 && (await object(redB)).overrides.width === undefined, @@ -567,7 +546,6 @@ export async function containers(t) { ); await click(applyMenu.x, applyMenu.y); await clickText('Apply to container'); - await sleep(150); check( 'Apply to container writes the override into the template, so every instance shows it', (await templateRed()).overrides.width === 77 && (await object(redA)).overrides.width === 77, @@ -597,7 +575,7 @@ export async function containers(t) { // The content must be nowhere in the saved scene, and reloading must resolve it back. await selectObject(redB); await setField('Y', 12); - for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300); + await saved(); const savedScene = sceneOf(await api(`projects/${projectId}`)); const savedInstance = savedScene.objects.find((o) => o.id === instanceB.id); check( diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs index cdfc131d..d35a33ee 100644 --- a/tools/e2e/10-text-and-enabled.mjs +++ b/tools/e2e/10-text-and-enabled.mjs @@ -2,7 +2,6 @@ export async function textAndEnabled(t) { const { check, - sleep, evaluate, json, has, @@ -21,7 +20,6 @@ export async function textAndEnabled(t) { // 10a. Text assets: created through the + menu, placed, and rendered as real text in the scene. await clickText('+'); await clickText('Text'); - await sleep(100); const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`); check('+ menu creates a text asset', Boolean(textAssetId)); await mutate(` @@ -31,7 +29,6 @@ export async function textAndEnabled(t) { const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position; __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); __gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`); - await sleep(100); check('a placed text object draws its content in the scene', await has('Hello world')); await shot('29-text-object'); @@ -44,7 +41,6 @@ export async function textAndEnabled(t) { const gizmoOn = await gizmoOf('otext'); check('a selected object is framed at full strength', gizmoOn?.alpha === 1, JSON.stringify(gizmoOn)); await click(enabledRow.x, enabledRow.y); - await sleep(100); const textObject = await object('otext'); check( 'clicking it writes an override instead of changing the asset', @@ -75,11 +71,9 @@ export async function textAndEnabled(t) { ); const otherRow = await evaluate(`__gm.hierarchyRow('o3')`); await click(otherRow.x, otherRow.y); - await sleep(100); check('selecting something else drops the frame', (await gizmoOf('otext')) === null); const offRow = await evaluate(`__gm.hierarchyRow('otext')`); await click(offRow.x, offRow.y); - await sleep(100); const gizmoBack = await gizmoOf('otext'); check( 'picking the switched-off object in the hierarchy frames it again', @@ -92,10 +86,8 @@ export async function textAndEnabled(t) { // 10c. In the running game a switched-off object does not exist: it is not rendered, and // switching it back on brings it back. await play(); - await sleep(200); const offItems = (await json('__gm.gameInfo()')).visibleItems; await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true`); - await sleep(200); const onItems = (await json('__gm.gameInfo()')).visibleItems; check( 'the game renders one item more once the object is switched on', @@ -112,7 +104,6 @@ export async function textAndEnabled(t) { await click(fontLabel.x + 160, fontLabel.y); await typeText('32.6'); await press('Enter'); - await sleep(200); const fontSize = (await object('otext')).overrides.fontSize; check('typing a fraction into it rounds the stored value', fontSize === 33, String(fontSize)); check('and the field shows exactly what was stored', Boolean(await has('33'))); diff --git a/tools/e2e/11-menus.mjs b/tools/e2e/11-menus.mjs index 06dc2448..af9f673f 100644 --- a/tools/e2e/11-menus.mjs +++ b/tools/e2e/11-menus.mjs @@ -22,7 +22,6 @@ export async function menus(t) { scene.objects.push(copy); } __gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`); - await sleep(150); // 11a. A picker with 40+ entries is capped in height and stays fully on screen instead of // running off the bottom edge. The cap is a share of the canvas, not a constant: a tenth is diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index 4fd7ce3e..e2cabfa7 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -244,9 +244,7 @@ export async function textures(t) { const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); sprite.props.shader = ''; delete sprite.props.brightness; __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'linear'`); - await sleep(400); await mutate(enlarge(160, 120)); - await sleep(400); // 12d. The colour multiplies the picture, so a tinted white quadrant stops being white. await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff"); @@ -282,7 +280,6 @@ export async function textures(t) { ); await undo(); - await sleep(300); const afterUndo = await json( "__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)", ); @@ -407,7 +404,6 @@ export async function textures(t) { // Back to one texture asset, the way the later steps expect the fixture. await undo(); - await sleep(300); check( 'undo takes the adopted asset back out', (await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1, @@ -417,23 +413,19 @@ export async function textures(t) { const entryText = 'Create sprite from texture'; const spriteRow = await json("__gm.assetRow('crate')"); await click(spriteRow.x, spriteRow.y, 'right'); - await sleep(200); check( 'a sprite asset does not offer making a sprite from it', (await json(`__gm.locate(${JSON.stringify(entryText)}, false)`)) === null, ); await press('Escape'); - await sleep(150); const spritesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length"); const textureRow = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`); await click(textureRow.x, textureRow.y, 'right'); - await sleep(200); const createEntry = await json(`__gm.locate(${JSON.stringify(entryText)}, false)`); check('a texture asset offers making a sprite from it', createEntry !== null); if (createEntry) { await click(createEntry.x, createEntry.y); - await sleep(400); const made = await json(`(() => { const s = __gm.state.selection; const a = s && s.kind === 'asset' ? __gm.state.project.assets.find((x) => x.id === s.assetId) : null; diff --git a/tools/e2e/13-frames.mjs b/tools/e2e/13-frames.mjs index 75de3b46..538faa16 100644 --- a/tools/e2e/13-frames.mjs +++ b/tools/e2e/13-frames.mjs @@ -35,7 +35,7 @@ function nearestColour(sample) { } export async function frames(t) { - const { check, sleep, json, evaluate, shot, clickText, drag, press, undo, wheel } = t; + const { check, sleep, json, evaluate, shot, clickText, drag, press, undo, wheel, showTab } = t; // 13a. The tab exists and shows the texture the selection points at. await press('Escape'); @@ -43,7 +43,6 @@ export async function frames(t) { const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''"); await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(texture)} })`); await openTab(t, 'texture'); - await sleep(400); check('the Texture tab opens', (await json('__gm.tab()')) === 'texture'); check( 'and shows the selected sheet', @@ -84,7 +83,6 @@ export async function frames(t) { // 13c. It is one undo step, not one per pointer move. await undo(); - await sleep(400); check( 'undo removes the frame in one step', (await json( @@ -92,7 +90,6 @@ export async function frames(t) { )) === 0, ); await t.redo(); - await sleep(400); // 13d. A sprite wearing the frame shows that part of the sheet and nothing else. await evaluate(`(() => { @@ -101,7 +98,7 @@ export async function frames(t) { sprite.props.texture = ${JSON.stringify(frame.id)}; __gm.state.preview(); })()`); - await evaluate("__gm.showTab('scene')"); + await showTab('scene'); await sleep(900); const box = await json("__gm.objectRect('otex')"); @@ -128,8 +125,7 @@ export async function frames(t) { __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff; __gm.state.preview(); })()`); - await evaluate("__gm.showTab('texture')"); - await sleep(400); + await showTab('texture'); await clickText('Slice…'); await sleep(500); const dialog = await json('__gm.overlayTexts()'); @@ -160,8 +156,7 @@ export async function frames(t) { // 13f. Zoomed in, the sheet is larger than the tab: nothing of it may be drawn next to the tab. // The bug this guards against drew the checkerboard's base rectangle, the frame outlines // and the name plates over the Assets panel, because only the picture itself was masked. - await evaluate("__gm.showTab('texture')"); - await sleep(400); + await showTab('texture'); const area = await json('__gm.texture.rect()'); // Points on the panel left of the tab and on the tab row above it – both were painted over. const probes = [ @@ -198,10 +193,9 @@ export async function frames(t) { JSON.stringify(await json('__gm.textOverflows()')), ); await clickText('Fit'); - await sleep(400); // 13g. A sprite pointed at a frame slicing removed falls back to its colour. - await evaluate("__gm.showTab('scene')"); + await showTab('scene'); await sleep(700); if (box) { const fallback = await json( diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index 9dd9e8ba..66178ef0 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -9,13 +9,12 @@ import { TAB_LABELS } from './lib/scaffold.mjs'; const LONG_NAME = 'Player movement with jumping and coyote time'; export async function overflow(t) { - const { check, sleep, json, evaluate, shot, mutate, drag, clickText } = t; + const { check, sleep, json, evaluate, shot, mutate, drag, clickText, showTab } = t; const pickerWidths = async () => { const out = {}; for (const tab of ['graph', 'texture', 'container']) { - await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); - await sleep(300); + await showTab(tab); out[tab] = await json(`__gm.${tab}.pickerWidth()`); } return out; @@ -42,8 +41,7 @@ export async function overflow(t) { // 14b. The tab row itself: a button past the centre column's edge sits under the inspector, and // a click on it lands there (nine tabs on a 90 px floor did exactly that at 1280 px wide). - await evaluate("__gm.showTab('scene')"); - await sleep(300); + await showTab('scene'); const columnRight = async () => { const column = await json('__gm.viewportRect()'); return column.x + column.w; @@ -70,7 +68,6 @@ export async function overflow(t) { await drag(bars.left.x, bars.left.y, bars.left.x + by, bars.left.y, { steps: 8 }); const after = await json('__gm.panelSplitters()'); await drag(after.right.x, after.right.y, after.right.x - by, after.right.y, { steps: 8 }); - await sleep(300); }; await widenPanels(250); const narrow = await json('__gm.tabRow()'); @@ -89,7 +86,6 @@ export async function overflow(t) { // The overflow button names them, and picking one brings it into the row. const wanted = narrow.hidden[narrow.hidden.length - 1]; await clickText('»'); - await sleep(400); const offered = await json('__gm.overlayTexts()'); check( 'the overflow button offers every tab that had to go', @@ -97,7 +93,6 @@ export async function overflow(t) { JSON.stringify({ offered, hidden: narrow.hidden }), ); await clickText(TAB_LABELS[wanted]); - await sleep(400); const picked = await json('__gm.tabRow()'); check( `picking ${TAB_LABELS[wanted]} from the overflow menu opens it and puts it in the row`, @@ -105,8 +100,7 @@ export async function overflow(t) { JSON.stringify({ tab: await json('__gm.tab()'), shown: picked.shown.map((b) => b.tab) }), ); await widenPanels(-250); - await evaluate("__gm.showTab('scene')"); - await sleep(300); + await showTab('scene'); const back = await json('__gm.tabRow()'); check( 'giving the room back brings tabs into the row again, and none past the edge', @@ -116,8 +110,7 @@ export async function overflow(t) { // 14c. Every tab, because each one owns a different set of controls. for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list', 'particles']) { - await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); - await sleep(400); + await showTab(tab); const spills = await json('__gm.textOverflows()'); check( `no label is drawn past its control in the ${tab} tab`, @@ -127,8 +120,7 @@ export async function overflow(t) { } // 14d. Shortened, not simply dropped: the value must still be readable and marked as cut. - await evaluate("__gm.showTab('graph')"); - await sleep(400); + await showTab('graph'); const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement')); check( 'a value too wide for its dropdown is cut with an ellipsis', @@ -139,8 +131,7 @@ export async function overflow(t) { // 14e. An inspector full of dropdowns – the parent row and the enum fields – with a selection // whose asset name is now overlong too. - await evaluate("__gm.showTab('scene')"); - await sleep(300); + await showTab('scene'); await evaluate("__gm.state.select({ kind: 'object', objectId: 'otex' })"); await sleep(500); const inspector = await json('__gm.textOverflows()'); @@ -149,8 +140,7 @@ export async function overflow(t) { // 14f. The other direction: a control narrow enough to swallow its whole label draws nothing // at all, which the overflow walk cannot see – an empty text spills over no edge. for (const tab of ['scene', 'graph', 'texture', 'container', 'list', 'particles']) { - await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); - await sleep(400); + await showTab(tab); const dropped = await json('__gm.labelsDropped()'); check( `no button is left without its label in the ${tab} tab`, @@ -160,7 +150,6 @@ export async function overflow(t) { } // The narrowest buttons in the editor sit on a behaviour's variable rows. await evaluate("__gm.state.select({ kind: 'asset', assetId: 'mover' })"); - await sleep(400); const onVariables = await json('__gm.labelsDropped()'); check( 'the variable rows keep their buttons labelled', diff --git a/tools/e2e/15-aspect.mjs b/tools/e2e/15-aspect.mjs index 13d3e522..0bdaaa2a 100644 --- a/tools/e2e/15-aspect.mjs +++ b/tools/e2e/15-aspect.mjs @@ -50,7 +50,6 @@ export async function aspect(t) { // 15c. Fit moves the width only, to the sheet's ratio: 100 x 64/48. await clickText('↔ ▾'); - await sleep(400); await clickText('Fit to 64 × 48'); await sleep(500); const fitted = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; @@ -107,7 +106,6 @@ export async function aspect(t) { const before = await json(spriteProp('width')); await clickText('↕ ▾'); - await sleep(400); await clickText('Fit to 40 × 10'); await sleep(500); const framed = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; @@ -123,13 +121,11 @@ export async function aspect(t) { await select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' }); await sleep(600); await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90"); - await sleep(400); check('the object row offers the fit as well', (await sizeButton('↔')) === 'menu'); // The crowded case: an overridden, out-of-proportion row carries ↔ ▾ next to ↑ and ↺. check('and shows it beside the override buttons', (await json('__gm.textOverflows()')).length === 0); await shot('43-aspect-beside-the-override-buttons'); await clickText('↔ ▾'); - await sleep(400); await clickText('Fit to 64 × 48'); await sleep(500); const override = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides"); diff --git a/tools/e2e/16-container.mjs b/tools/e2e/16-container.mjs index 4dded399..d0fd8bd8 100644 --- a/tools/e2e/16-container.mjs +++ b/tools/e2e/16-container.mjs @@ -28,6 +28,7 @@ export async function containerTab(t) { object, objects, storedObjects, + showTab, } = t; await press('Escape'); @@ -40,7 +41,6 @@ export async function containerTab(t) { // 16a. The tab opens on the container the project has, and its header says which one. await openTab(t, 'container'); - await sleep(300); const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`); const count = template.length; check( @@ -161,7 +161,6 @@ export async function containerTab(t) { // 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, @@ -173,19 +172,16 @@ export async function containerTab(t) { 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 mutate( + `const obj = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); obj.position.x = 3000`, ); - 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', @@ -196,25 +192,18 @@ export async function containerTab(t) { `(() => { 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); + await showTab('scene'); + await select({ kind: 'object', holderId: containerId, objectId: root.id }); check( 'selecting a template object switches to the Container tab', (await json('__gm.tab()')) === 'container', ); - await evaluate("__gm.showTab('scene')"); - await sleep(300); + await showTab('scene'); 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', @@ -224,15 +213,13 @@ export async function containerTab(t) { // 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 showTab('container'); 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' && @@ -303,8 +290,7 @@ export async function containerTab(t) { // 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); + await showTab('scene'); const storedBefore = (await storedObjects()).length; const listedBefore = (await objects()).length; const spot = await json('__gm.viewportCenter()'); @@ -331,5 +317,4 @@ export async function containerTab(t) { await shot('34-container-nested'); await undo(); // the placement await undo(); // the nesting - await sleep(200); } diff --git a/tools/e2e/17-editor-run.mjs b/tools/e2e/17-editor-run.mjs index 863945d0..7b254a69 100644 --- a/tools/e2e/17-editor-run.mjs +++ b/tools/e2e/17-editor-run.mjs @@ -43,7 +43,21 @@ const driftGraph = { }; export async function editorRun(t) { - const { check, sleep, evaluate, json, shot, click, holdKey, mutate, undo, object, api, projectId } = t; + const { + check, + sleep, + evaluate, + json, + shot, + click, + holdKey, + mutate, + undo, + object, + api, + projectId, + showTab, + } = t; // 17a. A box with a drifting behavior below it. Written into the model directly: what is under // test is the switch, not another way of building a graph. @@ -62,7 +76,6 @@ export async function editorRun(t) { ${MAKE_OBJECT} scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter')); `); - await sleep(400); const boxX = async () => (await object('er-box')).position.x; const startX = await boxX(); @@ -93,7 +106,6 @@ export async function editorRun(t) { JSON.stringify({ enabledBox, runBox, variables }), ); await click(runBox.x, runBox.y); - await sleep(300); const logic = await object('er-logic'); check( @@ -144,7 +156,6 @@ export async function editorRun(t) { // 17e. Taking the switch back stops it again. await click(runBox.x, runBox.y); - await sleep(300); const stoppedAt = await boxX(); await sleep(500); const stopped = await json('__gm.editorRun()'); @@ -158,7 +169,6 @@ export async function editorRun(t) { // undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock. await undo(); await undo(); - await sleep(300); const afterUndo = await object('er-box'); check( 'undo takes back the switch, never the movement it caused', @@ -244,12 +254,10 @@ export async function editorRun(t) { `${JSON.stringify(onScene)} x=${sceneBefore} → ${await walkX()}`, ); - await evaluate(`__gm.showTab('game')`); - await sleep(300); + await showTab('game'); // Clicking the view first: a checkbox left focused by an earlier check would still want the keys. const gameCentre = await json('__gm.gameCenter()'); await click(gameCentre.x, gameCentre.y); - await sleep(200); const onGame = await json('__gm.editorRun()'); const gameBefore = await walkX(); await holdKey('ArrowRight', 500); @@ -268,8 +276,7 @@ export async function editorRun(t) { ); await shot('37-editor-run-keys'); - await evaluate(`__gm.showTab('scene')`); - await sleep(300); + await showTab('scene'); const backOnScene = await walkX(); await holdKey('ArrowRight', 400); await sleep(200); @@ -285,7 +292,6 @@ export async function editorRun(t) { scene.objects = scene.objects.filter((o) => !o.id.startsWith('er-')); p.assets = p.assets.filter((a) => !['drifter', 'walker', 'er-level'].includes(a.id)); `); - await sleep(300); check( 'the step leaves nothing of its own behind', (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0 && diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs index 3eec3307..44e31fa6 100644 --- a/tools/e2e/18-pause.mjs +++ b/tools/e2e/18-pause.mjs @@ -46,6 +46,7 @@ export async function pause(t) { select, evaluate, asset, + showTab, } = t; const playInfo = () => json('__gm.playInfo()'); const boxX = async () => (await entity('p-box'))?.x ?? NaN; @@ -78,7 +79,6 @@ export async function pause(t) { // 18b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps // ticking while the picture happens to look the same would pass a position check alone. await clickText('❚❚ Pause'); - await sleep(200); const held = await playInfo(); const heldX = await boxX(); await sleep(1000); @@ -108,11 +108,9 @@ export async function pause(t) { const hitsWhilePaused = await keyHits(); await press('ArrowRight'); await press('ArrowRight'); - await sleep(200); // 18d. Ctrl+Enter resumes, the same shortcut that started play. await press('Enter', { ctrl: true }); - await sleep(400); const resumed = await playInfo(); const resumedX = await boxX(); check( @@ -132,7 +130,6 @@ export async function pause(t) { ); // Without this the check above would also pass on a graph that never logs anything at all. await press('ArrowRight'); - await sleep(300); check( 'the same key does fire once the game is running again', (await keyHits()) === hitsBefore + 1, @@ -142,7 +139,6 @@ export async function pause(t) { // 18e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts // moving. A pause left standing would leave the editor unable to run anything. await press('Enter', { ctrl: true }); - await sleep(200); check('Ctrl+Enter holds it again', (await playInfo()).paused === true); await stop(); const afterStop = await playInfo(); @@ -162,7 +158,9 @@ export async function pause(t) { // 18f. The graph stays editable while the game runs, with the mouse as in edit mode: a click // selects the Move node, Delete removes it, and the running world stops drifting the box. await select({ kind: 'asset', assetId: 'pauser' }); - await evaluate(`__gm.showTab('graph')`); + await showTab('graph'); + // The live values are not drawn, they are carried: the running graph has to execute once while + // this tab is open before a node output holds anything. That is game time, not frames. await sleep(150); const outputs = (id) => json(`__gm.graph.nodeOutputs(${JSON.stringify(id)})`); const live = { @@ -193,7 +191,6 @@ export async function pause(t) { ); const beforeKey = await beadsOn('p-e4'); await press('ArrowRight'); - await sleep(80); const onKey = await beadsOn('p-e4'); await sleep(500); const afterKey = await beadsOn('p-e4'); @@ -204,7 +201,6 @@ export async function pause(t) { ); const movePos = await json(`__gm.graph.nodeAt('p-move')`); await click(movePos.x, movePos.y); - await sleep(100); check( 'a click selects a node while playing', JSON.stringify(await json('__gm.graph.selected()')) === '["p-move"]', @@ -212,17 +208,14 @@ export async function pause(t) { ); const area = await json('__gm.graph.canvasRect()'); await click(area.x + area.w - 15, area.y + area.h - 15, 'right'); - await sleep(100); check('right-clicking the canvas opens the node palette while playing', await has('Search nodes…')); await press('Escape'); - await sleep(100); check( 'Escape closes the palette and leaves the game running', !(await has('Search nodes…')) && (await playInfo()).playing === true, ); await click(movePos.x, movePos.y); await press('Delete'); - await sleep(150); const stoppedAt = await boxX(); await sleep(500); check( @@ -232,7 +225,6 @@ export async function pause(t) { `x ${stoppedAt} → ${await boxX()}`, ); await stop(); - await sleep(200); check( 'stopping takes the values off the nodes', JSON.stringify(await outputs('p-owner')) === '["object"]', @@ -267,7 +259,6 @@ export async function pause(t) { await sleep(400); const runningClock = await clock(); await clickText('❚❚ Pause'); - await sleep(150); const heldClock = await clock(); await sleep(800); const stillHeldClock = await clock(); @@ -283,7 +274,6 @@ export async function pause(t) { // Twice the scale has to move the clock and the world by the same factor: the delta a behavior // reads is the step the clock took, not a second measurement of the wall clock. await press('Enter', { ctrl: true }); - await sleep(150); const slowFrom = { t: await clock(), x: await boxX() }; await sleep(600); const slowTo = { t: await clock(), x: await boxX() }; diff --git a/tools/e2e/19-nested-behavior.mjs b/tools/e2e/19-nested-behavior.mjs index 5e26009a..c14c3ba5 100644 --- a/tools/e2e/19-nested-behavior.mjs +++ b/tools/e2e/19-nested-behavior.mjs @@ -87,7 +87,6 @@ export async function nestedBehavior(t) { ${MAKE_OBJECT} scene.objects.push(make('nb-box', 'crate', '', 700, 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 700, 'Caller')); `); - await sleep(400); // 19b. The call really runs, and the value really comes back: the box moves at dx × scale, and // dx is nothing the caller holds – it is what the nested graph returned. Without the result @@ -95,7 +94,6 @@ export async function nestedBehavior(t) { const boxX = async () => (await object('nb-box')).position.x; const idleX = await boxX(); await t.play(); - await sleep(300); const before = (await entity('nb-box')).x; await sleep(1000); const after = (await entity('nb-box')).x; @@ -106,7 +104,6 @@ export async function nestedBehavior(t) { `moved ${speed.toFixed(1)} px in ~1 s – 2 × ${SCALE} means the returned value arrived, 0 would mean nothing came back`, ); await stop(); - await sleep(300); check( 'the stored object is untouched by the run, as in any play session', (await boxX()) === idleX, @@ -115,10 +112,7 @@ export async function nestedBehavior(t) { // 19c. The node in the graph carries the *other* behavior's ports and name – neither is part of // its own type. - await evaluate( - `__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`, - ); - await sleep(300); + await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] }); const inPort = await portAt('nb-call', 'in1', false); const outPort = await portAt('nb-call', 'out1', true); check( @@ -137,27 +131,19 @@ export async function nestedBehavior(t) { // 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the // check that a port list derived from another graph is really derived and not copied. check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false))); - await evaluate( - `__gm.state.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] })`, - ); - await sleep(300); + await t.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] }); const addButton = await locate('+ Input'); check('the start node offers its inputs in the inspector', Boolean(addButton), JSON.stringify(addButton)); await shot('38-behavior-ports'); await click(addButton.x, addButton.y); - await sleep(200); await clickText('Number'); - await sleep(300); const declared = await startPorts(); check( 'adding an input declares it on the start node', declared.length === 2 && declared[1].key === 'in2' && declared[1].kind === 'number', JSON.stringify(declared), ); - await evaluate( - `__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`, - ); - await sleep(300); + await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] }); check( 'and the node in the other graph grew that input without being touched', Boolean(await portAt('nb-call', 'in2', false)), @@ -165,7 +151,6 @@ export async function nestedBehavior(t) { // 19e. Undo takes the port back, and with it the port on the calling node. await undo(); - await sleep(300); const afterUndo = await startPorts(); check( 'undo removes it in both graphs', @@ -180,14 +165,11 @@ export async function nestedBehavior(t) { const idsBefore = await nodeIds(); const bare = await json('__gm.graph.emptySpot()'); await click(bare.x, bare.y, 'right'); - await sleep(150); check('node palette opens', Boolean(await locate('Search nodes…'))); await typeText('Double'); - await sleep(200); // Enter takes the first match. NOT a click on the label: "Double" also names the asset in the // list on the left, and that is what a click by text would find first. await press('Enter'); - await sleep(300); const added = (await asset('nb-caller')).graph.nodes.find((n) => !idsBefore.includes(n.id)); check( 'picking a behavior in the palette drops in a node already set to it', @@ -197,7 +179,6 @@ export async function nestedBehavior(t) { JSON.stringify(added && { type: added.type, params: added.params }), ); await undo(); - await sleep(200); // 19g. Dragging a behavior out of the asset panel drops a call into the graph, where the // pointer let go of it. @@ -213,7 +194,6 @@ export async function nestedBehavior(t) { }); const dropAt = spot(0.25, 0.75); await t.dragAsset('nb-double', dropAt.x, dropAt.y); - await sleep(300); const dropped = await newNodeIn(beforeDrag); check( 'a behavior dragged from the asset panel becomes a call node', @@ -231,7 +211,6 @@ export async function nestedBehavior(t) { const beforeEmpty = await nodeIds(); const emptyRow = await json(`__gm.assetRow('nb-empty')`); await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y); - await sleep(300); const emptyCall = await newNodeIn(beforeEmpty); check( 'a behavior without a start node can be added just the same', @@ -255,7 +234,6 @@ export async function nestedBehavior(t) { const beforeSelf = await nodeIds(); const selfRow = await json(`__gm.assetRow('nb-caller')`); await t.dragAsset('nb-caller', spot(0.45, 0.5).x, spot(0.45, 0.5).y); - await sleep(300); const afterSelf = await nodeIds(); const { notice } = await t.state(); // `dragging` tells a refusal apart from a drag that never arrived – both leave the graph alone. @@ -277,7 +255,6 @@ export async function nestedBehavior(t) { check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller'); check('no breadcrumb on a graph opened from the outside', (await json('__gm.graph.crumbs()')).length === 0); await t.doubleClick(callNode.x, callNode.y); - await sleep(400); const opened = await evaluate('__gm.graph.current()'); check( 'double-clicking a call node opens the behavior it runs', @@ -307,7 +284,6 @@ export async function nestedBehavior(t) { await trail(), ); await t.doubleClick(inner.x, inner.y); - await sleep(400); check( 'each call opened further down adds a crumb', (await evaluate('__gm.graph.current()')) === 'nb-empty' && (await trail()) === 'Caller › Double › Empty*', @@ -330,7 +306,6 @@ export async function nestedBehavior(t) { const clickCrumb = async (name) => { const crumb = (await crumbs()).find((c) => c.name === name); await click(crumb.x, crumb.y); - await sleep(300); }; await clickCrumb('Double'); await shot('39c-breadcrumb-back'); @@ -350,12 +325,10 @@ export async function nestedBehavior(t) { const enterDouble = async () => { const call = await json(`__gm.graph.nodeAt('nb-call')`); await t.doubleClick(call.x, call.y); - await sleep(400); }; await enterDouble(); const emptyAgain = await json(`__gm.assetRow('nb-empty')`); await click(emptyAgain.x, emptyAgain.y); - await sleep(300); check( 'choosing another behavior in the asset panel ends the trail', (await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0, @@ -366,10 +339,8 @@ export async function nestedBehavior(t) { await enterDouble(); const picker = await json('__gm.graph.pickerAt()'); await click(picker.x, picker.y); - await sleep(150); const emptyItem = await locate('Empty', true); await click(emptyItem.x, emptyItem.y); - await sleep(300); check( 'choosing another behavior in the picker ends the trail', (await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0, @@ -385,7 +356,6 @@ export async function nestedBehavior(t) { p.assets = p.assets.filter((a) => !mine.includes(a.id)); scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic'); `); - await sleep(300); const left = await json(`{ objects: __gm.sceneObjects().filter((o) => o.id.startsWith('nb-')).length, assets: __gm.state.project.assets.filter((a) => a.id.startsWith('nb-')).length, diff --git a/tools/e2e/20-key-repeat.mjs b/tools/e2e/20-key-repeat.mjs index a009b0a4..70313db2 100644 --- a/tools/e2e/20-key-repeat.mjs +++ b/tools/e2e/20-key-repeat.mjs @@ -50,7 +50,6 @@ export async function keyRepeat(t) { }); await t.play(); - await sleep(300); // 20b. A hold shorter than the delay is still just a press. Without this the check below would // also pass on a node that simply fires every frame the key is down. @@ -75,11 +74,9 @@ export async function keyRepeat(t) { `plain +${afterLong.plain - afterShort.plain}, repeating +${gained} over 800 ms (press + ~${Math.round((800 - DELAY) / INTERVAL)} repeats expected)`, ); await stop(); - await sleep(300); // 20d. Both settings are on the node itself, so a graph can be tuned where it is built. await select({ kind: 'node', assetId: 'repeater', nodeId: 'r-repeat' }); - await sleep(300); const afterField = await json(`__gm.inspectorField('Repeat after (ms)')`); const intervalField = await json(`__gm.inspectorField('Repeat interval (ms)')`); check( @@ -105,7 +102,6 @@ export async function keyRepeat(t) { await click(afterField.x, afterField.y); await typeText('750'); await click(intervalField.x, intervalField.y); - await sleep(200); const params = await json( `__gm.state.project.assets.find((a) => a.id === 'repeater').graph.nodes.find((n) => n.id === 'r-repeat').params`, ); @@ -118,7 +114,6 @@ export async function keyRepeat(t) { // 20e. The typed value is what the next run obeys: 750 ms is longer than this hold, so the // repeating node falls silent although nothing else about the graph changed. await t.play(); - await sleep(300); const beforeTuned = await hits(); await holdKey('ArrowRight', 500); await sleep(200); @@ -129,7 +124,6 @@ export async function keyRepeat(t) { `repeating ${beforeTuned.repeating} → ${afterTuned.repeating} over 500 ms with the delay raised to 750 ms`, ); await stop(); - await sleep(300); const left = await rig.remove(); check( diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs index e55ddb97..87966485 100644 --- a/tools/e2e/21-list.mjs +++ b/tools/e2e/21-list.mjs @@ -31,7 +31,6 @@ export async function listTab(t) { }); __gm.state.select({ kind: 'asset', assetId: 'listy' }); `); - await sleep(150); // 21a. The inspector shows the count, not a comma-separated line. const button = await find('3 items'); @@ -40,7 +39,6 @@ export async function listTab(t) { await shot('01-count-button'); await click(button.x, button.y); - await sleep(200); check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list'); let info = await json('__gm.list.info()'); check('which shows the list it was opened from', info.title === 'Listy · nums', JSON.stringify(info)); @@ -53,7 +51,6 @@ export async function listTab(t) { await click(second.x, second.y); await typeText('42'); await press('Enter'); - await sleep(200); info = await json('__gm.list.info()'); check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]); await undo(); @@ -67,7 +64,6 @@ export async function listTab(t) { a.props.nums = Array.from({ length: 60 }, (_, i) => i); __gm.state.preview(); `); - await sleep(250); const longRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')]; check('a long list stacks its rows', longRows[0].y !== longRows[1].y, JSON.stringify(longRows)); await mutate(` @@ -75,7 +71,6 @@ export async function listTab(t) { a.props.nums = [30, 11, 44]; __gm.state.preview(); `); - await sleep(250); const shortRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')]; check( 'and a short list after it still stacks its own', @@ -85,7 +80,6 @@ export async function listTab(t) { // 21d. `+ Item` asks for a kind, since the list does not hold one for it. await clickText('+ Item'); - await sleep(150); check( 'a list of its own offers every kind, the struct included', (await json('__gm.list.info()')).offers.join(',') === @@ -109,23 +103,19 @@ export async function listTab(t) { // 21e. Back on the scene tab the button carries the current count. await mutate('__gm.state.openList(null)'); - await sleep(200); check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene'); await mutate(` __gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2]; __gm.state.preview(); `); - await sleep(200); check('and the count follows the value', (await find('2 items')) !== null); // 21f. A list declares nothing about its items, so the kind sits on the row: the mark in front of // it is the picker, and picking changes that one item and no other. const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`); await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })"); - await sleep(200); let mark = await json('__gm.list.markAt(0)'); await click(mark.x, mark.y); - await sleep(150); await pickKind('Text'); await sleep(250); let listy = await numsOf(); @@ -141,7 +131,6 @@ export async function listTab(t) { ); check('the variable itself says nothing about items', listy.variables[0].items === undefined); await undo(); - await sleep(250); listy = await numsOf(); check( 'undo puts the item back as a number', @@ -152,7 +141,6 @@ export async function listTab(t) { // 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged. mark = await json('__gm.list.markAt(1)'); await click(mark.x, mark.y); - await sleep(150); await pickKind('Color'); await sleep(250); listy = await numsOf(); @@ -168,7 +156,6 @@ export async function listTab(t) { ); await shot('03b-kinds'); await undo(); - await sleep(200); // 21h. The second tagged kind: a picture is an id, which stored on its own would be a plain text. // The row carries the picker the `texture` property has, frames of a sheet included. @@ -186,10 +173,8 @@ export async function listTab(t) { }); __gm.state.preview(); `); - await sleep(200); mark = await json('__gm.list.markAt(0)'); await click(mark.x, mark.y); - await sleep(150); await pickKind('Texture'); await sleep(250); // Switching kind carries the value along, here into an id that resolves to nothing – the same as @@ -201,7 +186,6 @@ export async function listTab(t) { ); const pic = await json('__gm.list.fieldAt(0)'); await click(pic.x, pic.y); - await sleep(250); check('the row carries the picture picker, sheet frames and all', (await find('Sheet / Frame 1')) !== null); await pickKind('Sheet / Frame 1'); await sleep(250); @@ -218,7 +202,6 @@ export async function listTab(t) { await shot('03c-texture'); await undo(); await undo(); - await sleep(250); check( 'undo puts the plain number back', JSON.stringify((await numsOf()).props.nums) === '[1,2]', @@ -230,10 +213,8 @@ export async function listTab(t) { delete __gm.__pictures; __gm.state.preview(); `); - await sleep(200); await mutate('__gm.state.openList(null)'); - await sleep(200); // The mark of a list is plain now, and still may not push the value off the inspector's grid. const lefts = await json( `[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`, @@ -246,7 +227,6 @@ export async function listTab(t) { // 21i. A list whose target is gone empties the tab rather than showing a stale one. await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })"); - await sleep(200); check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list'); await mutate(` const p = __gm.state.project; @@ -254,7 +234,6 @@ export async function listTab(t) { __gm.state.select(null); __gm.state.preview(); `); - await sleep(200); info = await json('__gm.list.info()'); check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title); await shot('04-empty'); @@ -274,7 +253,6 @@ export async function listTab(t) { __gm.state.select({ kind: 'asset', assetId: 'celly' }); __gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' }); `); - await sleep(250); info = await json('__gm.list.info()'); check('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info)); check( @@ -296,7 +274,6 @@ export async function listTab(t) { await click(firstField.x, firstField.y); await typeText('77'); await press('Enter'); - await sleep(250); const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`); check( 'editing one field of one item leaves every other value untouched', @@ -308,7 +285,6 @@ export async function listTab(t) { JSON.stringify(cells), ); await undo(); - await sleep(200); const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`); check( 'and undo takes back just that field', @@ -325,7 +301,6 @@ export async function listTab(t) { const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`); check('a struct item carries the add-a-field control', (await find('+ Field')) !== null); await clickText('+ Field'); - await sleep(150); await pickKind('Boolean'); await sleep(250); check( @@ -356,7 +331,6 @@ export async function listTab(t) { await press('a', { ctrl: true }); await typeText('row'); await press('Enter'); - await sleep(250); check( 'renaming a field of one item leaves the other item alone', JSON.stringify(await cellsOf()) === @@ -368,7 +342,6 @@ export async function listTab(t) { ); const fieldMark = await json("__gm.list.markAt(0, 'x')"); await click(fieldMark.x, fieldMark.y); - await sleep(150); await pickKind('Color'); await sleep(250); check( @@ -379,7 +352,6 @@ export async function listTab(t) { await undo(); await undo(); await undo(); - await sleep(250); check( 'and three undos take the shape back to what it was', JSON.stringify(await cellsOf()) === @@ -399,16 +371,13 @@ export async function listTab(t) { __gm.state.select({ kind: 'node', assetId: 'celly', nodeId: 'counter', nodeIds: ['counter'] }); __gm.state.preview(); `); - await sleep(300); const portButton = await find('0 items'); check('an unconnected list input reads as an item count', portButton !== null); await click(portButton.x, portButton.y); - await sleep(250); check('clicking it opens the List tab on the port', (await json('__gm.tab()')) === 'list'); info = await json('__gm.list.info()'); check('titled by the node and the port it belongs to', info.title === 'Count · list', info.title); await clickText('+ Item'); - await sleep(150); await pickKind('Number'); await sleep(250); const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`); @@ -425,10 +394,8 @@ export async function listTab(t) { __gm.showTab('scene'); __gm.state.preview(); `); - await sleep(200); await mutate('__gm.state.openList(null)'); - await sleep(200); const left = await json( '__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length', ); diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs index 09cca3fa..4a55e7de 100644 --- a/tools/e2e/22-struct.mjs +++ b/tools/e2e/22-struct.mjs @@ -42,7 +42,6 @@ export async function structVariable(t) { }); __gm.state.select({ kind: 'asset', assetId: 'structy' }); `); - await sleep(150); // 22a. The declaration is what the inspector draws: a head that can add fields, a row per field. check('a struct variable carries the add-a-field control', (await find('+ Field')) !== null); @@ -62,7 +61,6 @@ export async function structVariable(t) { await click(xField.x, xField.y); await typeText('42'); await press('Enter'); - await sleep(200); check('typing in a field writes that field', (await piece()).x === 42, JSON.stringify(await piece())); const tagField = await json("__gm.inspectorField('tag')"); @@ -70,7 +68,6 @@ export async function structVariable(t) { await press('a', { ctrl: true }); await typeText('ell'); await press('Enter'); - await sleep(200); check( 'and the next field edited keeps the one before it', JSON.stringify(await piece()) === '{"x":42,"tag":"ell"}', @@ -85,7 +82,6 @@ export async function structVariable(t) { // 22d. Adding a field reaches the declaration and the value at once. await clickText('+ Field'); - await sleep(150); await pickKind('Boolean'); await sleep(250); const grown = await piece(); @@ -93,7 +89,6 @@ export async function structVariable(t) { check('and the new field has a row', (await find('field1')) !== null); await shot('02-field-added'); await undo(); - await sleep(200); check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece())); // 22e. Renaming a field carries its value along. @@ -102,7 +97,6 @@ export async function structVariable(t) { await press('a', { ctrl: true }); await typeText('column'); await press('Enter'); - await sleep(250); check( 'renaming a field keeps its value', JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}', @@ -112,11 +106,9 @@ export async function structVariable(t) { // 22f. A field may itself be a list. Its row is the same "n items" button a list property has, // and it opens the List tab one level down – on the field, not on the whole bundle. await clickText('+ Field'); - await sleep(150); // "List" is also the tab bar's own label, so the menu entry is reached as the last drawn one. const listOption = await json(`__gm.locate('List', true)`); await click(listOption.x, listOption.y); - await sleep(250); check( '+ Field ▸ List gives the bundle an empty list', Array.isArray((await piece()).field1), @@ -126,7 +118,6 @@ export async function structVariable(t) { check('and that field reads as an item count', itemsButton !== null); await click(itemsButton.x, itemsButton.y); - await sleep(250); check('clicking it opens the List tab', (await json('__gm.tab()')) === 'list'); const info = await json('__gm.list.info()'); check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title); @@ -134,7 +125,6 @@ export async function structVariable(t) { // The depth the stored value goes ends here: this list sits in a struct field, so a struct item // would be dropped by the next write. It is left out of the menu rather than doing nothing. await clickText('+ Item'); - await sleep(150); check( 'the menu of a list in a struct field leaves the struct kind out', (await find('Number')) !== null && (await find('Struct')) === null, @@ -150,7 +140,6 @@ export async function structVariable(t) { await shot('03-list-field'); await undo(); await undo(); - await sleep(200); check( 'undo takes the list field back out of the bundle', !('field1' in (await piece())), @@ -158,7 +147,6 @@ export async function structVariable(t) { ); await mutate('__gm.state.openList(null)'); - await sleep(200); // 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a // field edit rebuilds the whole bundle, so the untouched fields have to come from what the row @@ -177,7 +165,6 @@ export async function structVariable(t) { }); `); await select({ kind: 'object', holderId: 'scene1', objectId: 'ostruct' }); - await sleep(200); check('a placed behavior shows its bundle, filled from the asset', (await find('tee')) !== null); // 22h. It reads exactly like the asset does: a head carrying the struct mark, then one row per @@ -236,7 +223,6 @@ export async function structVariable(t) { await press('a', { ctrl: true }); await typeText('7'); await press('Enter'); - await sleep(250); check( 'the first override of one field keeps the asset values of the others', JSON.stringify(await placed()) === '{"column":7,"tag":"tee"}', @@ -245,7 +231,6 @@ export async function structVariable(t) { await shot('04-object-override'); await undo(); - await sleep(200); check( 'and undo takes the override away again, not just the one field', (await json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece")) === null, @@ -260,7 +245,6 @@ export async function structVariable(t) { __gm.state.select(null); __gm.state.preview(); `); - await sleep(200); const left = await json( '__gm.state.project.assets.filter((a) => a.id === "structy").length +' + ' __gm.state.activeScene.objects.filter((o) => o.id === "ostruct").length', diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs index fed498cc..6ab89726 100644 --- a/tools/e2e/23-variable-nodes.mjs +++ b/tools/e2e/23-variable-nodes.mjs @@ -35,44 +35,24 @@ const logicGraph = { }; export async function variableNodes(t) { - const { - check, - sleep, - evaluate, - json, - shot, - click, - locate, - undo, - entity, - asset, - mutate, - stop, - typeText, - press, - has, - } = t; + const { check, sleep, json, shot, click, locate, undo, entity, asset, mutate, stop, typeText, press, has } = + t; const portAt = (nodeId, key, isOutput) => json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); const portsOf = async (nodeId) => (await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []; const select = async (nodeId) => { - await evaluate( - `__gm.state.select({ kind: 'node', assetId: 'vp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`, - ); - await sleep(300); + await t.select({ kind: 'node', assetId: 'vp-logic', nodeId, nodeIds: [nodeId] }); }; /** The inspector is drawn over the graph, so the last match is the row rather than the port. */ const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); - await sleep(200); }; /** The Set checkbox of the newest variable row: the control right of the last "Set" label. */ const clickLastSet = async () => { const box = await json(`__gm.inspectorField('Set', true)`); await click(box.x, box.y); - await sleep(300); return box; }; @@ -93,12 +73,10 @@ export async function variableNodes(t) { ${MAKE_OBJECT} scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds')); `); - await sleep(400); // 23b. The run: both variables are written by one node and read back by another, and the two // speeds stay apart – 2 to the right for every 3 down. await t.play(); - await sleep(300); const before = await entity('vp-box'); await sleep(1000); const after = await entity('vp-box'); @@ -110,7 +88,6 @@ export async function variableNodes(t) { `moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`, ); await stop(); - await sleep(300); // 23c. Every variable is an output; a written one is an input too, and only a writing node has // flow pins. @@ -134,9 +111,7 @@ export async function variableNodes(t) { check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add)); await shot('01-variable-ports'); await click(add.x, add.y); - await sleep(200); await clickLast('vz'); - await sleep(300); const grown = await portsOf('vp-set'); check( 'picking one adds it as a read port, typed from the variable', @@ -161,18 +136,14 @@ export async function variableNodes(t) { ); await shot('02-variable-set'); await undo(); - await sleep(300); await undo(); - await sleep(300); check('undo takes both back', (await portsOf('vp-set')).length === 2); // 23e. Pointing a port at another variable keeps the edge hanging on it – a correction, not a // rewiring. `vp-e3` runs from this port to Move's dx. await select('vp-get'); await clickLast('vx'); - await sleep(200); await clickLast('vz'); - await sleep(300); const swapped = await portsOf('vp-get'); const edgeStill = await json( `(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`, @@ -183,7 +154,6 @@ export async function variableNodes(t) { JSON.stringify({ swapped, edgeStill }), ); await undo(); - await sleep(300); check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx'); // 23f. Collapsed, the ports are out of sight, so the title says what the node does – and only then. @@ -191,7 +161,6 @@ export async function variableNodes(t) { const toggle = async (id) => { const chevron = await json(`__gm.graph.chevronAt(${JSON.stringify(id)})`); await click(chevron.x, chevron.y); - await sleep(250); }; /** `locate` matches exactly, so a title cut short by a too-narrow node is not found. */ const drawnWhole = async (text) => { @@ -222,7 +191,6 @@ export async function variableNodes(t) { JSON.stringify({ switched, narrow }), ); await undo(); - await sleep(300); await toggle('vp-set'); await toggle('vp-get'); const open = [await titleOf('vp-set'), await titleOf('vp-get')]; @@ -250,16 +218,13 @@ export async function variableNodes(t) { a.props.shapes = [{ x: 0, y: 0 }]; a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))}); `); - await sleep(300); await select('vp-pack'); const from = await locate('From variable'); check('a Pack offers the shapes its behavior declares', Boolean(from), JSON.stringify(from)); await click(from.x, from.y); - await sleep(200); await shot('03-from-variable'); await clickLast('piece'); - await sleep(300); const seeded = await portsOf('vp-pack'); check( 'picking a struct variable declares every field at once, each of its own kind', @@ -270,15 +235,12 @@ export async function variableNodes(t) { check('and the node grew those inputs', Boolean(await portAt('vp-pack', 'cells', false))); await shot('04-fields-seeded'); await undo(); - await sleep(300); check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0); // 23h. A list declares nothing, so the shape comes off the struct items in it – the bundle a Pack // builds for such a list. await click(from.x, from.y); - await sleep(200); await clickLast('shapes (item)'); - await sleep(300); const fromItem = await portsOf('vp-pack'); check( 'a list offers the shape of the struct items it holds', @@ -286,7 +248,6 @@ export async function variableNodes(t) { JSON.stringify(fromItem), ); await undo(); - await sleep(300); // 23i. A behavior's own inputs are not fields of a bundle, so the helper stays out of that section. await mutate(` @@ -294,7 +255,6 @@ export async function variableNodes(t) { ${JSON.stringify(node('vp-start', 'BehaviorStart', 560, 600))}, ); `); - await sleep(200); await select('vp-start'); // `locate` throws when the text is absent, which is the wrong shape for "is it there?". const helper = await json(`__gm.locate('From variable', false)`); @@ -311,12 +271,9 @@ export async function variableNodes(t) { // Bare canvas below every node, so the click cannot land on the Pack. const bareSpot = await json('__gm.graph.emptySpot()'); await click(bareSpot.x, bareSpot.y, 'right'); - await sleep(150); // Enter takes the first hit in category order, so search by a phrase only its description has. await typeText('switched to Set'); - await sleep(200); await press('Enter'); - await sleep(300); const added = (await asset('vp-logic')).graph.nodes.find((n) => !idsBefore.includes(n.id)); check('the palette offers the Variables node', added?.type === 'Variables', JSON.stringify(added)); const variableId = added?.id ?? ''; @@ -324,7 +281,6 @@ export async function variableNodes(t) { const bare = !(await portAt(variableId, 'exec', false)); await clickLast('+ Variable'); await clickLast('vz'); - await sleep(300); await clickLastSet(); check( 'it starts without pins, and picking its variable leaves the title Variables', @@ -346,7 +302,6 @@ export async function variableNodes(t) { ${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'vz'))}, ); `); - await sleep(300); await select('vp-gone'); check( 'a port naming a variable the behavior does not have is marked in the inspector', @@ -357,7 +312,6 @@ export async function variableNodes(t) { await sleep(1000); const counted = (await entity('vp-brain'))?.props.vz; await stop(); - await sleep(300); check( 'in the run it reads through its own output and writes the sum back, so vz climbs', typeof counted === 'number' && counted > 20, @@ -372,7 +326,6 @@ export async function variableNodes(t) { __gm.state.select(null); __gm.state.preview(); `); - await sleep(300); const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length'); check('the step leaves the fixture as it found it', left === 0, String(left)); } diff --git a/tools/e2e/24-property-nodes.mjs b/tools/e2e/24-property-nodes.mjs index 273c95be..685c5cc1 100644 --- a/tools/e2e/24-property-nodes.mjs +++ b/tools/e2e/24-property-nodes.mjs @@ -29,7 +29,7 @@ const logicGraph = { }; export async function propertyNodes(t) { - const { check, sleep, evaluate, json, shot, click, doubleClick, locate, find, undo, entity, asset } = t; + const { check, evaluate, json, shot, click, doubleClick, locate, find, undo, entity, asset } = t; const { mutate, stop, typeText, press } = t; const portsOf = async (nodeId) => ((await asset('pp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []).map( @@ -38,44 +38,34 @@ export async function propertyNodes(t) { const title = (nodeId) => json(`__gm.graph.nodeLabel(${JSON.stringify(nodeId)})`); const settings = async (nodeId) => (await json(`__gm.graph.nodeSettings(${JSON.stringify(nodeId)})`)) ?? []; const select = async (nodeId) => { - await evaluate( - `__gm.state.select({ kind: 'node', assetId: 'pp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`, - ); - await sleep(300); + await t.select({ kind: 'node', assetId: 'pp-logic', nodeId, nodeIds: [nodeId] }); }; /** A port section takes its choices when it is built, and re-selecting the same node is a no-op. */ const rebuildInspector = async () => { await evaluate('__gm.state.select(null)'); - await sleep(150); await select('pp-set'); }; /** The inspector is drawn over the graph, so the last match is the row rather than the port. */ const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); - await sleep(200); }; const offered = async (shotName) => { await clickLast('+ Property'); const texts = await json('__gm.overlayTexts()'); if (shotName) await shot(shotName); await press('Escape'); - await sleep(200); return texts; }; const addProperty = async (search) => { await clickLast('+ Property'); await typeText(search); - await sleep(150); await press('Enter'); - await sleep(300); }; const choose = async (setting, option) => { const field = await json(`__gm.inspectorField(${JSON.stringify(setting)}, true)`); await click(field.x, field.y); - await sleep(200); await clickLast(option); - await sleep(200); }; /** The name field, not the node's port or the unconnected-input row below it that say the same. */ const rename = async (from, to) => { @@ -85,7 +75,6 @@ export async function propertyNodes(t) { await doubleClick(field.x, field.y); await typeText(to); await press('Enter'); - await sleep(300); }; // 24a. A box wearing a behavior that reshapes it through one node. @@ -104,15 +93,12 @@ export async function propertyNodes(t) { ${MAKE_OBJECT} scene.objects.push(make('pp-box', 'crate', '', 900, 'Reshaped box'), make('pp-brain', 'pp-logic', 'pp-box', 900, 'Reshape')); `); - await sleep(400); // 24b. The run: one node writes both properties, and its own output reads the width back after. await t.play(); - await sleep(400); const box = (await entity('pp-box'))?.props; const seen = (await entity('pp-brain'))?.props.seen; await stop(); - await sleep(300); check( 'one node writes several properties and reads them back', box?.width === 40 && box?.color === 0xff0000 && seen === 40, @@ -204,7 +190,6 @@ export async function propertyNodes(t) { ); const toggle = await json(`__gm.inspectorField('Set', true)`); await click(toggle.x, toggle.y); - await sleep(300); const inputs = await json(`__gm.graph.nodeInputs('pp-set')`); check( "switched to Set, its typed-in value starts at the property's own default", @@ -214,7 +199,6 @@ export async function propertyNodes(t) { await shot('03-height-set'); await undo(); await undo(); - await sleep(300); check('undo takes both back', (await portsOf('pp-set')).length === 2, (await portsOf('pp-set')).join()); // 24f. Another type offers its own fields; a port it does not have is marked, not dropped. @@ -234,7 +218,6 @@ export async function propertyNodes(t) { ); await shot('04-camera'); await undo(); - await sleep(300); check('undo switches back', (await settings('pp-set')).join() === 'Type: Sprite', await settings('pp-set')); // 24g. Any names its ports by hand, and the kind is picked when adding one. A name that happens @@ -294,7 +277,6 @@ export async function propertyNodes(t) { __gm.state.select(null); __gm.state.preview(); `); - await sleep(300); const left = await json('__gm.state.project.assets.filter((a) => a.id === "pp-logic").length'); check('the step leaves the fixture as it found it', left === 0, String(left)); } diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index d9d80856..dd7113c6 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -59,7 +59,8 @@ const logicGraph = (walkId) => ({ }); export async function animation(t) { - const { check, sleep, json, evaluate, shot, mutate, press, click, clickText, drag, undo, redo } = t; + const { check, sleep, json, evaluate, shot, mutate, press, click, clickText, drag, undo, redo, showTab } = + t; const { mouse, typeText } = t; const { entity, stop } = t; const info = () => json('__gm.animation.current()'); @@ -77,7 +78,6 @@ export async function animation(t) { await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); - await sleep(200); // 25a. A strip of four cells, cut into four frames and moved to the head of the asset list. await evaluate(PASTE_STRIP); @@ -94,14 +94,11 @@ export async function animation(t) { texture.frames = ${JSON.stringify(FRAME_IDS)}.map((id, i) => ({ id, name: 'Cell ' + (i + 1), x: i * 16, y: 0, w: 16, h: 16 })); p.assets.unshift(texture); `); - await sleep(400); // 25b. The texture's context menu makes an animation of its frames, as one undo step. const row = await json(`__gm.assetRow(${JSON.stringify(strip)})`); await click(row.x, row.y, 'right'); - await sleep(300); await clickText('Create animation from texture'); - await sleep(400); const made = await json( "__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, name: a.name, frames: a.frames, props: a.props }))", ); @@ -120,10 +117,8 @@ export async function animation(t) { JSON.stringify({ selection: await json('__gm.state.selection'), walk }), ); await undo(); - await sleep(300); const undone = await json("__gm.state.project.assets.filter((a) => a.type === 'animation').length"); await redo(); - await sleep(300); check( 'undo takes asset and frames back in one step, redo brings both', undone === 0 && JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS), @@ -133,7 +128,6 @@ export async function animation(t) { // 25c. The Animation tab shows it and plays it; the inspector keeps its two properties. await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(walk.id)} })`); await openTab(t, 'animation'); - await sleep(300); check( 'the inspector edits frames per second and playback', (await t.has('Frames per second')) && (await t.has('Playback')) && (await t.has('Loop')), @@ -184,7 +178,6 @@ export async function animation(t) { // 25d. Pausing holds the frame on screen, and the preview shows that frame's picture. await click(...Object.values(await json('__gm.animation.playButton()'))); - await sleep(200); const paused = await info(); await sleep(350); check( @@ -204,7 +197,6 @@ export async function animation(t) { const tile = async (i) => json(`__gm.animation.tileCentre(${i})`); const third = await tile(2); await click(third.x, third.y); - await sleep(250); const picked = await info(); check( 'clicking a tile selects it and the preview shows it', @@ -234,7 +226,6 @@ export async function animation(t) { nearestCell(await json(`__gm.pixelAt(${pointer.x}, ${pointer.y})`)) === 'blue', ); await drag(pointer.x, pointer.y, pointer.x + 40, pointer.y - 30, { button: 'middle' }); - await sleep(200); const panned = (await info()).preview; check( 'the middle button pans it', @@ -251,7 +242,6 @@ export async function animation(t) { ); await shot('02b-zoomed'); await clickText('Fit'); - await sleep(250); check( 'Fit puts the preview back', JSON.stringify((await info()).preview) === JSON.stringify(fitted), @@ -261,7 +251,6 @@ export async function animation(t) { // 25f. Dragging a tile into another gap reorders the frames, as one undo step. const first = await tile(0); await drag(first.x, first.y, third.x + 39, third.y, 10); - await sleep(300); const reordered = await frames(walk.id); check( 'dragging the first tile behind the third moves that frame there', @@ -270,15 +259,12 @@ export async function animation(t) { JSON.stringify({ reordered, info: await info() }), ); await undo(); - await sleep(250); check('undo puts it back', JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS)); // 25g. Delete removes the selected tile. const second = await tile(1); await click(second.x, second.y); - await sleep(200); await press('Delete'); - await sleep(250); const deleted = await frames(walk.id); check( 'Delete removes the selected frame and nothing else', @@ -287,24 +273,23 @@ export async function animation(t) { JSON.stringify(deleted), ); await undo(); - await sleep(250); // 25h. + Frames offers a sheet's frames at once and inserts them after the selection. await clickText('+ Frames'); - await sleep(300); + // Search first: in the full suite a 17-frame sheet sorts ahead of this one and pushes the later + // cells out of the list's view, and a row outside its list is not on the screen to be read. + // The filter also sharpens the check, because the animation is called "strip 2" and would match. + await typeText('strip'); const offered = await json('__gm.overlayTexts()'); check( '+ Frames offers the whole strip and each cell, but no animation', offered.includes('strip – all 4 frames') && offered.includes('strip / Cell 3') && + offered.includes('strip / Cell 4') && !offered.some((s) => s.startsWith('strip 2')), JSON.stringify(offered), ); - // In the full suite a 17-frame sheet sorts first and pushes this row out of the list's view. - await typeText('strip'); - await sleep(150); await clickText('strip – all 4 frames'); - await sleep(300); const grown = await frames(walk.id); check( 'all four go in right after the selected frame', @@ -312,7 +297,6 @@ export async function animation(t) { JSON.stringify(grown), ); await undo(); - await sleep(250); // 25i. A sprite wears the animation through its Texture picker. await mutate(` @@ -331,7 +315,6 @@ export async function animation(t) { await sleep(500); const picker = await json("__gm.inspectorField('Texture', true)"); await click(picker.x, picker.y); - await sleep(300); const choices = await json('__gm.overlayTexts()'); check( 'the Texture picker offers the animation first, marked as one', @@ -339,14 +322,13 @@ export async function animation(t) { JSON.stringify(choices), ); await clickText('strip 2 (animation)'); - await sleep(300); check( 'picking it makes the sprite wear the animation', (await json("__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture")) === walk.id, ); // 25j. The scene shows the first frame and does not play. - await evaluate("__gm.showTab('scene')"); + await showTab('scene'); await sleep(700); const box = await json("__gm.objectRect('an-box')"); const sceneColour = async () => @@ -374,22 +356,18 @@ export async function animation(t) { // lies outside; `outward` says which way the neighbouring cell would be. const edgePixels = async (side, target, outward) => { await t.play(); - await sleep(300); const first = await edgesOf(); await stop(); - await sleep(200); const shift = (target - fraction(first[side]) + 1) % 1; await mutate( `__gm.state.activeScene.objects.find((o) => o.id === 'an-box').position.x += ${shift / first.scale}`, ); await t.play(); - await sleep(300); const e = await edgesOf(); const x = Math.floor(e[side]); const edge = rgb(await json(`__gm.pixelAt(${x}, ${e.y})`)); const outside = rgb(await json(`__gm.pixelAt(${x + outward * 2}, ${e.y})`)); await stop(); - await sleep(200); return { fraction: fraction(e[side]).toFixed(2), edge, outside }; }; // The edge pixel is the cell's green over the background and nothing else: red and blue rise @@ -420,11 +398,9 @@ export async function animation(t) { await mutate( `__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = ${JSON.stringify(walk.id)}`, ); - await sleep(300); // 25k. Play runs it: the pixels the game draws change colour over time. await t.play(); - await sleep(150); const colours = new Set(); for (let i = 0; i < 10; i++) { const at = await json("__gm.gameScreen('an-box')"); @@ -434,7 +410,6 @@ export async function animation(t) { const clock = (await entity('an-box'))?.animation; await shot('04-playing'); await stop(); - await sleep(300); check( 'in play the sprite runs through the frames', [...colours].filter((c) => NAMES.includes(c)).length >= 3 && clock?.id === walk.id && clock.time > 0, @@ -460,7 +435,6 @@ export async function animation(t) { scene.objects.push(make('an-brain', 'an-logic', 'an-box', 0, 'Animation logic')); __gm.state.select(null); `); - await sleep(400); await t.play(); await sleep(900); const brain = (await entity('an-brain'))?.props; @@ -470,7 +444,6 @@ export async function animation(t) { ? nearestCell(await json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`)) : 'none'; await stop(); - await sleep(300); check( 'On animation finished fires once when a once animation ends', brain?.done === 1, @@ -487,11 +460,8 @@ export async function animation(t) { const pickPicture = async (label, name) => { const field = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`); await click(field.x, field.y); - await sleep(200); await typeText(name); - await sleep(150); await press('Enter'); - await sleep(300); }; await mutate(` const logic = __gm.state.project.assets.find((a) => a.id === 'an-logic'); @@ -500,7 +470,6 @@ export async function animation(t) { logic.props.pic = ''; __gm.state.select({ kind: 'node', assetId: 'an-logic', nodeId: 'an-pic', nodeIds: ['an-pic'] }); `); - await sleep(400); await pickPicture('Value', walk.name); const picNode = (await t.asset('an-logic')).graph.nodes.find((n) => n.id === 'an-pic'); const picRows = (await json("__gm.graph.nodeSettings('an-pic')")) ?? []; @@ -510,7 +479,6 @@ export async function animation(t) { JSON.stringify({ params: picNode?.params, rows: picRows }), ); await evaluate("__gm.state.select({ kind: 'asset', assetId: 'an-logic' })"); - await sleep(400); await pickPicture('pic', walk.name); check( 'a texture variable is picked with the same picker', @@ -522,9 +490,7 @@ export async function animation(t) { // 25m. Deleting the texture counts the animations that play its frames. const again = await json(`__gm.assetRow(${JSON.stringify(strip)})`); await click(again.x, again.y, 'right'); - await sleep(300); await clickText('Delete…'); - await sleep(300); const dialog = await json('__gm.overlayTexts()'); await shot('05-delete-dialog'); check( @@ -535,7 +501,6 @@ export async function animation(t) { check('and its bold Delete button is not cut short', dialog.includes('Delete'), JSON.stringify(dialog)); const cancel = await t.locate('Cancel', true); await click(cancel.x, cancel.y); - await sleep(300); await mutate(` const p = __gm.state.project; @@ -546,7 +511,6 @@ export async function animation(t) { __gm.state.select(null); __gm.state.preview(); `); - await sleep(300); const left = await json( "__gm.state.project.assets.filter((a) => a.type === 'animation' || a.id === 'an-sprite' || a.id === 'an-logic').length", ); diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index 2f6e5e0d..16758c0d 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -23,8 +23,9 @@ export async function stateMachine(t) { doubleClick, drag, mouse, + showTab, } = t; - const { typeText, undo, redo, world, play, stop } = t; + const { settle, typeText, undo, redo, world, play, stop } = t; const machines = () => json(`__gm.state.project.assets .filter((a) => a.type === 'statemachine') @@ -34,29 +35,22 @@ export async function stateMachine(t) { const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); - await sleep(200); }; const stateLabel = (id) => json(`__gm.stateMachine.nodeLabel(${JSON.stringify(id)})`); const selection = () => json('__gm.state.selection'); const outputsOf = async (nodeId) => (await json(`__gm.graph.nodeOutputs(${JSON.stringify(nodeId)})`)) ?? []; const inputsOf = async (nodeId) => (await json(`__gm.graph.nodeInputs(${JSON.stringify(nodeId)})`)) ?? []; const selectNode = async (nodeId) => { - await evaluate( - `__gm.state.select({ kind: 'node', assetId: 'sm-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`, - ); - await sleep(400); + await t.select({ kind: 'node', assetId: 'sm-logic', nodeId, nodeIds: [nodeId] }); }; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); - await sleep(200); // 26a. The + menu creates a state machine; selected, it opens its own tab. await clickText('+'); - await sleep(200); await clickLast('State machine'); - await sleep(400); const made = (await machines())[0]; check( 'the + menu creates a state machine asset', @@ -86,9 +80,7 @@ export async function stateMachine(t) { const canvas = await json('__gm.stateMachine.canvasRect()'); const addState = async (dy) => { await click(canvas.x + 80, canvas.y + dy, 'right'); - await sleep(300); await clickLast('State'); - await sleep(300); }; await addState(60); await addState(220); @@ -122,7 +114,6 @@ export async function stateMachine(t) { const preSnap = JSON.stringify(await states()); // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. await click(snapLabel.x + 30, snapLabel.y); - await sleep(150); check( 'the Snap toggle switches it on without moving a state', (await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap, @@ -133,7 +124,6 @@ export async function stateMachine(t) { const from = await json(`__gm.stateMachine.nodeAt(${JSON.stringify(dragged)})`); const wasAt = await stateXY(dragged); await drag(from.x, from.y, from.x + 50, from.y + 50); - await sleep(200); const nowAt = await stateXY(dragged); check( 'a dragged state lands on the grid, not where the pointer let go', @@ -148,7 +138,6 @@ export async function stateMachine(t) { `${backAt.x},${backAt.y}`, ); await click(snapLabel.x + 30, snapLabel.y); - await sleep(150); check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false); // 26c. The inspector renames the selected state; the node follows. @@ -157,7 +146,6 @@ export async function stateMachine(t) { await doubleClick(nameField.x, nameField.y); await typeText('Open'); await press('Enter'); - await sleep(300); const renamed = (await states())[1]; check( 'typing in the Name field renames the state, on the node too', @@ -181,7 +169,6 @@ export async function stateMachine(t) { // 26e. The canvas palette offers a state and nothing else. await click(canvas.x + canvas.w - 40, canvas.y + canvas.h - 40, 'right'); - await sleep(300); const offered = await json('__gm.overlayTexts()'); check( 'right-click offers a State and no behavior node', @@ -189,7 +176,6 @@ export async function stateMachine(t) { JSON.stringify(offered), ); await press('Escape'); - await sleep(200); // 26e2. A transition is an ordinary edge, but it hangs on ports the editor never draws: a // transition joins two whole states, so there is no socket to aim at and none to grab. @@ -217,25 +203,20 @@ export async function stateMachine(t) { const makeTransition = async (fromId, toId) => { const from = await centreOfState(fromId); await click(from.x, from.y, 'right'); - await sleep(300); await clickLast('Make transition'); - await sleep(250); const to = await centreOfState(toId); await click(to.x, to.y); - await sleep(350); }; // The right-click way in, since there is no pin to drag from any more. const firstCentre = await centreOfState(first); await click(firstCentre.x, firstCentre.y, 'right'); - await sleep(300); check( "a state's menu offers Make transition", (await json('__gm.overlayTexts()')).includes('Make transition'), JSON.stringify(await json('__gm.overlayTexts()')), ); await clickLast('Make transition'); - await sleep(250); check( 'armed, the canvas says which click it is waiting for', (await json('__gm.stateMachine.hint()')).includes('Click the state'), @@ -247,7 +228,6 @@ export async function stateMachine(t) { ); await shot('05a-armed'); await press('Escape'); - await sleep(250); check( 'Escape drops the attempt, drawing nothing', (await transitions()).length === 0 && (await json('__gm.stateMachine.danglingEdge()')) === false, @@ -265,25 +245,20 @@ export async function stateMachine(t) { (await json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(drawn[0]?.id)})`)) === true, ); await undo(); - await sleep(300); check('undo takes the transition back', (await transitions()).length === 0); await redo(); - await sleep(300); check('redo puts it back', (await transitions()).length === 1); await shot('05-transition'); // 26e3. Naming a transition, from the list the inspector shows on the state it leaves. const edgeId = (await transitions())[0]?.id; - await evaluate( - `__gm.state.select({ kind: 'node', assetId: ${JSON.stringify(made.id)}, nodeId: ${JSON.stringify(first)}, nodeIds: [${JSON.stringify(first)}] })`, - ); - await sleep(400); + await t.select({ kind: 'node', assetId: made.id, nodeId: first, nodeIds: [first] }); check( 'the inspector lists the transitions of the selected state, both ways round', (await has('TRANSITIONS OUT')) && (await has('TRANSITIONS IN')), ); await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`); - await sleep(400); + await settle(); check( 'a named transition carries the name on the edge itself', (await transitions())[0]?.label === 'alarm', @@ -297,17 +272,15 @@ export async function stateMachine(t) { const field = await locate('alarm', true); // Double-click, so the name is selected and replaced – a single click only parks the caret in it. await doubleClick(field.x, field.y); - await sleep(200); await typeText('lost'); await press('Enter'); - await sleep(400); check( 'and the panel row is a real field: typing in it renames the transition', (await transitions())[0]?.label === 'lost', JSON.stringify(await transitions()), ); await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`); - await sleep(300); + await settle(); await shot('05b-named'); // 26e4. Both ways round between the same pair: the two arrows must not lie on top of each other. @@ -320,7 +293,6 @@ export async function stateMachine(t) { check('both arrows are stroked', drawnBoth.every(Boolean), JSON.stringify(drawnBoth)); await shot('05c-both-ways'); await undo(); - await sleep(300); // 26f. A behavior reads the machine: Set state on start, State branch every frame. const rig = await scaffoldBehavior(t, { @@ -389,7 +361,6 @@ export async function stateMachine(t) { id: 'sm-e5', from: { node: 'sm-set', port: 'exec' }, to: { node: 'sm-next', port: 'exec' }, }); `); - await sleep(300); await play(); await sleep(600); const moved = (await world())?.log ?? []; @@ -405,7 +376,6 @@ export async function stateMachine(t) { const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'no such transition'; `); - await sleep(300); await play(); await sleep(600); const stayed = (await world())?.log ?? []; @@ -419,20 +389,16 @@ export async function stateMachine(t) { const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'alarm'; `); - await sleep(200); - await sleep(200); // 26f3. A start state: marked from the state's own menu, worn as a ring, entered by every // instance before anything reads the machine – and heard as `On state entered`. - await evaluate("__gm.showTab('statemachine')"); - await sleep(300); + await showTab('statemachine'); const startOf = () => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).start ?? ''`); const ringOn = (id) => json(`__gm.stateMachine.startRing(${JSON.stringify(id)})`); const marks = () => json('__gm.stateMachine.stateMarks()'); const startCentre = await centreOfState(first); await click(startCentre.x, startCentre.y, 'right'); - await sleep(300); const stateMenu = await json('__gm.overlayTexts()'); check( 'a state offers to become the start state', @@ -440,7 +406,6 @@ export async function stateMachine(t) { JSON.stringify(stateMenu), ); await clickLast('Set as start state'); - await sleep(400); check( 'the mark is kept on the asset and drawn as a ring on that state alone', (await startOf()) === first && (await ringOn(first)) === true && (await ringOn(second)) === false, @@ -458,7 +423,6 @@ export async function stateMachine(t) { ); b.graph.edges.push(${JSON.stringify(edge('sm-e6', 'sm-entered', first, 'sm-entered-log', 'exec'))}); `); - await sleep(300); await play(); await sleep(600); const started = (await world())?.log ?? []; @@ -474,9 +438,8 @@ export async function stateMachine(t) { ); // 26f4. The tab shows where the run is: weakly for any instance, strongly for the selected one. - await evaluate("__gm.showTab('statemachine')"); + await showTab('statemachine'); await evaluate('__gm.state.select(null)'); - await sleep(400); const loose = await marks(); check( 'while playing, the state the machine is in is marked', @@ -486,7 +449,6 @@ export async function stateMachine(t) { await evaluate( "__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: 'sm-box', objectIds: ['sm-box'] })", ); - await sleep(400); const picked2 = await marks(); check( "selecting the object makes its instance's state the strong one", @@ -495,8 +457,7 @@ export async function stateMachine(t) { ); await shot('06-live-state'); await stop(); - await evaluate("__gm.showTab('statemachine')"); - await sleep(400); + await showTab('statemachine'); check( 'nothing is marked once the run is over', Object.keys(await marks()).length === 0, @@ -505,10 +466,8 @@ export async function stateMachine(t) { // 26f5. The same entry takes the mark off again, and the machine is back to starting nowhere. await evaluate('__gm.state.select(null)'); - await sleep(200); const againCentre = await centreOfState(first); await click(againCentre.x, againCentre.y, 'right'); - await sleep(300); const onMenu = await json('__gm.overlayTexts()'); check( 'the same place offers to take the mark off again', @@ -516,7 +475,6 @@ export async function stateMachine(t) { JSON.stringify(onMenu), ); await clickLast('Remove start state'); - await sleep(400); check( 'the mark and its ring are gone', (await startOf()) === '' && (await ringOn(first)) === false, @@ -538,7 +496,6 @@ export async function stateMachine(t) { b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e6'); b.graph.edges.push(${JSON.stringify(edge('sm-e1', 'sm-start', 'exec', 'sm-set', first))}); `); - await sleep(300); // 26g. An unconnected machine input shows a picker; picking a machine grows the state ports. await mutate(` @@ -550,7 +507,6 @@ export async function stateMachine(t) { // The row is named after the port, as the node body names it. const machineField = await json("__gm.inspectorField('machine', true)"); await click(machineField.x, machineField.y); - await sleep(300); const choices = await json('__gm.overlayTexts()'); check( 'the Machine picker offers the machine by name', @@ -558,7 +514,6 @@ export async function stateMachine(t) { JSON.stringify(choices), ); await clickLast(made.name); - await sleep(400); check( 'picking it grows the state outputs', names(await outputsOf('sm-fresh')).includes('Open'), @@ -577,7 +532,6 @@ export async function stateMachine(t) { const m = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); m.graph.nodes = m.graph.nodes.filter((n) => n.id !== ${JSON.stringify(second)}); `); - await sleep(400); const ghosted = await outputsOf('sm-branch'); check( 'the deleted state leaves a row named after its key, and takes its name away', @@ -632,7 +586,6 @@ export async function stateMachine(t) { p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(made.id)}); `); await evaluate('__gm.state.select(null)'); - await sleep(300); check( 'the step leaves nothing behind', left.objects === 0 && left.assets === 0 && (await machines()).length === 0, diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index ed1c956b..cd95ab5e 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -28,6 +28,7 @@ export async function shader(t) { play, stop, shot, + showTab, } = t; const { doubleClick, typeText } = t; const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`); @@ -40,7 +41,6 @@ export async function shader(t) { const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); - await sleep(200); }; const assetProps = (patch) => mutate( @@ -50,7 +50,6 @@ export async function shader(t) { await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); - await sleep(200); // 28a. The fixture ships Adjust colors as an asset; the + menu makes another and opens its tab on it. const seeded = (await shaders()).find((s) => s.id === ADJUST); @@ -60,9 +59,7 @@ export async function shader(t) { JSON.stringify(seeded), ); await clickText('+'); - await sleep(200); await clickLast('Shader'); - await sleep(400); const made = (await shaders()).find((s) => s.id !== ADJUST); const selection = await json('__gm.state.selection'); check( @@ -84,7 +81,6 @@ export async function shader(t) { // 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette. const canvas = await json('__gm.shader.canvasRect()'); await click(canvas.x + 80, canvas.y + 60, 'right'); - await sleep(300); const offered = await json('__gm.overlayTexts()'); check( 'right-click offers shader nodes and no behavior node', @@ -95,13 +91,9 @@ export async function shader(t) { ); // A dozen entries scroll; searched, the row is the only one and in view. await typeText('Texture color'); - await sleep(150); await clickLast('Texture color'); - await sleep(300); await click(canvas.x + 420, canvas.y + 60, 'right'); - await sleep(300); await clickLast('Output'); - await sleep(300); const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? []; check( 'both nodes are in the graph, the Output where its right-click was', @@ -109,7 +101,6 @@ export async function shader(t) { JSON.stringify(await madeNodes()), ); await click(canvas.x + 420, canvas.y + 320, 'right'); - await sleep(300); const again = await json('__gm.overlayTexts()'); check( 'the palette offers the Output only once', @@ -117,7 +108,6 @@ export async function shader(t) { JSON.stringify(again), ); await press('Escape'); - await sleep(200); check( 'an Output with nothing connected compiles: the texel passes through, no property declared', (await info()) === '0 properties', @@ -126,12 +116,9 @@ export async function shader(t) { // 28c. A Property node: its name is the row a sprite shows; a bad name is a problem on the node. await click(canvas.x + 80, canvas.y + 320, 'right'); - await sleep(300); // Property is the twelfth entry, below the palette's scroll mask: search it into view first. await typeText('Property'); - await sleep(150); await clickLast('Property'); - await sleep(300); const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty'); check( 'the placed property is named "value" and counted', @@ -143,7 +130,6 @@ export async function shader(t) { await doubleClick(nameField.x, nameField.y); await typeText('glow'); await press('Enter'); - await sleep(300); check( 'typing in the Name field renames the property, on the node too', (await madeNodes()).find((n) => n.id === propertyNode?.id)?.name === 'glow' && @@ -154,7 +140,6 @@ export async function shader(t) { await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'no good';`); - await sleep(300); const problem = await json(`__gm.shader.nodeProblem(${JSON.stringify(propertyNode?.id)})`); check( 'a name that is not one word is a problem on the node and the reason in the readout', @@ -164,7 +149,6 @@ export async function shader(t) { await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'glow';`); - await sleep(300); // 28c-ii. Where a node sits is the editor's business: dropping it somewhere else compiles nothing // again, a changed param does – once (#65). @@ -172,7 +156,6 @@ export async function shader(t) { const compiledBefore = await json('__gm.shaderCompiles()'); const grab = await json(`__gm.shader.nodeAt(${nodeRef})`); await drag(grab.x, grab.y, grab.x + 60, grab.y + 40); - await sleep(300); const dropped = await json(`__gm.shader.nodeAt(${nodeRef})`); const compiledAfterMove = await json('__gm.shaderCompiles()'); check( @@ -188,7 +171,6 @@ export async function shader(t) { const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes.find((n) => n.id === ${nodeRef}).params.default = ${JSON.stringify(value)};`); await setDefault(2); - await sleep(300); const compiledAfterParam = await json('__gm.shaderCompiles()'); check( 'changing a param compiles it again, once', @@ -196,7 +178,6 @@ export async function shader(t) { JSON.stringify({ compiledAfterMove, compiledAfterParam }), ); await setDefault(defaultBefore); - await sleep(300); // 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets. await mutate(` @@ -212,9 +193,8 @@ export async function shader(t) { __gm.state.select(null)`); await sleep(600); // The pixels are the scene view's; the tab is still the shader's. - await evaluate("__gm.showTab('scene')"); + await showTab('scene'); await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' }); - await sleep(400); const box = await json("__gm.objectRect('oshade')"); check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box)); if (!box) return; @@ -245,7 +225,6 @@ export async function shader(t) { JSON.stringify(shaderField), ); await click(shaderField.x, shaderField.y); - await sleep(300); const choices = await json('__gm.overlayTexts()'); check( 'the dropdown offers None and every shader asset', @@ -255,7 +234,6 @@ export async function shader(t) { JSON.stringify({ choices, shaderField }), ); await clickLast(choices.find((text) => text.includes('Adjust colors'))); - await sleep(400); const rows = await Promise.all([field('brightness'), field('contrast'), field('saturation')]); check( 'picking Adjust colors adds its three property rows below the dropdown', @@ -266,14 +244,11 @@ export async function shader(t) { ); await shot('28-shader-rows'); await undo(); - await sleep(300); check('undoing the choice takes the rows away again', (await field('brightness')) === null); await select(null); - await sleep(200); // 28e. Each property is visible in the pixels, with 1 neutral. await assetProps({ shader: ADJUST }); - await sleep(300); const neutral = await centre(); check( 'Adjust colors at its defaults draws exactly what no shader draws', @@ -285,10 +260,8 @@ export async function shader(t) { const passes = '({ shaders: __gm.shaderCount(), filters: __gm.filterCount() })'; const passesOn = await json(passes); await assetProps({ shader: '' }); - await sleep(300); const passesOff = await json(passes); await assetProps({ shader: ADJUST }); - await sleep(300); check( 'a shaded sprite draws with its own shader and no filter pass – and plain without', passesOn.shaders === 1 && passesOn.filters === 0 && passesOff.shaders === 0 && passesOff.filters === 0, @@ -298,15 +271,12 @@ export async function shader(t) { }, ); await assetProps({ brightness: 1.25 }); - await sleep(300); const lighter = await centre(); check('brightness 1.25 adds a quarter to every channel', colourDistance(lighter, '#80c0ff') < 24, lighter); await assetProps({ brightness: 1, contrast: 0 }); - await sleep(300); const flat = await centre(); check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat); await assetProps({ contrast: 1, saturation: 0 }); - await sleep(300); const grey = await centre(); check('saturation 0 leaves the luma as a grey', colourDistance(grey, '#7c7c7c') < 24, grey); await shot('28-shader-desaturated'); @@ -323,11 +293,9 @@ export async function shader(t) { JSON.stringify({ at, played }), ); await stop(); - await sleep(300); // 28g. Editing a shader's graph reaches every sprite wearing it: a Color wired into the new one's Output. await assetProps({ brightness: 1, shader: made.id }); - await sleep(300); const through = await centre(); check( 'the new shader passes the texel through', @@ -339,7 +307,6 @@ export async function shader(t) { const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput'); a.graph.nodes.push({ id: 'sh-red', type: 'ShaderColor', x: 0, y: 200, params: { value: 0xff0000 } }); a.graph.edges.push({ id: 'sh-red-edge', from: { node: 'sh-red', port: 'color' }, to: { node: out.id, port: 'color' } });`); - await sleep(400); const red = await centre(); check( 'wiring a red Color into the Output recompiles: the sprite turns red', @@ -360,7 +327,6 @@ export async function shader(t) { { id: 'sh-fade-a', from: { node: 'sh-alpha', port: 'value' }, to: { node: 'sh-fade', port: 'a' } }, { id: 'sh-fade-b', from: { node: 'sh-opacity', port: 'value' }, to: { node: 'sh-fade', port: 'b' } }, { id: 'sh-fade-out', from: { node: 'sh-fade', port: 'result' }, to: { node: out.id, port: 'alpha' } });`); - await sleep(400); const opaque = await centre(); check( 'an opacity property at its default 1 changes nothing', @@ -368,11 +334,9 @@ export async function shader(t) { JSON.stringify({ red, opaque, info: await info() }), ); await assetProps({ opacity: 0 }); - await sleep(300); const behind = await centre(); check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind }); await assetProps({ opacity: 0.5 }); - await sleep(300); const half = await centre(); const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const expected = @@ -391,7 +355,6 @@ export async function shader(t) { expected, }); await assetProps({ opacity: 1 }); - await sleep(300); check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red); // 28i. A Number node is a constant in the fragment: red times 0.5 is half red; typed back to 1, @@ -407,25 +370,22 @@ export async function shader(t) { { id: 'sh-dim-a', from: { node: 'sh-red', port: 'color' }, to: { node: 'sh-dim', port: 'a' } }, { id: 'sh-dim-b', from: { node: 'sh-half', port: 'value' }, to: { node: 'sh-dim', port: 'b' } }, { id: 'sh-dim-out', from: { node: 'sh-dim', port: 'result' }, to: { node: out.id, port: 'color' } });`); - await sleep(400); const dimmed = await centre(); check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, { red, dimmed, }); - await evaluate("__gm.showTab('shader')"); - await sleep(300); + await showTab('shader'); check( 'the Number node draws its value as a setting row', (await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.500', JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")), ); await shot('28-number-node'); - await evaluate("__gm.showTab('scene')"); + await showTab('scene'); await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes.find((n) => n.id === 'sh-half').params.value = 1`); - await sleep(400); check('the Number typed to 1 gives the red from before, byte for byte', (await centre()) === red, { red, now: await centre(), @@ -468,7 +428,6 @@ export async function shader(t) { [edge('uv', port, 'cut', 'value'), edge('cut', 'result', 'mix', 't')], ); await cutAt('u'); - await sleep(400); const uLeft = await pixel(-0.3, -0.3); const uRight = await pixel(0.3, -0.3); check( @@ -477,7 +436,6 @@ export async function shader(t) { { uLeft, uRight }, ); await cutAt('v'); - await sleep(400); const vTop = await pixel(-0.3, -0.3); const vBottom = await pixel(-0.3, 0.3); check( @@ -501,7 +459,6 @@ export async function shader(t) { ], ); await cutAfterPow(2); - await sleep(400); const squaredNear = await pixel(0.1, -0.3); const squaredFar = await pixel(0.3, -0.3); check( @@ -510,7 +467,6 @@ export async function shader(t) { { squaredNear, squaredFar }, ); await cutAfterPow(0.5); - await sleep(400); const rootNear = await pixel(-0.3, -0.3); const rootFar = await pixel(-0.15, -0.3); check( @@ -534,7 +490,6 @@ export async function shader(t) { edge('cut', 'result', 'mix', 't'), ], ); - await sleep(400); const foldLeft = await pixel(-0.4, -0.3); const foldMiddle = await pixel(0, -0.3); const foldRight = await pixel(0.4, -0.3); @@ -564,7 +519,6 @@ export async function shader(t) { ], ); await namedProperty('width'); - await sleep(400); const spriteProp = await info(); check( 'a property named like a sprite row is a problem on the node, not a second width row (#63)', @@ -573,7 +527,6 @@ export async function shader(t) { JSON.stringify({ spriteProp, problem: await json("__gm.shader.nodeProblem('named')") }), ); await namedProperty('amount'); - await sleep(400); check( 'the same graph under a free name compiles: a quarter of the red', (await info()) === '1 property' && colourDistance(await pixel(-0.3, -0.3), '#400000') < 6, @@ -598,10 +551,8 @@ export async function shader(t) { ], ); await dividedBy(2); - await sleep(400); const halved = await pixel(-0.3, -0.3); await dividedBy(0); - await sleep(400); const byZero = await pixel(-0.3, -0.3); check( 'Divide by 2 halves the red, and dividing by 0 is 0 – a black pixel, not the driver’s choice (#64)', @@ -618,7 +569,6 @@ export async function shader(t) { ], [edge('red', 'color', 'hold', 'value'), edge('hold', 'result', 'out', 'color')], ); - await sleep(400); const held = await pixel(-0.3, -0.3); check('Clamp at 0..0.25 holds full red down to a quarter', colourDistance(held, '#400000') < 6, held); @@ -661,7 +611,6 @@ export async function shader(t) { [node('flip', 'ShaderSubtract', { a: 1 }, 260, 0)], [edge('uv', 'u', 'flip', 'b'), edge('flip', 'result', 'sample', 'u')], ); - await sleep(400); const flipLeft = await pixel(-0.3, -0.3); const flipRight = await pixel(0.3, -0.3); check( @@ -675,7 +624,6 @@ export async function shader(t) { [edge('uv', 'u', 'shift', 'a'), edge('shift', 'result', 'sample', 'u')], { edge: edgeMode }, ); - await sleep(400); return { left: await pixel(-0.3, -0.3), right: await pixel(0.3, -0.3) }; }; const repeat = await scrolled('repeat'); @@ -698,7 +646,6 @@ export async function shader(t) { [edge('uv', 'u', 'far', 'a'), edge('far', 'result', 'sample', 'u')], outParams, ); - await sleep(400); return pixel(0.3, -0.3); }; const byDefault = await scrolledFar({}); @@ -708,17 +655,14 @@ export async function shader(t) { colourDistance(byDefault, RIGHT) < 12 && colourDistance(byMirror, LEFT) < 12, { byDefault, byMirror }, ); - await evaluate("__gm.showTab('shader')"); - await sleep(300); + await showTab('shader'); await shot('28-shader-scroll-graph'); - await evaluate("__gm.showTab('scene')"); - await sleep(300); + await showTab('scene'); const sizeAtLeast = async (px) => { await redOrBlue( [node('size', 'ShaderTextureSize'), node('cut', 'ShaderStep', { edge: px }, 260, 0)], [edge('size', 'width', 'cut', 'value'), edge('cut', 'result', 'mix', 't')], ); - await sleep(400); return pixel(-0.3, -0.3); }; const wide = await sizeAtLeast(31.5); @@ -788,21 +732,17 @@ export async function shader(t) { await sleep(500); const playing = await json('__gm.animates()'); await stop(); - await sleep(300); check('and so does the game view while playing', playing.game === true, JSON.stringify(playing)); await assetProps({ shader: '' }); - await sleep(400); check( 'with the shader off the scene view stops redrawing', (await json('__gm.animates()')).scene === false, JSON.stringify(await json('__gm.animates()')), ); await assetProps({ texture: '', color: 0x4080ff, shader: made.id }); - await sleep(400); // 28j. Back to None: no filter at all, the plain colour returns bit for bit. await assetProps({ shader: '' }); - await sleep(300); const back = await centre(); check( 'switching back to None restores the plain colour', @@ -817,7 +757,6 @@ export async function shader(t) { const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'oshade')`); await evaluate('__gm.state.select(null)'); - await sleep(200); check( 'the step leaves nothing behind but the fixture’s shader', (await json("__gm.state.project.assets.some((a) => a.id === 'shade-sprite')")) === false && diff --git a/tools/e2e/29-transform.mjs b/tools/e2e/29-transform.mjs index 958ddb3c..5ef69547 100644 --- a/tools/e2e/29-transform.mjs +++ b/tools/e2e/29-transform.mjs @@ -33,6 +33,7 @@ export async function transform(t) { entity, stop, shot, + showTab, } = t; const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`); const rect = (id) => json(`__gm.objectRect(${JSON.stringify(id)})`); @@ -47,7 +48,6 @@ export async function transform(t) { ); const selectObject = async (id) => { await select({ kind: 'object', holderId: 'scene1', objectIds: [id], objectId: id }); - await sleep(300); }; const remove = (ids) => mutate(` @@ -57,8 +57,7 @@ export async function transform(t) { await press('Escape'); await evaluate('__gm.state.select(null)'); - await evaluate("__gm.showTab('scene')"); - await sleep(200); + await showTab('scene'); // 29a. A plain red 100×40 sprite in its lane, and a second camera to read the inspector off; // a sprite object has the transform rows, a camera object has not. @@ -118,7 +117,6 @@ export async function transform(t) { // 29c. Rotation 90: the box stands up – its frame swaps sides, the pixels move with it. await override(BOX, { rotation: 90 }); - await sleep(300); const turned = await rect(BOX); const turnedPx = { right: await px(40, 0), below: await px(0, 40) }; check( @@ -132,7 +130,6 @@ export async function transform(t) { // 29d. Scale 2 on top: twice the frame, red where the upright box would have ended long ago. await override(BOX, { scale: 2 }); - await sleep(300); const grown = await rect(BOX); const far = await px(0, 90); check( @@ -159,7 +156,6 @@ export async function transform(t) { JSON.stringify(plain), ); await override(BOX, { mirrorX: true }); - await sleep(300); const mirrored = { left: await px(-30, 0), right: await px(30, 0) }; check( 'Mirror X swaps the halves: green left, red right', @@ -167,7 +163,6 @@ export async function transform(t) { JSON.stringify(mirrored), ); await override(BOX, { mirrorY: true }); - await sleep(300); const both = { left: await px(-30, 0), right: await px(30, 0) }; check( 'Mirror Y on top leaves the halves where Mirror X put them', @@ -176,17 +171,14 @@ export async function transform(t) { ); await override(BOX, { mirrorX: false, mirrorY: false }); await spriteProps({ texture: '', color: 0xff0000 }); - await sleep(300); // 29f. A child 100 to the right of its parent: once the parent turns a quarter, the child hangs // 100 below it – and is drawn turned itself. await mutate(` const scene = __gm.state.activeScene; scene.objects.push({ id: ${JSON.stringify(CHILD)}, assetId: ${JSON.stringify(SPRITE)}, name: '', position: { x: 100, y: 0, z: 1 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: ${JSON.stringify(BOX)}, instanceEdits: {} });`); - await sleep(300); const flat = { parent: await screen(BOX), child: await screen(CHILD) }; await override(BOX, { rotation: 90 }); - await sleep(300); const hung = { parent: await screen(BOX), child: await screen(CHILD), childRect: await rect(CHILD) }; check( 'a child follows its parent’s turn: 100 to the right becomes 100 below, drawn turned', @@ -202,7 +194,6 @@ export async function transform(t) { // 29g. Dragging the child 30 screen pixels down moves it along the parent's x axis – which now // points down – so its local x grows by 30 / scale and y stays. await drag(hung.child.x, hung.child.y, hung.child.x, hung.child.y + 30, { steps: 8 }); - await sleep(300); const dragged = await object(CHILD); check( 'a drag under a turned parent lands in the parent’s frame: local x + 30 / scale, y unchanged', @@ -210,7 +201,6 @@ export async function transform(t) { JSON.stringify({ position: dragged.position, expected: 100 + 30 / s }), ); await undo(); - await sleep(200); check('undo puts the child back', (await object(CHILD)).position.x === 100); await selectObject(CHILD); const gizmos = await json('__gm.gizmos()'); @@ -236,7 +226,6 @@ export async function transform(t) { ); __gm.state.select(null)`); await press('Enter', { ctrl: true }); - await sleep(300); await holdKey('ArrowRight', 1000); const body = await entity('otf-body'); await stop(); @@ -255,7 +244,6 @@ export async function transform(t) { project.assets = project.assets.filter((a) => !gone.has(a.id)); __gm.lookAt(0, 0)`); await evaluate('__gm.state.select(null)'); - await sleep(200); check( 'the step leaves nothing behind', (await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(SPRITE)})`)) === false && diff --git a/tools/e2e/30-font.mjs b/tools/e2e/30-font.mjs index 1e293834..52bc74b1 100644 --- a/tools/e2e/30-font.mjs +++ b/tools/e2e/30-font.mjs @@ -17,7 +17,8 @@ const SERIF_BYTES = 380660; const MONO_BYTES = 343140; export async function font(t) { - const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot } = t; + const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, showTab } = + t; const { select } = t; const assetsOfType = (type) => json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)}) @@ -27,7 +28,6 @@ export async function font(t) { const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); - await sleep(200); }; /** A face arrives over the network, so what it changes is awaited rather than slept for. */ const waitFor = async (read, done, tries = 20) => { @@ -42,8 +42,7 @@ export async function font(t) { await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); - await evaluate("__gm.showTab('scene')"); - await sleep(200); + await showTab('scene'); // 30a. What the server holds, straight from the API: the two files the seed put there. // `evaluate`, not `json`: the latter stringifies the promise itself rather than what it yields. @@ -66,7 +65,6 @@ export async function font(t) { // 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture. await evaluate('__gm.console.clear()'); await clickText('+'); - await sleep(200); await pickAssetType(t, 'Font'); await sleep(500); const picker = await json('__gm.fontPicker()'); @@ -93,9 +91,7 @@ export async function font(t) { ); const row = await json(`__gm.fontPickerRow(${JSON.stringify(serif.file)})`); await click(row.x, row.y); - await sleep(200); await clickText('Use font'); - await sleep(400); const fonts = await assetsOfType('font'); check( 'picking a row makes a font asset of that file, named after the file rather than "Font"', @@ -118,7 +114,6 @@ export async function font(t) { name: '', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); __gm.lookAt(0, ${LANE});`); - await sleep(400); const plain = await info(); check( 'a text wearing no font is drawn in the editor’s own typeface', @@ -129,7 +124,6 @@ export async function font(t) { // 30d. Wearing the font, chosen the way a user chooses it: the Font row of the text object. await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT }); - await sleep(300); // The label is no anchor on its own: the assets panel has a row per font. The dropdown of the // Font row is the last "None" in the window – Parent says it above, nothing says it below. const fontRow = await json("__gm.inspectorField('Font', true)"); @@ -140,9 +134,7 @@ export async function font(t) { JSON.stringify({ fontRow, none }), ); await click(fontRow.x, fontRow.y); - await sleep(250); await clickLast('Bookish (font)'); - await sleep(400); const chosen = await json( `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font`, ); @@ -155,7 +147,6 @@ export async function font(t) { await shot('30-font-scene'); // The asset's own inspector is where a font is looked at; it has nothing else to show. await select({ kind: 'asset', assetId: fontId }); - await sleep(300); await shot('30-font-asset'); // 30e. The game draws through the same path, so the face is in play as well. The camera frames @@ -177,7 +168,6 @@ export async function font(t) { ); await stop(); await place(`{ x: 0, y: ${LANE} }`); - await sleep(300); // 30f. An id that no longer resolves is no font at all – the text falls back rather than vanishing. await mutate( @@ -223,7 +213,6 @@ export async function font(t) { (o) => o.id !== ${JSON.stringify(TEXT_OBJECT)}); __gm.state.select(null); __gm.lookAt(0, 0);`); - await sleep(300); check( 'the step leaves nothing of its own behind', (await assetsOfType('font')).length === 0 && (await info()) === null, diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index 32a611ff..a45b24bd 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -16,7 +16,8 @@ const SOURCE = 'pt-source'; const RATE = 20; export async function particles(t) { - const { check, sleep, json, evaluate, mutate, click, clickText, locate, typeText, press, shot } = t; + const { check, sleep, json, evaluate, mutate, click, clickText, locate, typeText, press, shot, showTab } = + t; const { drag } = t; const { select } = t; /** Freeze, restart every source's clock, then step the clock to `seconds` and let it redraw. */ @@ -33,21 +34,16 @@ export async function particles(t) { const drawn = (limit = 8) => json(`__gm.particlesOf(${JSON.stringify(SOURCE)}, 'scene', ${limit})`); const selectSource = async () => { await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE }); - await sleep(350); }; const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); - await sleep(200); }; /** Places a node from the palette. The list scrolls, so the name is searched into view first. */ const placeNodeAt = async (label, point) => { await click(point.x, point.y, 'right'); - await sleep(300); await typeText(label); - await sleep(200); await clickLast(label); - await sleep(300); }; const placeNode = async (label, dx, dy) => { const canvas = await json('__gm.particles.canvasRect()'); @@ -66,29 +62,24 @@ export async function particles(t) { __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.nodes.push({ id: ${JSON.stringify(id)}, type: ${JSON.stringify(type)}, x: 0, y: 0, params: ${JSON.stringify(params)} });`); - await sleep(150); }; const link = async (id, from, fromPort, to, toPort) => { await mutate(` __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.edges.push({ id: ${JSON.stringify(id)}, from: { node: ${JSON.stringify(from)}, port: ${JSON.stringify(fromPort)} }, to: { node: ${JSON.stringify(to)}, port: ${JSON.stringify(toPort)} } });`); - await sleep(150); }; let assetId = ''; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); - await evaluate("__gm.showTab('scene')"); - await sleep(200); + await showTab('scene'); // 31a. "+ → Particles" makes the asset and opens its tab; with no Output there is nothing to run. await evaluate('__gm.console.clear()'); await clickText('+'); - await sleep(200); const picked = await pickAssetType(t, 'Particles'); - await sleep(400); const created = await json(`{ tab: __gm.tab(), current: __gm.particles.current(), @@ -107,12 +98,10 @@ export async function particles(t) { assetId = created.assets[0]; // Renamed away from "Particles": the tab button says that too, and a lookup by text would find it. await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).name = 'Sparks'`); - await sleep(200); // 31b. The palette of this tab holds particle nodes and nothing else. const canvas = await json('__gm.particles.canvasRect()'); await click(canvas.x + 80, canvas.y + 60, 'right'); - await sleep(300); const palette = await json('__gm.overlayTexts()'); check( 'the palette offers particle nodes, not behavior ones', @@ -120,7 +109,6 @@ export async function particles(t) { JSON.stringify(palette.slice(0, 12)), ); await press('Escape'); - await sleep(200); // 31c. One Output node is a whole graph: a particle without rules is one at the source, full size. await placeNode('Output', 80, 60); @@ -148,7 +136,6 @@ export async function particles(t) { // the button has to offer the way back, and Restart on a held panel stands at second 0. const track = await json('__gm.particles.sliderAt()'); await click(track.x, track.y); - await sleep(200); const dragged = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'dragging the time slider holds the panel, and the button then offers to run it again', @@ -160,7 +147,6 @@ export async function particles(t) { JSON.stringify(dragged), ); await clickText('Restart'); - await sleep(200); const restarted = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'Restart puts a held panel back to second 0, and the readout and slider with it', @@ -172,7 +158,6 @@ export async function particles(t) { JSON.stringify(restarted), ); await clickText('Run'); - await sleep(250); const running = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'the same button lets it run again and says "Hold" once it does', @@ -196,7 +181,6 @@ export async function particles(t) { JSON.stringify(beside), ); await drag(beside.split.x, beside.split.y, beside.split.x - 80, beside.split.y, { steps: 8 }); - await sleep(200); const widened = await json(`{ canvas: __gm.particles.canvasRect(), preview: __gm.particles.previewRect(), @@ -207,7 +191,6 @@ export async function particles(t) { JSON.stringify({ before: beside.preview.w, after: widened.preview.w, canvas: widened.canvas.w }), ); await drag(beside.split.x - 80, beside.split.y, beside.split.x, beside.split.y, { steps: 8 }); - await sleep(200); // 31d-iv. (#86) A hold has to stop the clock, not just the picture: the library's second goes on // under the freeze, so letting go must move the panel's origin rather than resume at the @@ -217,12 +200,10 @@ export async function particles(t) { await evaluate('__gm.particles.hold(0.5)'); await sleep(150); await clickText('Restart'); - await sleep(200); await evaluate('__gm.particleTime(2003)'); await sleep(200); const waited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); await clickText('Run'); - await sleep(200); const letGo = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'three seconds spent on hold are not owed: letting it run again carries on from second 0', @@ -237,7 +218,6 @@ export async function particles(t) { a.props.lifetime = 2; a.props.rate = 5; `); - await sleep(200); await evaluate('__gm.particles.hold(1.5)'); await sleep(200); const edited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); @@ -254,7 +234,6 @@ export async function particles(t) { const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)}); a.props.timeScale = 2; `); - await sleep(300); const scaled = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'a time scale of 2 halves what the slider spans and doubles what a held second shows', @@ -268,7 +247,6 @@ export async function particles(t) { delete a.props.rate; delete a.props.timeScale; `); - await sleep(200); await evaluate('__gm.particles.hold(null)'); await evaluate('__gm.particleTime(null)'); await sleep(150); @@ -298,8 +276,7 @@ export async function particles(t) { name: 'Fountain', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); __gm.lookAt(0, ${LANE});`); - await evaluate("__gm.showTab('scene')"); - await sleep(400); + await showTab('scene'); await rewind(); await at(0.5); const inScene = await drawn(); @@ -312,8 +289,7 @@ export async function particles(t) { // 31f. Gravity is ½gt², not a force stepped frame by frame: the oldest particle, half a second // old, is exactly 100 px below the source at strength 800. - await evaluate("__gm.showTab('particles')"); - await sleep(250); + await showTab('particles'); // Not a fixed point: the Output node is already on the canvas, and since the preview took the // right of the tab (#86) there is no corner left that is free by construction. await placeNodeAt('Gravity', await json('__gm.particles.emptySpot()')); @@ -323,8 +299,7 @@ export async function particles(t) { const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph; graph.edges.push({ id: 'pt-e1', from: { node: ${JSON.stringify(gravityId)}, port: 'y' }, to: { node: ${JSON.stringify(outputId)}, port: 'y' } });`); - await evaluate("__gm.showTab('scene')"); - await sleep(300); + await showTab('scene'); await rewind(); await at(0.5); const falling = await drawn(2); @@ -352,7 +327,6 @@ export async function particles(t) { await addNode('pt-age', 'ParticleAge'); await link('pt-e2', 'pt-age', 'age', 'pt-sub', 'b'); await link('pt-e3', 'pt-sub', 'result', outputId, 'alpha'); - await sleep(300); await rewind(); await at(0.5); const fading = await drawn(11); @@ -367,9 +341,7 @@ export async function particles(t) { // 31i. A Property node is a row on the placed source, like a shader parameter on a sprite. await addNode('pt-prop', 'ParticleProperty', { name: 'drift', default: 0, min: -200, max: 200 }); await link('pt-e4', 'pt-prop', 'value', outputId, 'x'); - await sleep(300); await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE }); - await sleep(350); const driftRow = await json("__gm.inspectorField('drift')"); const rateRow = await json("__gm.inspectorField('Rate')"); check( @@ -379,7 +351,6 @@ export async function particles(t) { ); await mutate(` __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.drift = 60;`); - await sleep(250); await rewind(); await at(0.5); const drifted = await drawn(2); @@ -404,7 +375,6 @@ export async function particles(t) { ); await mutate(` __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.emitting = true;`); - await sleep(200); await rewind(); await at(0.5); check('switching it back on fills the field again', (await drawn(1))?.count === RATE / 2 + 1, ''); @@ -413,7 +383,6 @@ export async function particles(t) { await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); Object.assign(o.overrides, { mode: 'burst', count: 7 });`); - await sleep(200); await rewind(); await at(0.5); const burst = await drawn(7); @@ -431,7 +400,6 @@ export async function particles(t) { await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); o.overrides.prewarm = true;`); - await sleep(200); await rewind(); await at(0.5); const warmBurst = await drawn(7); @@ -447,14 +415,12 @@ export async function particles(t) { const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); Object.assign(o.overrides, { mode: 'continuous', drift: 0 }); delete o.overrides.count;`); - await sleep(200); await rewind(); await at(0); const warmStart = await drawn(1); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); delete o.overrides.prewarm;`); - await sleep(200); await rewind(); await at(0); const coldStart = await drawn(1); @@ -469,14 +435,12 @@ export async function particles(t) { await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); o.overrides.timeScale = 2;`); - await sleep(200); await rewind(); await at(0.5); const fast = await drawn(3); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); delete o.overrides.timeScale;`); - await sleep(200); await rewind(); await at(1); const plain = await drawn(3); @@ -503,13 +467,11 @@ export async function particles(t) { await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); Object.assign(o.overrides, { mode: 'burst', count: 7 });`); - await sleep(200); // 31l. It really draws: the pixel where a burst particle sits is not the scene background. await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); Object.assign(o.overrides, { size: 40, color: 0x3cc85a, drift: 0 });`); - await sleep(200); await rewind(); await at(0.1); const spotOn = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`); @@ -531,9 +493,7 @@ export async function particles(t) { await sleep(400); const thawed = await drawn(1); await t.play(); - await sleep(300); await t.stop(); - await sleep(400); const afterRun = await drawn(1); check( 'the source draws on after the clock is pulled out from under it: a freeze released, a run ended', @@ -579,7 +539,6 @@ export async function particles(t) { const source = scene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); source.parentId = 'pt-anchor'; source.position = { x: 200, y: 0, z: 0 };`); - await sleep(300); const linked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); await mutate(` const scene = __gm.state.activeScene; @@ -587,7 +546,6 @@ export async function particles(t) { source.parentId = ''; source.position = { x: 0, y: ${LANE}, z: 0 }; scene.objects = scene.objects.filter((o) => o.id !== 'pt-anchor');`); - await sleep(300); const unlinked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); check( 'a parented source hangs off its owner by a line, a free one by none', @@ -612,7 +570,6 @@ export async function particles(t) { }); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); Object.assign(o.overrides, { shader: ${JSON.stringify(SHADER)}, level: 1 });`); - await sleep(300); await selectSource(); // The tab bar has a button reading "Shader" too, and it is drawn first: the row wanted here is the // *last* text of that name, and it has to stand in the inspector's own column to be one. @@ -641,7 +598,6 @@ export async function particles(t) { await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); o.overrides.level = 0.25;`); - await sleep(300); await at(0.1); const dimmed = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`); check( @@ -657,7 +613,6 @@ export async function particles(t) { const shader = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SHADER)}); shader.graph.nodes.find((n) => n.id === 'lv-prop').params.name = 'rate'; shader.graph.edges = [];`); - await sleep(300); await select(null); await selectSource(); const own = await json('__gm.locateAll("Rate")'); @@ -673,7 +628,6 @@ export async function particles(t) { delete o.overrides.level; __gm.state.project.assets = __gm.state.project.assets.filter( (a) => a.id !== ${JSON.stringify(SHADER)});`); - await sleep(300); // 31p. The graph tab draws no Pixi complaint of its own. const logged = await json('__gm.console.lines()'); @@ -692,8 +646,7 @@ export async function particles(t) { __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter( (o) => o.id !== ${JSON.stringify(SOURCE)}); __gm.lookAt(0, 0);`); - await evaluate("__gm.showTab('scene')"); - await sleep(300); + await showTab('scene'); check( 'the step leaves nothing of its own behind', (await json("__gm.state.project.assets.filter((a) => a.type === 'particles').length")) === 0 && diff --git a/tools/e2e/32-scope.mjs b/tools/e2e/32-scope.mjs index d4203234..0fa4daeb 100644 --- a/tools/e2e/32-scope.mjs +++ b/tools/e2e/32-scope.mjs @@ -27,23 +27,18 @@ const counterGraph = { }; export async function scope(t) { - const { check, sleep, evaluate, json, shot, click, locate, asset, mutate, stop, has } = t; + const { check, sleep, json, shot, click, locate, asset, mutate, stop, has } = t; const selectAsset = async (id) => { - await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(id)} })`); - await sleep(300); + await t.select({ kind: 'asset', assetId: id }); }; const selectNode = async (nodeId) => { - await evaluate( - `__gm.state.select({ kind: 'node', assetId: 'sc-logic', nodeId: ${JSON.stringify(nodeId)} })`, - ); - await sleep(300); + await t.select({ kind: 'node', assetId: 'sc-logic', nodeId }); }; /** The inspector is drawn over the graph, so the last match is the row rather than a port. */ const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); - await sleep(250); }; // 32a. A Variables asset, a behavior counting on it, and two boxes wearing that behavior. @@ -74,7 +69,6 @@ export async function scope(t) { make('sc-brain-b', 'sc-logic', 'sc-box-b', 0, 'Counter B'), ); `); - await sleep(400); // 32b. The run: two instances, one number. A per-instance variable would stand at 1. await t.play(); @@ -93,7 +87,6 @@ export async function scope(t) { `objects carrying "score": ${JSON.stringify(carriers)}`, ); await stop(); - await sleep(400); // 32c. The asset is where the declaration lives, and it says what it is for. await selectAsset('sc-vars'); @@ -123,7 +116,6 @@ export async function scope(t) { const g = __gm.state.project.assets.find((a) => a.id === 'sc-logic').graph; g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = ''; `); - await sleep(400); const complainsNow = await has('"score" is not a variable of this behavior.'); check( 'and clearing it points the node back at the behavior, which has no such variable', @@ -146,9 +138,7 @@ export async function scope(t) { const machineScope = await has('Local'); const pick = await locate('Local', true); await click(pick.x, pick.y); - await sleep(250); await clickLast('Global'); - await sleep(350); const machine = await asset('sc-machine'); check( 'a state machine is switched between one current state per object and one for the run', @@ -165,5 +155,4 @@ export async function scope(t) { p.assets = p.assets.filter((a) => !a.id.startsWith('sc-')); __gm.state.select(null); `); - await sleep(300); } diff --git a/tools/e2e/33-render-on-change.mjs b/tools/e2e/33-render-on-change.mjs index 78607586..36f8256f 100644 --- a/tools/e2e/33-render-on-change.mjs +++ b/tools/e2e/33-render-on-change.mjs @@ -10,7 +10,7 @@ import { openTab } from './lib/scaffold.mjs'; * The step runs last and leaves the editor as it found it. */ export async function renderOnChange(t) { - const { check, sleep, evaluate, select, click, locate, play, stop, shot } = t; + const { check, sleep, evaluate, select, click, locate, play, stop, shot, waitUntil } = t; const stats = () => evaluate('__gm.renderStats()'); /** Frames drawn over `ms` of doing nothing at all. */ @@ -23,8 +23,18 @@ export async function renderOnChange(t) { /** * Waits for the trailing window of the last event to run out. Measuring without this counts the * frames the previous click is still entitled to and calls them a leak. + * + * The autosave is waited out first. It fires ~1 s after the last model change and paints the + * status twice – `saving`, then `saved` – so an earlier step's last edit lands in this step's + * quiet window and looks like a renderer that will not go to sleep. Those frames are earned: + * the status text really did change. The step has to stop measuring before them, not call them + * a leak. */ - const settle = async (timeoutMs = 3000) => { + const settle = async (timeoutMs = 5000) => { + await waitUntil("!['dirty', 'saving'].includes(__gm.state.saveStatus)", { + timeout: timeoutMs, + label: 'the autosave finished', + }); for (let waited = 0; waited < timeoutMs; waited += 100) { if (!(await stats()).awake) return true; await sleep(100); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 99e99ef0..2b1f338b 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -19,7 +19,7 @@ const BLIP_BYTES = 4044; const DRONE_BYTES = 12044; export async function sound(t) { - const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot } = t; + const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t; const { select } = t; const assetsOfType = (type) => json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)}) @@ -38,8 +38,7 @@ export async function sound(t) { await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); - await evaluate("__gm.showTab('scene')"); - await sleep(200); + await showTab('scene'); // 34a. What the server holds, straight from the API: the two files the seed put there. const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`); @@ -59,7 +58,6 @@ export async function sound(t) { // 34b. "+ → Sound" offers them; a sound is picked rather than created empty, like a typeface. await clickText('+'); - await sleep(200); await pickAssetType(t, 'Sound'); await sleep(500); const picker = await json('__gm.soundPicker()'); @@ -91,9 +89,7 @@ export async function sound(t) { const row = await json(`__gm.soundPickerRow(${JSON.stringify(blip.file)})`); await click(row.x, row.y); - await sleep(200); await clickText('Use sound'); - await sleep(400); const sounds = await assetsOfType('sound'); check( 'picking a row makes a sound asset of that file, named after the file rather than "Sound"', @@ -116,7 +112,6 @@ export async function sound(t) { // 34d. Its inspector: the two rows, and the preview that is the only way to tell which sound it is. await select({ kind: 'asset', assetId: soundId }); - await sleep(300); const volumeRow = await json("__gm.inspectorField('Volume', true)"); const loopRow = await json("__gm.inspectorField('Loop', true)"); check( @@ -153,7 +148,6 @@ export async function sound(t) { make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'), ); __gm.lookAt(0, ${LANE});`); - await sleep(400); await evaluate('__gm.sound.stopAll()'); const before = await stats(); @@ -168,14 +162,12 @@ export async function sound(t) { JSON.stringify(played), ); await stop(); - await sleep(300); // 34f. A looping sound is music: it plays on, and ending the run ends it – nothing may outlive // the world that started it. await mutate(` const p = __gm.state.project; p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.loop = true;`); - await sleep(300); await play(); const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true); check( @@ -187,7 +179,6 @@ export async function sound(t) { await sleep(700); const stillLooping = await stats(); await stop(); - await sleep(400); const afterStop = await stats(); check( 'a loop keeps playing while the run is on and ends with it', @@ -203,15 +194,12 @@ export async function sound(t) { const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.find((n) => n.id === 'sn-play').params.loop = ${JSON.stringify(value)};`); await setNodeLoop('false'); - await sleep(300); const beforeOnce = await stats(); await play(); const once = await waitFor(stats, (s) => s.started > beforeOnce.started); check('a node saying "off" plays a looping asset once', once.last?.loop === false, JSON.stringify(once)); await stop(); - await sleep(300); await setNodeLoop(''); - await sleep(300); // 34g. `Stop sound` silences it from inside the graph, without ending the run. await mutate(` @@ -220,7 +208,6 @@ export async function sound(t) { graph.nodes.push(${JSON.stringify(node('sn-stop', 'StopSound', 520, 0, { sound: soundId }))}); graph.nodes.push(${JSON.stringify(node('sn-key', 'OnKeyDown', 260, 220, { key: 's' }))}); graph.edges.push(${JSON.stringify(edge('sn-e2', 'sn-key', 'exec', 'sn-stop', 'exec'))});`); - await sleep(300); await play(); await waitFor(stats, (s) => s.playing > 0); await press('s'); @@ -231,7 +218,6 @@ export async function sound(t) { JSON.stringify(silenced), ); await stop(); - await sleep(300); // 34g-2. A voice belongs to the behavior playing it: destroy the object and its sounds go with it, // without any `Stop sound` in the graph. `detached` is the one way out, for the death @@ -248,7 +234,6 @@ export async function sound(t) { ${JSON.stringify(edge('sn-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))}, ${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))}, );`); - await sleep(300); await play(); await waitFor(stats, (s) => s.playing > 0); await press('d'); @@ -259,7 +244,6 @@ export async function sound(t) { JSON.stringify(destroyed), ); await stop(); - await sleep(300); const setDetached = (value) => mutate(` @@ -267,7 +251,6 @@ export async function sound(t) { const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`); await setDetached(true); - await sleep(300); await play(); await waitFor(stats, (s) => s.playing > 0); await press('d'); @@ -280,14 +263,12 @@ export async function sound(t) { JSON.stringify(detached), ); await stop(); - await sleep(300); check( 'ending the run silences a detached voice too', (await stats()).playing === 0, JSON.stringify(await stats()), ); await setDetached(false); - await sleep(300); // 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file. const dropped = await evaluate(` @@ -322,7 +303,6 @@ export async function sound(t) { (o) => !['sn-box', 'sn-brain'].includes(o.id)); __gm.state.select(null); __gm.lookAt(0, 0);`); - await sleep(300); check( 'the step leaves nothing of its own behind', (await assetsOfType('sound')).length === 0 && (await stats()).playing === 0, diff --git a/tools/e2e/35-load-scene.mjs b/tools/e2e/35-load-scene.mjs index 89b9ff67..1bd5ae08 100644 --- a/tools/e2e/35-load-scene.mjs +++ b/tools/e2e/35-load-scene.mjs @@ -35,14 +35,28 @@ const graphFor = (sceneId, soundId) => ({ }); export async function loadScene(t) { - const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, has } = t; + const { + check, + sleep, + json, + evaluate, + mutate, + click, + clickText, + locate, + press, + play, + stop, + shot, + has, + showTab, + } = t; const playInfo = () => json('__gm.playInfo()'); const playFrom = () => json('__gm.playFrom()'); /** The switch is opened by position: its caption is elided at the width it has. */ const openPlayFrom = async () => { const at = await json('__gm.playFromAt()'); await click(at.x, at.y); - await sleep(250); }; const stats = () => json('__gm.sound.stats()'); const waitFor = async (read, done, tries = 25) => { @@ -56,8 +70,7 @@ export async function loadScene(t) { await press('Escape'); await evaluate('__gm.state.select(null)'); - await evaluate("__gm.showTab('scene')"); - await sleep(200); + await showTab('scene'); const firstId = await json('__gm.state.project.activeSceneId'); const firstName = await json('__gm.state.activeScene.name'); @@ -100,7 +113,6 @@ export async function loadScene(t) { props: { volume: 1, loop: true }, file: ${JSON.stringify(file)}, });`); - await sleep(400); // 35b. Without a start scene the switch says the active scene – the two questions have the same // answer until someone answers them differently. @@ -118,16 +130,13 @@ export async function loadScene(t) { // project holds every kind of asset, so the new scene is the last row of a list far longer // than the panel – selecting it brings it into view, which is what makes the click land. await evaluate("__gm.state.select({ kind: 'asset', assetId: 'ls-scene' })"); - await sleep(200); check( 'selecting an asset scrolls its row into the panel', await has('Level 2'), JSON.stringify({ row: await json('__gm.assetRow("ls-scene")'), panel: await json('__gm.assetsRect()') }), ); await clickText('Level 2', 'right'); - await sleep(250); await clickText('Set as start scene'); - await sleep(300); check( 'the scene’s context menu names it the start scene, and the switch follows', (await json('__gm.state.project.startSceneId')) === 'ls-scene' && (await playFrom()).scene === 'Level 2', @@ -158,12 +167,10 @@ export async function loadScene(t) { // and not the other was the inconsistency. The run itself is untouched by it. const select = await json('__gm.sceneSelect()'); await click(select.x, select.y); - await sleep(250); const rows = await json('__gm.menuRows()'); // The topmost hit, because the scene's name is on its row in the Assets panel as well. const entry = await locate('Level 2', true); await click(entry.x, entry.y); - await sleep(300); const playing = await playInfo(); check( 'the scene selector stays usable in play mode, and the run runs on', @@ -208,13 +215,11 @@ export async function loadScene(t) { JSON.stringify({ humming, now: await stats() }), ); await stop(); - await sleep(300); // 35f. The switch is the way to run what you are working on instead – and it is remembered // between runs, because it says how you work rather than what the game does. await openPlayFrom(); await clickText('Active scene'); - await sleep(300); await play(); const fromActive = await playInfo(); check( @@ -226,10 +231,8 @@ export async function loadScene(t) { JSON.stringify({ fromActive, switch: await playFrom() }), ); await stop(); - await sleep(300); await openPlayFrom(); await clickText('Start scene'); - await sleep(300); check( 'the switch goes back to the start scene', (await playFrom()).mode === 'start' && (await playFrom()).scene === 'Level 2', @@ -243,7 +246,6 @@ export async function loadScene(t) { const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-sound']); p.assets = p.assets.filter((a) => !gone.has(a.id)); __gm.state.select(null);`); - await sleep(300); check( 'the step leaves nothing of its own behind', (await json('__gm.state.project.assets.filter((a) => a.id.startsWith("ls-")).length')) === 0 && diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index afed95cf..0af8a58a 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -76,7 +76,6 @@ export async function scaffoldBehavior(t, options) { make(${JSON.stringify(logicId)}, ${JSON.stringify(assetId)}, ${JSON.stringify(boxId)}, 0, ${JSON.stringify(name)}), ); `); - await t.sleep(400); return { assetId, @@ -93,7 +92,6 @@ export async function scaffoldBehavior(t, options) { scene.objects = scene.objects.filter((o) => !objects.includes(o.id)); p.assets = p.assets.filter((a) => !assets.includes(a.id)); `); - await t.sleep(300); return t.json(`{ objects: __gm.sceneObjects().filter((o) => ${JSON.stringify([boxId, logicId])}.includes(o.id)).length, assets: __gm.state.project.assets.filter((a) => a.id === ${JSON.stringify(assetId)}).length, @@ -125,10 +123,8 @@ export async function openTab(t, tab) { const row = await t.json('__gm.tabRow()'); if (row.hidden.includes(tab)) { await t.clickText('»'); - await t.sleep(300); } await t.clickText(TAB_LABELS[tab]); - await t.sleep(400); } /** @@ -144,11 +140,9 @@ export async function pickAssetType(t, label) { let scrolled = false; if (box && (!p || p.y > box.y + box.h - 4)) { await t.wheel(box.x + box.w / 2, box.y + box.h / 2, 300); - await t.sleep(250); p = await t.locate(label, true); scrolled = true; } await t.click(p.x, p.y); - await t.sleep(250); return { box, p, scrolled }; } diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index aea216ac..9b7995e0 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -6,7 +6,17 @@ import { writeFileSync } from 'node:fs'; import { launchBrowser } from './browser.mjs'; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +/** + * Every wait the checks do goes through here, and the time is booked. A run that takes ten + * minutes needs to say how much of that was the editor working and how much was the harness + * waiting for a deadline it made up – the two call for opposite fixes. + */ +export const waited = { ms: 0, calls: 0 }; +const sleep = (ms) => { + waited.ms += ms; + waited.calls++; + return new Promise((r) => setTimeout(r, ms)); +}; /** Generous on purpose: a slow navigation still finishes, a wedged chromium never does. */ const CDP_TIMEOUT_MS = 30_000; @@ -131,6 +141,41 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { }; /** Evaluates an expression in the page and returns it as plain JSON; `undefined` comes back as null. */ const json = async (expression) => JSON.parse(await evaluate(`JSON.stringify((${expression}) ?? null)`)); + /** + * Waits for the editor to have drawn, instead of for a deadline someone guessed. A frame costs + * ~16 ms where the fixed waits this replaces cost 150–300, and it is the editor itself that + * says when it has caught up – so the same call is both faster and less flaky than the number + * it replaces. Two frames by default: one for the handler, one for the layout it invalidated. + * + * Falls back to a fixed wait when the page has no such hook (an old build, or before the first + * navigation), because a silent no-wait would turn every later check into a coin toss. + */ + const settle = async (count = 2) => { + const at = Date.now(); + const ok = await evaluate( + `window.__gm?.afterFrames ? __gm.afterFrames(${count}).then(() => true) : false`, + ); + if (!ok) return sleep(150); + waited.ms += Date.now() - at; + waited.calls++; + }; + /** + * Waits until `expression` is true in the page – for the things a frame cannot answer, such as a + * save that is still in flight. Polls, because the conditions live all over the model and none + * of them has an event worth inventing one for. + */ + const waitUntil = async (expression, { timeout = 5000, label = expression } = {}) => { + const at = Date.now(); + while (Date.now() - at < timeout) { + if (await json(`Boolean(${expression})`)) { + waited.ms += Date.now() - at; + waited.calls++; + return; + } + await sleep(25); + } + throw new Error(`waited ${timeout} ms and ${label} never came true`); + }; /** Like `locate`, but null instead of throwing – which a check for absence needs. */ const find = async (text, last = false) => (await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`)) || null; @@ -163,7 +208,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await mouse('mouseMoved', x, y); await mouse('mousePressed', x, y, button, extra); await mouse('mouseReleased', x, y, button, extra); - await sleep(150); + await settle(); }; /** * A real double-click: two press/release pairs in quick succession, the second carrying @@ -176,13 +221,13 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await mouse('mousePressed', x, y, 'left', { clickCount }); await mouse('mouseReleased', x, y, 'left', { clickCount }); } - await sleep(150); + await settle(); }; /** Mouse wheel over (x, y); positive `dy` scrolls down. */ const wheel = async (x, y, dy) => { await mouse('mouseMoved', x, y); await mouse('mouseWheel', x, y, 'none', { deltaX: 0, deltaY: dy }); - await sleep(150); + await settle(); }; const clickText = async (text, button = 'left') => { const p = await locate(text); @@ -206,7 +251,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await sleep(16); } await mouse('mouseReleased', x2, y2, button, extra); - await sleep(150); + await settle(); }; /** @@ -231,7 +276,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await sleep(16); } await mouse('mouseReleased', x, y); - await sleep(200); + await settle(); }; const keyEvent = (type, name, modifiers) => { @@ -243,6 +288,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { const press = async (name, modifiers = {}) => { await keyEvent('keyDown', name, modifiers); await keyEvent('keyUp', name, modifiers); + await settle(); }; /** Hold a key for `ms` milliseconds (arrow keys while playing). */ const holdKey = async (name, ms) => { @@ -267,14 +313,13 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { throw new Error( `typing "${text}" did not arrive: focus is ${typed.tag}, it holds ${JSON.stringify(typed.value)}`, ); + await settle(); }; const undo = async () => { await press('z', { ctrl: true }); - await sleep(200); }; const redo = async () => { await press('z', { ctrl: true, shift: true }); - await sleep(200); }; /** Selection, mode, save status and notice – the facts most checks look at. */ @@ -294,22 +339,47 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`); const world = () => json('__gm.world()'); const entity = async (objectId) => (await world())?.entities.find((e) => e.objectId === objectId); - const select = (selection) => evaluate(`__gm.state.select(${JSON.stringify(selection)})`); + const select = async (selection) => { + await evaluate(`__gm.state.select(${JSON.stringify(selection)})`); + await settle(); + }; + /** + * Switching the centre tab rebuilds a whole panel, so it needs the same wait an action gets. + * It is a helper rather than a bare `evaluate` in every step because a tab change without one + * reads the old panel – which is a check that passes on stale content, the worst kind. + */ + const showTab = async (tab) => { + await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); + await settle(); + }; + /** + * Waits for the autosave to have reached the server. The status goes `dirty` → `saving` → + * `saved`, and only the last one means the document a reload would fetch holds the change. + */ + const saved = () => + waitUntil("__gm.state.saveStatus === 'saved'", { timeout: 9000, label: 'the save finished' }); /** * Runs `body` (JavaScript source) against the page's model and previews the result, the way a * scrub or a command would – for putting the scene into a known state between checks. */ const mutate = async (body) => { await evaluate(`(() => { ${body}; __gm.state.preview(); })()`); - await sleep(200); + await settle(); }; + /** + * Starting and stopping a run build or tear down a whole world, which is more than the two frames + * `settle()` grants – so these wait for the mode itself to have changed, then for a drawn frame so + * the panels that follow the mode are on screen too. + */ const play = async () => { await clickText('▶ Play'); - await sleep(300); + await waitUntil("__gm.state.mode === 'play'", { label: 'the run started' }); + await settle(); }; const stop = async () => { await press('Escape'); - await sleep(300); + await waitUntil("__gm.state.mode === 'edit'", { label: 'the run stopped' }); + await settle(); }; // Timeout, not convenience: a stalled response keeps its socket open, so node stays alive and @@ -342,6 +412,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { sleep, evaluate, json, + settle, + waitUntil, find, has, locate, @@ -368,6 +440,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { world, entity, select, + showTab, + saved, mutate, play, stop,