A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
game-maker / suite (push) Successful in 6m48s

The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.

The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.

Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.

564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.

Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
This commit is contained in:
admin committed 2026-09-16 08:08:10 +02:00
1 parent c990dbcb26
commit 7e5003b590
41 files changed
+362 -732

No files matched your search

+17 -1
View File
@@ -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;
+8 -1
View File
@@ -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';
+38
View File
@@ -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<void> {
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 = [];
}
+45
View File
@@ -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();
}
});
});
+27 -2
View File
@@ -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(', '),
);
});
+1 -3
View File
@@ -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());
}
-4
View File
@@ -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',
+4 -12
View File
@@ -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,
+5 -19
View File
@@ -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,
+1 -3
View File
@@ -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,
+9 -73
View File
@@ -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');
}
-9
View File
@@ -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),
-3
View File
@@ -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');
}
+2 -24
View File
@@ -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(
-9
View File
@@ -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')));
-1
View File
@@ -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
-8
View File
@@ -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;
+5 -11
View File
@@ -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(
+8 -19
View File
@@ -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',
-4
View File
@@ -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");
+8 -23
View File
@@ -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 `<instance>~<outer>/<inner>`.
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);
}
+17 -11
View File
@@ -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 &&
+4 -14
View File
@@ -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() };
+3 -33
View File
@@ -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,
-6
View File
@@ -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(
-33
View File
@@ -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',
);
-16
View File
@@ -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',
+3 -50
View File
@@ -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));
}
+2 -20
View File
@@ -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));
}
+8 -44
View File
@@ -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",
);
+9 -56
View File
@@ -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,
+6 -67
View File
@@ -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 &&
+2 -14
View File
@@ -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 &&
+3 -14
View File
@@ -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,
+7 -54
View File
@@ -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 &&
+3 -14
View File
@@ -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);
}
+12 -2
View File
@@ -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);
+2 -22
View File
@@ -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,
+17 -15
View File
@@ -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 &&
-6
View File
@@ -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 };
}
+86 -12
View File
@@ -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,