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 { findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture'; import type { FrameRect } from '../core/model/texture';
import { effectiveScale, gameClock } from '../core/time'; 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 { particleLibrary } from '../viewport/ParticleLibrary';
import { shaderLibrary } from '../viewport/ShaderLibrary'; import { shaderLibrary } from '../viewport/ShaderLibrary';
import { soundLibrary } from '../viewport/SoundLibrary'; import { soundLibrary } from '../viewport/SoundLibrary';
@@ -530,6 +540,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
renderStats: () => ({ drawn: renderCount(), awake: isRendering() }), renderStats: () => ({ drawn: renderCount(), awake: isRendering() }),
/** Draw again, whatever happens next – what a screenshot needs before it is taken. */ /** Draw again, whatever happens next – what a screenshot needs before it is taken. */
wake: () => requestRender(), 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. */ /** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */
filterCount: () => { filterCount: () => {
let n = 0; 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 { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack';
export { Theme, textStyle, type TextStyleToken } from './Theme'; export { Theme, textStyle, type TextStyleToken } from './Theme';
export { initUI, ui, type UIContext } from './UIContext'; 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 { drawBand, drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
+38
View File
@@ -37,6 +37,39 @@ export function renderCount(): number {
return rendered; 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 * 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. * 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; if (!isRendering()) return;
rendered += 1; rendered += 1;
app.render(); 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, undefined,
UPDATE_PRIORITY.LOW, UPDATE_PRIORITY.LOW,
@@ -60,4 +97,5 @@ export function initRenderLoop(app: Application): void {
export function resetRenderLoop(): void { export function resetRenderLoop(): void {
deadline = 0; deadline = 0;
rendered = 0; rendered = 0;
frameWaiters = [];
} }
+45
View File
@@ -1,6 +1,7 @@
import type { Application } from 'pixi.js'; import type { Application } from 'pixi.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
afterFrames,
initRenderLoop, initRenderLoop,
isRendering, isRendering,
renderCount, renderCount,
@@ -93,4 +94,48 @@ describe('render loop', () => {
tick(); tick();
expect(app.drawn).toBe(1); 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. * rely on what earlier ones leave behind, a subset that skips a prerequisite fails on purpose.
*/ */
import { STEPS } from './e2e/index.mjs'; 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'; import { PROJECTS, cliArgs } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('e2e.mjs', PROJECTS.fixture); 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(', ')}`); 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 runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async (t) => {
await t.navigate(); await t.navigate();
await t.shot('01-loaded'); 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. * without touching the selection, and selecting a nested object unfolds the way down to it.
*/ */
export async function hierarchy(t) { 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 rowIds = () => json('__gm.hierarchyOrder()');
const rows = async () => JSON.stringify(await rowIds()); const rows = async () => JSON.stringify(await rowIds());
const selectionBefore = (await state()).selection; const selectionBefore = (await state()).selection;
@@ -48,13 +48,11 @@ export async function hierarchy(t) {
); );
await select({ kind: 'object', holderId: 'scene1', objectId: 'oc1' }); await select({ kind: 'object', holderId: 'scene1', objectId: 'oc1' });
await sleep(150);
check( check(
'selecting a nested object unfolds its ancestors', 'selecting a nested object unfolds its ancestors',
same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']),
await rows(), await rows(),
); );
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); 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()); 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 typeText('Renamed crate');
await shot('03-typing'); await shot('03-typing');
await press('Enter'); await press('Enter');
await sleep(200);
check( check(
'typing + Enter renamed the object', 'typing + Enter renamed the object',
(await object('o2')).name === 'Renamed crate', (await object('o2')).name === 'Renamed crate',
@@ -77,7 +76,6 @@ export async function inspector(t) {
await doubleClick(nameLabel.x + 160, nameLabel.y); await doubleClick(nameLabel.x + 160, nameLabel.y);
await typeText('Twice'); await typeText('Twice');
await press('Enter'); await press('Enter');
await sleep(200);
check( check(
'double-clicking a text field selects all – typing replaces the name', 'double-clicking a text field selects all – typing replaces the name',
(await object('o2')).name === 'Twice', (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 // Undone rather than typed back: check 2h further down counts its undos, so this must not
// leave a step on the stack. // leave a step on the stack.
await undo(); await undo();
await sleep(200);
check( check(
'undo takes that rename back', 'undo takes that rename back',
(await object('o2')).name === 'Renamed crate', (await object('o2')).name === 'Renamed crate',
@@ -172,7 +169,6 @@ export async function inspector(t) {
JSON.stringify(stillTyping), JSON.stringify(stillTyping),
); );
await press('Escape'); await press('Escape');
await sleep(200);
const after = await json('__gm.inspectorInfo()'); const after = await json('__gm.inspectorInfo()');
check( check(
'leaving the field runs the postponed rebuild', '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) { export async function play(t) {
const { const {
check, check,
sleep,
evaluate, evaluate,
json, json,
shot, shot,
@@ -27,6 +26,7 @@ export async function play(t) {
select, select,
mutate, mutate,
stop, stop,
showTab,
} = t; } = t;
const gameInfo = () => json('__gm.gameInfo()'); const gameInfo = () => json('__gm.gameInfo()');
const play = t.play; const play = t.play;
@@ -92,7 +92,6 @@ export async function play(t) {
); );
await shot('08b-editing-while-playing'); await shot('08b-editing-while-playing');
await press('Delete'); await press('Delete');
await sleep(150);
const liveIds = (await world()).entities.map((e) => e.objectId); const liveIds = (await world()).entities.map((e) => e.objectId);
check( check(
'Delete while playing removes the entity and its collider child from the world', '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. // Stop discards it with the rest of the runtime copy.
const visibleBeforeSpawn = (await gameInfo()).visibleItems; const visibleBeforeSpawn = (await gameInfo()).visibleItems;
await press('Space'); await press('Space');
await sleep(200);
const spawnedObjects = await objects(); const spawnedObjects = await objects();
const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id)); const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id));
check( check(
@@ -162,7 +160,6 @@ export async function play(t) {
// 3d. Without a camera nothing is rendered: delete the camera object, play, then undo. // 3d. Without a camera nothing is rendered: delete the camera object, play, then undo.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
await press('Delete'); await press('Delete');
await sleep(200);
check('camera object deleted', (await objects()).length === 6); check('camera object deleted', (await objects()).length === 6);
await play(); await play();
info = await gameInfo(); 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. // Found through its row, not an offset from the label: the label column's width is layout.
const enabledRow = await json("__gm.inspectorField('Enabled')"); const enabledRow = await json("__gm.inspectorField('Enabled')");
await click(enabledRow.x, enabledRow.y); await click(enabledRow.x, enabledRow.y);
await sleep(150);
check( check(
'the camera has the enabled switch like any other object', 'the camera has the enabled switch like any other object',
(await object('o3')).overrides.enabled === false, (await object('o3')).overrides.enabled === false,
@@ -222,7 +218,6 @@ export async function play(t) {
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const switchBackOn = await json("__gm.inspectorField('Enabled')"); const switchBackOn = await json("__gm.inspectorField('Enabled')");
await click(switchBackOn.x, switchBackOn.y); await click(switchBackOn.x, switchBackOn.y);
await sleep(150);
await play(); await play();
info = await gameInfo(); info = await gameInfo();
check( check(
@@ -249,8 +244,7 @@ export async function play(t) {
const p = await json(`__gm.gameScreen('o1')`); const p = await json(`__gm.gameScreen('o1')`);
return json(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`); return json(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`);
}; };
await evaluate(`__gm.showTab('game')`); await showTab('game');
await sleep(150);
const preview = await gameInfo(); const preview = await gameInfo();
const previewPixel = await crateSpot(); const previewPixel = await crateSpot();
check( check(
@@ -276,11 +270,10 @@ export async function play(t) {
await stop(); await stop();
// Switching the only camera off leaves nothing to preview – and says so, live on the tab. // 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' }); await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const previewSwitch = await json("__gm.inspectorField('Enabled')"); const previewSwitch = await json("__gm.inspectorField('Enabled')");
await click(previewSwitch.x, previewSwitch.y); await click(previewSwitch.x, previewSwitch.y);
await sleep(200);
info = await gameInfo(); info = await gameInfo();
check( check(
'switching the camera off empties the preview and names the reason', '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 shot('10d-no-active-camera');
await undo(); await undo();
await sleep(200);
info = await gameInfo(); info = await gameInfo();
check( check(
'and undo brings the preview back without a reload', 'and undo brings the preview back without a reload',
info.rendering && info.camera === 'o3' && (await json(`__gm.locate('No active camera')`)) === null, info.rendering && info.camera === 'o3' && (await json(`__gm.locate('No active camera')`)) === null,
JSON.stringify(info), JSON.stringify(info),
); );
await evaluate(`__gm.showTab('scene')`); await showTab('scene');
check( check(
'the fixture is left as it was found', 'the fixture is left as it was found',
(await objects()).length === 7 && Object.keys((await object('o3')).overrides).length === 0, (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, select,
objects, objects,
asset, asset,
showTab,
} = t; } = t;
// 4a. Context menu on the Crate asset. Placing left the menu when it became a drag. // 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')`))); check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`)));
await shot('11-context-menu'); await shot('11-context-menu');
await press('Escape'); await press('Escape');
await sleep(150);
// 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than // 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. // 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; const afterUndo = (await objects()).length;
check('one undo takes the dropped object back out', afterUndo === 7, `objects=${afterUndo}`); check('one undo takes the dropped object back out', afterUndo === 7, `objects=${afterUndo}`);
await redo(); await redo();
await sleep(150);
check('redo puts it back', (await objects()).length === 8); 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 – // 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 // 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. // tall enough to show all fifteen types without scrolling.
await clickText('+'); await clickText('+');
await sleep(150);
const rows = await json('__gm.menuRows()'); const rows = await json('__gm.menuRows()');
const cut = rows.indexOf(null); const cut = rows.indexOf(null);
const placeable = rows.slice(0, cut); const placeable = rows.slice(0, cut);
@@ -129,7 +127,6 @@ export async function assets(t) {
JSON.stringify({ above, below, margin, screenH }), JSON.stringify({ above, below, margin, screenH }),
); );
await clickText('Sprite'); await clickText('Sprite');
await sleep(100);
const names = await json('__gm.state.project.assets.map(a => a.name)'); const names = await json('__gm.state.project.assets.map(a => a.name)');
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names)); check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
await shot('12-new-asset'); 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: [] })`, `__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`,
); );
const secondScene = await json("__gm.assetRow('scene2')"); const secondScene = await json("__gm.assetRow('scene2')");
await evaluate("__gm.showTab('graph')"); await showTab('graph');
await sleep(150);
await doubleClick(secondScene.x, secondScene.y); await doubleClick(secondScene.x, secondScene.y);
check( check(
'double-clicking a scene asset makes it the active scene', '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()'], ['box-open', 'container', '__gm.container.current()'],
]; ];
for (const [id, tab, shown] of openable) { for (const [id, tab, shown] of openable) {
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(150);
const row = await json(`__gm.assetRow(${JSON.stringify(id)})`); const row = await json(`__gm.assetRow(${JSON.stringify(id)})`);
await click(row.x, row.y); await click(row.x, row.y);
await sleep(500); // past the double-click window, so the next press starts a fresh pair 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), JSON.stringify(clicked),
); );
await doubleClick(row.x, row.y); await doubleClick(row.x, row.y);
await sleep(300);
const opened = { tab: await json('__gm.tab()'), shown: await json(shown) }; const opened = { tab: await json('__gm.tab()'), shown: await json(shown) };
check( check(
`a double-click opens the ${tab} tab on it`, `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 // 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. // click does not give it – so it is not driven here.
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(150);
await clickText('+'); await clickText('+');
await sleep(200);
const entry = await evaluate(`__gm.locate('Behavior', true)`); const entry = await evaluate(`__gm.locate('Behavior', true)`);
await click(entry.x, entry.y); await click(entry.x, entry.y);
await sleep(400);
const created = await json( const created = await json(
"(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''", "(__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( await mutate(
`__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open'].includes(a.id))`, `__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 showTab('scene');
await sleep(150);
// 4e. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour, // 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 // 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. // reset is the first on screen and colour's the last.
await select({ kind: 'asset', assetId: 'crate' }); await select({ kind: 'asset', assetId: 'crate' });
await sleep(200);
const heightReset = await evaluate(`__gm.locate('↺')`); const heightReset = await evaluate(`__gm.locate('↺')`);
const colorReset = await evaluate(`__gm.locate('↺', true)`); const colorReset = await evaluate(`__gm.locate('↺', true)`);
check('asset inspector shows a reset per deviating property', Boolean(heightReset)); check('asset inspector shows a reset per deviating property', Boolean(heightReset));
@@ -239,7 +228,6 @@ export async function assets(t) {
JSON.stringify({ heightReset, colorReset }), JSON.stringify({ heightReset, colorReset }),
); );
await click(heightReset.x, heightReset.y); await click(heightReset.x, heightReset.y);
await sleep(100);
check( check(
'reset puts the asset property back to its default', 'reset puts the asset property back to its default',
(await asset('crate')).props.height === 100, (await asset('crate')).props.height === 100,
@@ -281,7 +269,6 @@ export async function assets(t) {
await assetColor('mover'), await assetColor('mover'),
); );
await click(heightReset.x, heightReset.y); await click(heightReset.x, heightReset.y);
await sleep(150);
check( check(
'the mark stays while the second deviation is left', 'the mark stays while the second deviation is left',
(await assetColor('crate')) === GOLD, (await assetColor('crate')) === GOLD,
@@ -289,7 +276,6 @@ export async function assets(t) {
); );
const colorOnly = await evaluate(`__gm.locate('↺')`); const colorOnly = await evaluate(`__gm.locate('↺')`);
await click(colorOnly.x, colorOnly.y); await click(colorOnly.x, colorOnly.y);
await sleep(150);
check( check(
'resetting the last deviation clears the mark, without a reload', 'resetting the last deviation clears the mark, without a reload',
(await assetColor('crate')) === PLAIN, (await assetColor('crate')) === PLAIN,
+1 -3
View File
@@ -1,6 +1,6 @@
/** Draw order: symbols always above sprites, `z` above hierarchy order. */ /** Draw order: symbols always above sprites, `z` above hierarchy order. */
export async function drawing(t) { 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')`); 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 // 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()), JSON.stringify(await orderOfCrates()),
); );
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' }); await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
await sleep(200);
const zLabel = await locate('Z'); const zLabel = await locate('Z');
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
await typeText('5'); await typeText('5');
await press('Enter'); await press('Enter');
await sleep(200);
check( check(
'Z field sets position.z', 'Z field sets position.z',
(await object('o1')).position.z === 5, (await object('o1')).position.z === 5,
+9 -73
View File
@@ -6,6 +6,7 @@ export async function graph(t) {
const { const {
check, check,
sleep, sleep,
saved,
evaluate, evaluate,
json, json,
find, find,
@@ -26,6 +27,7 @@ export async function graph(t) {
api, api,
navigate, navigate,
projectId, projectId,
showTab,
} = t; } = t;
const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id); const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id);
const graphCounts = async () => { 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. // 6a. Selecting the Mover asset binds the graph; the Behavior tab shows it.
await clickText('Mover'); await clickText('Mover');
await evaluate(`__gm.showTab('graph')`); await showTab('graph');
await sleep(250);
check( check(
'graph editor shows the selected behavior', 'graph editor shows the selected behavior',
(await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph', (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 bare = await json('__gm.graph.emptySpot()');
const beforeClickPick = await graphCounts(); const beforeClickPick = await graphCounts();
await click(bare.x, bare.y, 'right'); await click(bare.x, bare.y, 'right');
await sleep(100);
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('stop'); await typeText('stop');
await sleep(150);
const stopRow = await locate('Stop game'); // nothing else on screen shows this label const stopRow = await locate('Stop game'); // nothing else on screen shows this label
await click(stopRow.x, stopRow.y); await click(stopRow.x, stopRow.y);
await sleep(100);
const afterClickPick = await graphCounts(); const afterClickPick = await graphCounts();
check( check(
'clicking a palette row adds exactly one node', '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. // 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const before13 = await graphCounts(); const before13 = await graphCounts();
await click(bare.x, bare.y, 'right'); await click(bare.x, bare.y, 'right');
await sleep(100);
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); 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 // 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 // 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 }), JSON.stringify({ gapAbove, gapBelow, paletteMargin, paletteScreenH }),
); );
await typeText('log'); await typeText('log');
await sleep(100);
await press('Enter'); await press('Enter');
await sleep(250);
const after13 = await graphCounts(); const after13 = await graphCounts();
s = await state(); s = await state();
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; 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`. // Enter the first row alphabetically, which was `Run behavior`.
const beforeRank = await graphCounts(); const beforeRank = await graphCounts();
await click(bare.x, bare.y, 'right'); await click(bare.x, bare.y, 'right');
await sleep(100);
await typeText('variable'); await typeText('variable');
await sleep(150);
const ranked = await json('__gm.graph.paletteRows()'); const ranked = await json('__gm.graph.paletteRows()');
const hits = (row) => `${row.label} ${row.type}`.toLowerCase().includes('variable'); const hits = (row) => `${row.label} ${row.type}`.toLowerCase().includes('variable');
const lastNamed = ranked.map(hits).lastIndexOf(true); const lastNamed = ranked.map(hits).lastIndexOf(true);
@@ -147,7 +140,6 @@ export async function graph(t) {
JSON.stringify(ranked), JSON.stringify(ranked),
); );
await press('Enter'); await press('Enter');
await sleep(250);
s = await state(); s = await state();
const rankPick = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; const rankPick = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
check( check(
@@ -156,7 +148,6 @@ export async function graph(t) {
JSON.stringify(rankPick), JSON.stringify(rankPick),
); );
await undo(); await undo();
await sleep(150);
check('undo removes it again', (await graphCounts()).nodes === beforeRank.nodes); 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 // 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'); await shot('18c-drop-into-empty');
const clampRow = await locate('Clamp'); const clampRow = await locate('Clamp');
await click(clampRow.x, clampRow.y); await click(clampRow.x, clampRow.y);
await sleep(200);
const afterDrop = await graphCounts(); const afterDrop = await graphCounts();
const wired = await state(); const wired = await state();
const clampEdge = (await asset('mover')).graph.edges.find( 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 mouse('mouseReleased', empty.x, empty.y);
await sleep(150); await sleep(150);
await press('Escape'); await press('Escape');
await sleep(150);
const cancelled = await graphCounts(); const cancelled = await graphCounts();
check( check(
'escaping the palette clears the edge and adds nothing', '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. // 6f. Delete removes the selected node together with its edge; undo brings both back.
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id }); 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'))); check('the node inspector has no delete button of its own', !(await t.has('Delete node')));
await press('Delete'); await press('Delete');
await sleep(150);
const deleted = await graphCounts(); const deleted = await graphCounts();
check( check(
'Delete removes the node and its edge', 'Delete removes the node and its edge',
@@ -324,8 +311,7 @@ export async function graph(t) {
typeof fpsPlay === 'number' && fpsPlay > 0, typeof fpsPlay === 'number' && fpsPlay > 0,
`fps=${fpsPlay}`, `fps=${fpsPlay}`,
); );
await evaluate(`__gm.showTab('graph')`); await showTab('graph');
await sleep(100);
const holding = holdKey('ArrowRight', 300); const holding = holdKey('ArrowRight', 300);
await sleep(250); await sleep(250);
await shot('19-graph-live'); 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. // 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(150);
await clickText('+ Variable'); await clickText('+ Variable');
await clickText('Number'); await clickText('Number');
await sleep(100);
check( check(
'adding a variable declares it on the behavior', 'adding a variable declares it on the behavior',
(await variableKeys()) === 'speed,var1', (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…'))); check('the header has no Add node button – the right-click palette is the way', !(await find('Add node…')));
await select(null); await select(null);
await sleep(100);
check( check(
'graph editor keeps its behavior when nothing is selected', 'graph editor keeps its behavior when nothing is selected',
(await evaluate(`__gm.graph.current()`)) === 'mover', (await evaluate(`__gm.graph.current()`)) === 'mover',
); );
await select({ kind: 'asset', assetId: 'cam' }); await select({ kind: 'asset', assetId: 'cam' });
await sleep(100);
check( check(
'graph editor keeps its behavior for a non-behavior asset', 'graph editor keeps its behavior for a non-behavior asset',
(await evaluate(`__gm.graph.current()`)) === 'mover', (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. // The picker is the *last* "Mover" on screen: the assets panel shows one too.
const pickerAt = await locate('Mover', true); const pickerAt = await locate('Mover', true);
await click(pickerAt.x, pickerAt.y); await click(pickerAt.x, pickerAt.y);
await sleep(150);
const spawnerRow = await locate('Spawner', true); const spawnerRow = await locate('Spawner', true);
await click(spawnerRow.x, spawnerRow.y); await click(spawnerRow.x, spawnerRow.y);
await sleep(200);
check( check(
'the header picker switches the graph to another behavior', 'the header picker switches the graph to another behavior',
(await evaluate(`__gm.graph.current()`)) === 'spawner', (await evaluate(`__gm.graph.current()`)) === 'spawner',
String(await evaluate(`__gm.graph.current()`)), String(await evaluate(`__gm.graph.current()`)),
); );
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(100);
check( check(
'graph editor rebinds when a behavior is selected again', 'graph editor rebinds when a behavior is selected again',
(await evaluate(`__gm.graph.current()`)) === 'mover', (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 // 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. // under it included – and the left button no longer pans at all.
await clickText('Fit'); await clickText('Fit');
await sleep(250);
const positions = async () => const positions = async () =>
(await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' '); (await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' ');
const area = await json('__gm.graph.canvasRect()'); 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. // Fit again: the pan just now pushed a node out of view, and a band takes only what it sees.
await clickText('Fit'); await clickText('Fit');
await sleep(250);
// An empty spot: after a fit every node sits at least a margin away from the corner. // 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 corner = { x: area.x + 6, y: area.y + 6 };
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 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'); const snapLabel = await locate('Snap');
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre. // The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
await click(snapLabel.x + 30, snapLabel.y); await click(snapLabel.x + 30, snapLabel.y);
await sleep(150);
check( check(
'the toggle switches it on without moving a single node', 'the toggle switches it on without moving a single node',
(await json('__gm.graph.snapping()')) === true && (await positions()) === before13i, (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. // A single node: dropped between two grid lines, it lands on the nearer one.
await click(corner.x, corner.y); await click(corner.x, corner.y);
await sleep(150);
const snapPre = await nodeOf('n-axis'); const snapPre = await nodeOf('n-axis');
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`); const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const snapBy = await units(50); 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. // Switching it off has to give the pointer its exact drop position back.
await click(snapLabel.x + 30, snapLabel.y); await click(snapLabel.x + 30, snapLabel.y);
await sleep(150);
await click(corner.x, corner.y); await click(corner.x, corner.y);
await sleep(150);
const offPre = await nodeOf('n-axis'); const offPre = await nodeOf('n-axis');
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const offBy = await units(50); const offBy = await units(50);
@@ -538,13 +511,11 @@ export async function graph(t) {
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`, `${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
); );
await undo(); await undo();
await sleep(150);
// 6m. Delete acts on the selection, not just the primary node. // 6m. Delete acts on the selection, not just the primary node.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const beforeDelete = await graphCounts(); const beforeDelete = await graphCounts();
await press('Delete'); await press('Delete');
await sleep(200);
const afterDelete = await graphCounts(); const afterDelete = await graphCounts();
check( check(
'Delete removes every selected node, edges with them', '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. // and a click cannot miss one that the earlier pan pushed over the edge.
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit'); await clickText('Fit');
await sleep(250);
const axisRow = await json(`__gm.graph.nodeAt('n-axis')`); const axisRow = await json(`__gm.graph.nodeAt('n-axis')`);
await click(axisRow.x, axisRow.y); await click(axisRow.x, axisRow.y);
const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis'); 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 sleep(250);
await clickText('Fit'); await clickText('Fit');
await sleep(300);
const rects = []; const rects = [];
for (const node of (await asset('mover')).graph.nodes) for (const node of (await asset('mover')).graph.nodes)
rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`)); 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(); })()`, `(() => { 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 clickText('Fit');
await sleep(250);
check('the parked node is back where it started', (await positions()) === before13i, await positions()); 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 // 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. // comes back while either of its two nodes is selected.
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit'); await clickText('Fit');
await sleep(250);
const allEdges = (await asset('mover')).graph.edges; const allEdges = (await asset('mover')).graph.edges;
const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0]; const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0];
const id = JSON.stringify(edge.id); 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)`, `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
); );
await click(outPort.x, outPort.y, 'right'); 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')`))); check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`)));
await clickText('Hide link'); await clickText('Hide link');
await sleep(250);
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(150);
const hidden = await edgeNow(); const hidden = await edgeNow();
check( check(
'hiding changes nothing but a flag – the connection is still there', '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. // 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)})`); const sourceAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.from.node)})`);
await click(sourceAt.x, sourceAt.y); await click(sourceAt.x, sourceAt.y);
await sleep(200);
check('selecting the node at one end brings the curve back', (await drawn()) === true); 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)})`); const targetAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.to.node)})`);
await click(targetAt.x, targetAt.y); await click(targetAt.x, targetAt.y);
await sleep(200);
check('selecting the node at the other end does too', (await drawn()) === true); check('selecting the node at the other end does too', (await drawn()) === true);
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
check('deselecting hides it again', (await drawn()) === false); 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 // 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. // puts the curve back – so it answers "where does this go?" in one click.
const mark2 = await json(`__gm.graph.foldMark(${id}, true)`); const mark2 = await json(`__gm.graph.foldMark(${id}, true)`);
await click(mark2.x, mark2.y); await click(mark2.x, mark2.y);
await sleep(200);
check( check(
'clicking the mark jumps to the node at the far end', 'clicking the mark jumps to the node at the far end',
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]), JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]),
@@ -684,7 +644,6 @@ export async function graph(t) {
await undo(); await undo();
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(250);
const shown = await edgeNow(); const shown = await edgeNow();
check( check(
'undo unhides the link, with nothing left behind', '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 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')`))); check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`)));
await clickText('Hide 2 links'); await clickText('Hide 2 links');
await sleep(250);
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
const ids = forked.map((e) => JSON.stringify(e.id)); const ids = forked.map((e) => JSON.stringify(e.id));
const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`); const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`);
const markB = await json(`__gm.graph.foldMark(${ids[1]}, 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 // 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. // lends all of its curves back at once.
await click(markA.x, markA.y); await click(markA.x, markA.y);
await sleep(250);
const picked = await json('__gm.graph.selected()'); const picked = await json('__gm.graph.selected()');
check( check(
'clicking a bundled mark selects every node the port feeds', '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 click(markA.x, markA.y, 'right');
await sleep(150);
check( check(
'the mark menu shows both links back in one go', 'the mark menu shows both links back in one go',
Boolean(await evaluate(`__gm.locate('Show 2 links')`)), Boolean(await evaluate(`__gm.locate('Show 2 links')`)),
); );
await clickText('Show 2 links'); await clickText('Show 2 links');
await sleep(250);
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
const unfolded = (await asset('mover')).graph.edges.filter( const unfolded = (await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port, (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. // One step of undo takes both back – hiding two links was one command, so it is one undo.
await undo(); await undo();
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(250);
const refolded = (await asset('mover')).graph.edges.filter( const refolded = (await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port, (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)), JSON.stringify(refolded.map((e) => e.folded)),
); );
await undo(); await undo();
await sleep(200);
const forkedNow = async () => const forkedNow = async () =>
(await asset('mover')).graph.edges.filter( (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)`, `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
); );
await click(portNow.x, portNow.y, 'right'); await click(portNow.x, portNow.y, 'right');
await sleep(150);
const offered = await find('Disconnect 2 links'); const offered = await find('Disconnect 2 links');
if (offered) await click(offered.x, offered.y); if (offered) await click(offered.x, offered.y);
await sleep(250); await sleep(250);
const cut = await forkedNow(); const cut = await forkedNow();
await undo(); await undo();
await sleep(250);
check( check(
'disconnecting both links at a port is one undo step', 'disconnecting both links at a port is one undo step',
Boolean(offered) && cut === 0 && (await forkedNow()) === 2, 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. // link lands on it until it is expanded, and the state is saved with the project.
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit'); await clickText('Fit');
await sleep(250);
const edgesBefore13q = (await graphCounts()).edges; const edgesBefore13q = (await graphCounts()).edges;
const openRect = await json(`__gm.graph.nodeRect('n-move')`); const openRect = await json(`__gm.graph.nodeRect('n-move')`);
const chevron = await json(`__gm.graph.chevronAt('n-move')`); const chevron = await json(`__gm.graph.chevronAt('n-move')`);
await click(chevron.x, chevron.y); await click(chevron.x, chevron.y);
await sleep(200);
const shut = await json(`__gm.graph.nodeRect('n-move')`); const shut = await json(`__gm.graph.nodeRect('n-move')`);
const zoom = openRect.w / 176; const zoom = openRect.w / 176;
check( check(
@@ -902,12 +848,9 @@ export async function graph(t) {
for (const port of ['x', 'y']) { for (const port of ['x', 'y']) {
const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`); const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`);
await click(at.x, at.y, 'right'); await click(at.x, at.y, 'right');
await sleep(150);
await clickText('Hide link'); await clickText('Hide link');
await sleep(200);
} }
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
const markDx = await json(`__gm.graph.foldMark('e3', false)`); const markDx = await json(`__gm.graph.foldMark('e3', false)`);
const markDy = await json(`__gm.graph.foldMark('e4', false)`); const markDy = await json(`__gm.graph.foldMark('e4', false)`);
check( check(
@@ -919,9 +862,7 @@ export async function graph(t) {
await undo(); await undo();
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); 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 click(title.x, title.y, 'right');
await sleep(150);
check( check(
'a mixed selection is offered both ways', 'a mixed selection is offered both ways',
Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) && Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) &&
@@ -929,14 +870,11 @@ export async function graph(t) {
); );
await press('Escape'); await press('Escape');
await undo(); await undo();
await sleep(150);
check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined); 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 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')`); const openTitle = await json(`__gm.graph.nodeAt('n-move')`);
await click(openTitle.x, openTitle.y, 'right'); await click(openTitle.x, openTitle.y, 'right');
await sleep(150);
const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`); const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`);
check('two expanded nodes are offered as one', collapseBoth !== null); check('two expanded nodes are offered as one', collapseBoth !== null);
if (collapseBoth) await click(collapseBoth.x, collapseBoth.y); 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]; 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]'); check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]');
await undo(); await undo();
await sleep(150);
check( check(
'and one undo takes all of it back', 'and one undo takes all of it back',
JSON.stringify(await both()) === '[null,null]', JSON.stringify(await both()) === '[null,null]',
@@ -957,16 +894,15 @@ export async function graph(t) {
const again = await json(`__gm.graph.chevronAt('n-move')`); const again = await json(`__gm.graph.chevronAt('n-move')`);
await click(again.x, again.y); await click(again.x, again.y);
const savedMove = async () => { const savedMove = async () => {
for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300); await saved();
const saved = await api(`projects/${projectId}`); const doc = await api(`projects/${projectId}`);
return saved.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move'); 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); check('the collapse reaches the saved file', (await savedMove()).collapsed === true);
await navigate(); await navigate();
await sleep(500); await sleep(500);
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
await evaluate(`__gm.showTab('graph')`); await showTab('graph');
await sleep(250);
const reloaded = await json(`__gm.graph.nodeRect('n-move')`); const reloaded = await json(`__gm.graph.nodeRect('n-move')`);
check( check(
'and a reloaded project draws it collapsed again', 'and a reloaded project draws it collapsed again',
@@ -977,5 +913,5 @@ export async function graph(t) {
await click(reopen.x, reopen.y); await click(reopen.x, reopen.y);
check('expanding leaves no trace in the file', !('collapsed' in (await savedMove()))); 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) { export async function parenting(t) {
const { const {
check, check,
sleep,
json, json,
shot, shot,
click, click,
@@ -36,7 +35,6 @@ export async function parenting(t) {
const redRow = await locate('Red crate'); const redRow = await locate('Red crate');
const blueRow = await locate('Blue crate'); const blueRow = await locate('Blue crate');
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y); await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
await sleep(100);
let o2 = await object('o2'); let o2 = await object('o2');
check( check(
'dropping a row onto another parents it, keeping the world position', '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 press('a', { ctrl: true });
await typeText('-50'); await typeText('-50');
await press('Enter'); await press('Enter');
await sleep(200);
o2 = await object('o2'); o2 = await object('o2');
const parent = await object('o2').then((o) => object(o.parentId)); 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 }; 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. // 7c. Play: entities mirror the hierarchy; undo restores parent and position.
await select(null); await select(null);
await press('Enter', { ctrl: true }); await press('Enter', { ctrl: true });
await sleep(300);
const e2 = await entity('o2'); const e2 = await entity('o2');
check( check(
'play instantiates the parent link and world positions', 'play instantiates the parent link and world positions',
@@ -100,7 +96,6 @@ export async function parenting(t) {
JSON.stringify(entries), JSON.stringify(entries),
); );
await typeText('red'); await typeText('red');
await sleep(150);
const filtered = await json('__gm.overlayTexts()'); const filtered = await json('__gm.overlayTexts()');
check( check(
'typing filters the picker down to the matching entries', 'typing filters the picker down to the matching entries',
@@ -109,7 +104,6 @@ export async function parenting(t) {
); );
await shot('30-parent-search'); await shot('30-parent-search');
await press('Enter'); // takes the first match await press('Enter'); // takes the first match
await sleep(150);
o2 = await object('o2'); o2 = await object('o2');
check( check(
'inspector Parent menu parents the object', 'inspector Parent menu parents the object',
@@ -120,7 +114,6 @@ export async function parenting(t) {
const blueRow2 = await locate('Blue crate'); const blueRow2 = await locate('Blue crate');
await click(blueRow2.x, blueRow2.y, 'right'); await click(blueRow2.x, blueRow2.y, 'right');
await clickText('Unparent (move to top level)'); await clickText('Unparent (move to top level)');
await sleep(100);
o2 = await object('o2'); o2 = await object('o2');
check( check(
'Unparent moves the object to the top level, keeping its world position', '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'); 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. // 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 drag(blueRow3.x, blueRow3.y, redRow3.x, redRow3.y - 10);
await sleep(150);
const rowsAfter = await json('__gm.hierarchyOrder()'); const rowsAfter = await json('__gm.hierarchyOrder()');
o2 = await object('o2'); o2 = await object('o2');
check( check(
@@ -155,7 +147,6 @@ export async function parenting(t) {
); );
await shot('31-reordered'); await shot('31-reordered');
await undo(); await undo();
await sleep(150);
check( check(
'undo puts the order back', 'undo puts the order back',
same(await json('__gm.hierarchyOrder()'), rowsBefore), same(await json('__gm.hierarchyOrder()'), rowsBefore),
-3
View File
@@ -34,7 +34,6 @@ export async function colliders(t) {
const redRow = await locate('Red crate'); const redRow = await locate('Red crate');
await click(redRow.x, redRow.y, 'right'); await click(redRow.x, redRow.y, 'right');
await clickText('Add collider'); await clickText('Add collider');
await sleep(100);
const s = await state(); const s = await state();
const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
const colAsset = colObj ? await asset(colObj.assetId) : 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};` : ''} ${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''}
__gm.state.select(null)`); __gm.state.select(null)`);
await press('Enter', { ctrl: true }); await press('Enter', { ctrl: true });
await sleep(300);
await holdKey('ArrowRight', 700); await holdKey('ArrowRight', 700);
const red = await entity('o1'); const red = await entity('o1');
await stop(); 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 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 select({ kind: 'object', objectId: 'oc2' });
await sleep(200);
await shot('22-trigger-collider'); await shot('22-trigger-collider');
} }
+2 -24
View File
@@ -7,6 +7,7 @@ import { same } from '../lib/harness.mjs';
*/ */
export async function containers(t) { export async function containers(t) {
const { const {
saved,
check, check,
sleep, sleep,
evaluate, evaluate,
@@ -44,7 +45,6 @@ export async function containers(t) {
await press('a', { ctrl: true }); await press('a', { ctrl: true });
await typeText(String(value)); await typeText(String(value));
await press('Enter'); await press('Enter');
await sleep(200);
}; };
const notice = async () => { const notice = async () => {
await evaluate("__gm.state.notice = ''"); await evaluate("__gm.state.notice = ''");
@@ -52,7 +52,6 @@ export async function containers(t) {
}; };
const selectObject = async (id) => { const selectObject = async (id) => {
await select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [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: // 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; const listedBeforeDelete = (await objects()).length;
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await press('Delete'); await press('Delete');
await sleep(150);
check( check(
'Delete removes the whole multi-selection with its subtrees', 'Delete removes the whole multi-selection with its subtrees',
(await objects()).length === listedBeforeDelete - 5, (await objects()).length === listedBeforeDelete - 5,
@@ -131,7 +129,6 @@ export async function containers(t) {
const listedBefore = (await objects()).length; const listedBefore = (await objects()).length;
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await clickText('Create container'); await clickText('Create container');
await sleep(150);
s = await state(); s = await state();
const instanceA = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const instanceA = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
const containerAsset = instanceA ? await asset(instanceA.assetId) : null; const containerAsset = instanceA ? await asset(instanceA.assetId) : null;
@@ -304,11 +301,8 @@ export async function containers(t) {
const before = (await objects()).length; const before = (await objects()).length;
const noticeNow = await notice(); const noticeNow = await notice();
await click(row.x, row.y, 'right'); await click(row.x, row.y, 'right');
await sleep(150);
await clickText('Add collider'); await clickText('Add collider');
await sleep(200);
await press('Escape'); await press('Escape');
await sleep(100);
return { added: (await objects()).length - before, notice: await noticeNow() }; return { added: (await objects()).length - before, notice: await noticeNow() };
}; };
const refused = JSON.stringify({ added: 0, notice: '' }); const refused = JSON.stringify({ added: 0, notice: '' });
@@ -340,11 +334,9 @@ export async function containers(t) {
JSON.stringify(fromPlain), JSON.stringify(fromPlain),
); );
await undo(); await undo();
await sleep(200);
await selectObject(redA); await selectObject(redA);
let noticeAfter = await notice(); let noticeAfter = await notice();
await press('Delete'); await press('Delete');
await sleep(200);
check( check(
'removing resolved content is refused with a notice', 'removing resolved content is refused with a notice',
(await noticeAfter()).includes('container template') && Boolean(await object(redA)), (await noticeAfter()).includes('container template') && Boolean(await object(redA)),
@@ -352,7 +344,6 @@ export async function containers(t) {
); );
noticeAfter = await notice(); noticeAfter = await notice();
await press('g', { ctrl: true }); await press('g', { ctrl: true });
await sleep(200);
check( check(
'making a container out of resolved content is refused too', 'making a container out of resolved content is refused too',
(await noticeAfter()).includes('container template'), (await noticeAfter()).includes('container template'),
@@ -360,7 +351,6 @@ export async function containers(t) {
); );
const parentRow = await json("__gm.inspectorField('Parent')"); const parentRow = await json("__gm.inspectorField('Parent')");
await click(parentRow.x, parentRow.y); await click(parentRow.x, parentRow.y);
await sleep(150);
check( check(
'the Parent picker does not even open for resolved content', 'the Parent picker does not even open for resolved content',
same(await json('__gm.overlayTexts()'), []), same(await json('__gm.overlayTexts()'), []),
@@ -377,7 +367,6 @@ export async function containers(t) {
const redARow = await rowAt(redA); const redARow = await rowAt(redA);
const otherRow = await rowAt(instanceB.id); const otherRow = await rowAt(instanceB.id);
await drag(redARow.x, redARow.y, otherRow.x, otherRow.y); await drag(redARow.x, redARow.y, otherRow.x, otherRow.y);
await sleep(250);
check( check(
're-parenting resolved content by dragging its row is refused', 're-parenting resolved content by dragging its row is refused',
(await noticeAfter()).includes('container template') && (await object(redA)).parentId === instanceA.id, (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 has('Follows a template of 5 object(s).'),
); );
await clickText('Unpack'); await clickText('Unpack');
await sleep(250);
check( check(
'Unpack writes the content into the scene, keeping the ids and the listing', 'Unpack writes the content into the scene, keeping the ids and the listing',
(await storedObjects()).length === storedBeforeUnpack + 5 && (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. // not freeze the picker any more – the inspector has to read the holder, not the id.
const unpackedParentRow = await json("__gm.inspectorField('Parent')"); const unpackedParentRow = await json("__gm.inspectorField('Parent')");
await click(unpackedParentRow.x, unpackedParentRow.y); await click(unpackedParentRow.x, unpackedParentRow.y);
await sleep(150);
check( check(
'unpacked content offers the Parent picker like any ordinary object', 'unpacked content offers the Parent picker like any ordinary object',
(await json('__gm.overlayTexts()')).includes('None'), (await json('__gm.overlayTexts()')).includes('None'),
JSON.stringify(await json('__gm.overlayTexts()')), JSON.stringify(await json('__gm.overlayTexts()')),
); );
await press('Escape'); await press('Escape');
await sleep(100);
noticeAfter = await notice(); noticeAfter = await notice();
const redCRow = await rowAt(redC); const redCRow = await rowAt(redC);
await click(redCRow.x, redCRow.y, 'right'); await click(redCRow.x, redCRow.y, 'right');
await clickText('Unparent (move to top level)'); await clickText('Unparent (move to top level)');
await sleep(250);
check( check(
'unpacked content can be re-parented, which was refused while it followed the template', 'unpacked content can be re-parented, which was refused while it followed the template',
(await object(redC)).parentId === '' && (await noticeAfter()) === '', (await object(redC)).parentId === '' && (await noticeAfter()) === '',
@@ -450,7 +435,6 @@ export async function containers(t) {
noticeAfter = await notice(); noticeAfter = await notice();
await selectObject(redC); await selectObject(redC);
await press('Delete'); await press('Delete');
await sleep(250);
check( check(
'and it can be removed – the refusal was about following a template, not about the id', 'and it can be removed – the refusal was about following a template, not about the id',
!(await object(redC)) && (await noticeAfter()) === '', !(await object(redC)) && (await noticeAfter()) === '',
@@ -458,7 +442,6 @@ export async function containers(t) {
); );
await selectObject(instanceC.id); await selectObject(instanceC.id);
await press('Delete'); await press('Delete');
await sleep(250);
check( check(
'removing the unpacked container takes the rest of its content with it', 'removing the unpacked container takes the rest of its content with it',
(await storedObjects()).length === storedBeforeUnpack - 1, (await storedObjects()).length === storedBeforeUnpack - 1,
@@ -475,7 +458,6 @@ export async function containers(t) {
const beforeSpawn = await objects(); const beforeSpawn = await objects();
await play(); await play();
await press('Space'); await press('Space');
await sleep(250);
const fresh = (await objects()).filter((o) => !beforeSpawn.some((b) => b.id === o.id)); const fresh = (await objects()).filter((o) => !beforeSpawn.some((b) => b.id === o.id));
const spawnedRoot = fresh.find((o) => o.parentId === ''); const spawnedRoot = fresh.find((o) => o.parentId === '');
check( 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('↺'))); check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await has('↺')));
await shot('27-apply-buttons'); await shot('27-apply-buttons');
await click(applyButton.x, applyButton.y); await click(applyButton.x, applyButton.y);
await sleep(150);
check( check(
'apply writes x into the template and the untouched instance follows', 'apply writes x into the template and the untouched instance follows',
(await templateRed()).position.x === -100 && (await object(redA)).position.x === -100, (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('↺')`); const revertButton = await evaluate(`__gm.locate('↺')`);
await click(revertButton.x, revertButton.y); await click(revertButton.x, revertButton.y);
await sleep(150);
const revertedB = (await storedObjects()).find((o) => o.id === instanceB.id); const revertedB = (await storedObjects()).find((o) => o.id === instanceB.id);
check( check(
'revert drops the deviation, so the instance follows the template again', '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 shot('28-apply-menu');
await clickText('Apply to asset'); await clickText('Apply to asset');
await sleep(150);
check( check(
'Apply to asset writes the value into the asset and drops the deviation', '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, (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 click(applyMenu.x, applyMenu.y);
await clickText('Apply to container'); await clickText('Apply to container');
await sleep(150);
check( check(
'Apply to container writes the override into the template, so every instance shows it', '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, (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. // The content must be nowhere in the saved scene, and reloading must resolve it back.
await selectObject(redB); await selectObject(redB);
await setField('Y', 12); 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 savedScene = sceneOf(await api(`projects/${projectId}`));
const savedInstance = savedScene.objects.find((o) => o.id === instanceB.id); const savedInstance = savedScene.objects.find((o) => o.id === instanceB.id);
check( check(
-9
View File
@@ -2,7 +2,6 @@
export async function textAndEnabled(t) { export async function textAndEnabled(t) {
const { const {
check, check,
sleep,
evaluate, evaluate,
json, json,
has, 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. // 10a. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+'); await clickText('+');
await clickText('Text'); await clickText('Text');
await sleep(100);
const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`); const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`);
check('+ menu creates a text asset', Boolean(textAssetId)); check('+ menu creates a text asset', Boolean(textAssetId));
await mutate(` await mutate(`
@@ -31,7 +29,6 @@ export async function textAndEnabled(t) {
const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position; 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.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' })`); __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')); check('a placed text object draws its content in the scene', await has('Hello world'));
await shot('29-text-object'); await shot('29-text-object');
@@ -44,7 +41,6 @@ export async function textAndEnabled(t) {
const gizmoOn = await gizmoOf('otext'); const gizmoOn = await gizmoOf('otext');
check('a selected object is framed at full strength', gizmoOn?.alpha === 1, JSON.stringify(gizmoOn)); check('a selected object is framed at full strength', gizmoOn?.alpha === 1, JSON.stringify(gizmoOn));
await click(enabledRow.x, enabledRow.y); await click(enabledRow.x, enabledRow.y);
await sleep(100);
const textObject = await object('otext'); const textObject = await object('otext');
check( check(
'clicking it writes an override instead of changing the asset', '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')`); const otherRow = await evaluate(`__gm.hierarchyRow('o3')`);
await click(otherRow.x, otherRow.y); await click(otherRow.x, otherRow.y);
await sleep(100);
check('selecting something else drops the frame', (await gizmoOf('otext')) === null); check('selecting something else drops the frame', (await gizmoOf('otext')) === null);
const offRow = await evaluate(`__gm.hierarchyRow('otext')`); const offRow = await evaluate(`__gm.hierarchyRow('otext')`);
await click(offRow.x, offRow.y); await click(offRow.x, offRow.y);
await sleep(100);
const gizmoBack = await gizmoOf('otext'); const gizmoBack = await gizmoOf('otext');
check( check(
'picking the switched-off object in the hierarchy frames it again', '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 // 10c. In the running game a switched-off object does not exist: it is not rendered, and
// switching it back on brings it back. // switching it back on brings it back.
await play(); await play();
await sleep(200);
const offItems = (await json('__gm.gameInfo()')).visibleItems; const offItems = (await json('__gm.gameInfo()')).visibleItems;
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true`); await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true`);
await sleep(200);
const onItems = (await json('__gm.gameInfo()')).visibleItems; const onItems = (await json('__gm.gameInfo()')).visibleItems;
check( check(
'the game renders one item more once the object is switched on', '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 click(fontLabel.x + 160, fontLabel.y);
await typeText('32.6'); await typeText('32.6');
await press('Enter'); await press('Enter');
await sleep(200);
const fontSize = (await object('otext')).overrides.fontSize; const fontSize = (await object('otext')).overrides.fontSize;
check('typing a fraction into it rounds the stored value', fontSize === 33, String(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'))); 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); scene.objects.push(copy);
} }
__gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`); __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 // 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 // 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'); const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.shader = ''; delete sprite.props.brightness; sprite.props.shader = ''; delete sprite.props.brightness;
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'linear'`); __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'linear'`);
await sleep(400);
await mutate(enlarge(160, 120)); await mutate(enlarge(160, 120));
await sleep(400);
// 12d. The colour multiplies the picture, so a tinted white quadrant stops being white. // 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"); 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 undo();
await sleep(300);
const afterUndo = await json( const afterUndo = await json(
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)", "__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. // Back to one texture asset, the way the later steps expect the fixture.
await undo(); await undo();
await sleep(300);
check( check(
'undo takes the adopted asset back out', 'undo takes the adopted asset back out',
(await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1, (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 entryText = 'Create sprite from texture';
const spriteRow = await json("__gm.assetRow('crate')"); const spriteRow = await json("__gm.assetRow('crate')");
await click(spriteRow.x, spriteRow.y, 'right'); await click(spriteRow.x, spriteRow.y, 'right');
await sleep(200);
check( check(
'a sprite asset does not offer making a sprite from it', 'a sprite asset does not offer making a sprite from it',
(await json(`__gm.locate(${JSON.stringify(entryText)}, false)`)) === null, (await json(`__gm.locate(${JSON.stringify(entryText)}, false)`)) === null,
); );
await press('Escape'); await press('Escape');
await sleep(150);
const spritesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length"); const spritesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length");
const textureRow = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`); const textureRow = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`);
await click(textureRow.x, textureRow.y, 'right'); await click(textureRow.x, textureRow.y, 'right');
await sleep(200);
const createEntry = await json(`__gm.locate(${JSON.stringify(entryText)}, false)`); const createEntry = await json(`__gm.locate(${JSON.stringify(entryText)}, false)`);
check('a texture asset offers making a sprite from it', createEntry !== null); check('a texture asset offers making a sprite from it', createEntry !== null);
if (createEntry) { if (createEntry) {
await click(createEntry.x, createEntry.y); await click(createEntry.x, createEntry.y);
await sleep(400);
const made = await json(`(() => { const made = await json(`(() => {
const s = __gm.state.selection; const s = __gm.state.selection;
const a = s && s.kind === 'asset' ? __gm.state.project.assets.find((x) => x.id === s.assetId) : null; 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) { 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. // 13a. The tab exists and shows the texture the selection points at.
await press('Escape'); 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 ?? ''"); 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 evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(texture)} })`);
await openTab(t, 'texture'); await openTab(t, 'texture');
await sleep(400);
check('the Texture tab opens', (await json('__gm.tab()')) === 'texture'); check('the Texture tab opens', (await json('__gm.tab()')) === 'texture');
check( check(
'and shows the selected sheet', '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. // 13c. It is one undo step, not one per pointer move.
await undo(); await undo();
await sleep(400);
check( check(
'undo removes the frame in one step', 'undo removes the frame in one step',
(await json( (await json(
@@ -92,7 +90,6 @@ export async function frames(t) {
)) === 0, )) === 0,
); );
await t.redo(); await t.redo();
await sleep(400);
// 13d. A sprite wearing the frame shows that part of the sheet and nothing else. // 13d. A sprite wearing the frame shows that part of the sheet and nothing else.
await evaluate(`(() => { await evaluate(`(() => {
@@ -101,7 +98,7 @@ export async function frames(t) {
sprite.props.texture = ${JSON.stringify(frame.id)}; sprite.props.texture = ${JSON.stringify(frame.id)};
__gm.state.preview(); __gm.state.preview();
})()`); })()`);
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(900); await sleep(900);
const box = await json("__gm.objectRect('otex')"); 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.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff;
__gm.state.preview(); __gm.state.preview();
})()`); })()`);
await evaluate("__gm.showTab('texture')"); await showTab('texture');
await sleep(400);
await clickText('Slice…'); await clickText('Slice…');
await sleep(500); await sleep(500);
const dialog = await json('__gm.overlayTexts()'); 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. // 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 // 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. // and the name plates over the Assets panel, because only the picture itself was masked.
await evaluate("__gm.showTab('texture')"); await showTab('texture');
await sleep(400);
const area = await json('__gm.texture.rect()'); 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. // Points on the panel left of the tab and on the tab row above it – both were painted over.
const probes = [ const probes = [
@@ -198,10 +193,9 @@ export async function frames(t) {
JSON.stringify(await json('__gm.textOverflows()')), JSON.stringify(await json('__gm.textOverflows()')),
); );
await clickText('Fit'); await clickText('Fit');
await sleep(400);
// 13g. A sprite pointed at a frame slicing removed falls back to its colour. // 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); await sleep(700);
if (box) { if (box) {
const fallback = await json( 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'; const LONG_NAME = 'Player movement with jumping and coyote time';
export async function overflow(t) { 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 pickerWidths = async () => {
const out = {}; const out = {};
for (const tab of ['graph', 'texture', 'container']) { for (const tab of ['graph', 'texture', 'container']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await showTab(tab);
await sleep(300);
out[tab] = await json(`__gm.${tab}.pickerWidth()`); out[tab] = await json(`__gm.${tab}.pickerWidth()`);
} }
return out; 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 // 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). // 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 showTab('scene');
await sleep(300);
const columnRight = async () => { const columnRight = async () => {
const column = await json('__gm.viewportRect()'); const column = await json('__gm.viewportRect()');
return column.x + column.w; 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 }); await drag(bars.left.x, bars.left.y, bars.left.x + by, bars.left.y, { steps: 8 });
const after = await json('__gm.panelSplitters()'); const after = await json('__gm.panelSplitters()');
await drag(after.right.x, after.right.y, after.right.x - by, after.right.y, { steps: 8 }); await drag(after.right.x, after.right.y, after.right.x - by, after.right.y, { steps: 8 });
await sleep(300);
}; };
await widenPanels(250); await widenPanels(250);
const narrow = await json('__gm.tabRow()'); 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. // The overflow button names them, and picking one brings it into the row.
const wanted = narrow.hidden[narrow.hidden.length - 1]; const wanted = narrow.hidden[narrow.hidden.length - 1];
await clickText('»'); await clickText('»');
await sleep(400);
const offered = await json('__gm.overlayTexts()'); const offered = await json('__gm.overlayTexts()');
check( check(
'the overflow button offers every tab that had to go', '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 }), JSON.stringify({ offered, hidden: narrow.hidden }),
); );
await clickText(TAB_LABELS[wanted]); await clickText(TAB_LABELS[wanted]);
await sleep(400);
const picked = await json('__gm.tabRow()'); const picked = await json('__gm.tabRow()');
check( check(
`picking ${TAB_LABELS[wanted]} from the overflow menu opens it and puts it in the row`, `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) }), JSON.stringify({ tab: await json('__gm.tab()'), shown: picked.shown.map((b) => b.tab) }),
); );
await widenPanels(-250); await widenPanels(-250);
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(300);
const back = await json('__gm.tabRow()'); const back = await json('__gm.tabRow()');
check( check(
'giving the room back brings tabs into the row again, and none past the edge', '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. // 14c. Every tab, because each one owns a different set of controls.
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list', 'particles']) { for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list', 'particles']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await showTab(tab);
await sleep(400);
const spills = await json('__gm.textOverflows()'); const spills = await json('__gm.textOverflows()');
check( check(
`no label is drawn past its control in the ${tab} tab`, `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. // 14d. Shortened, not simply dropped: the value must still be readable and marked as cut.
await evaluate("__gm.showTab('graph')"); await showTab('graph');
await sleep(400);
const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement')); const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement'));
check( check(
'a value too wide for its dropdown is cut with an ellipsis', '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 // 14e. An inspector full of dropdowns – the parent row and the enum fields – with a selection
// whose asset name is now overlong too. // whose asset name is now overlong too.
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(300);
await evaluate("__gm.state.select({ kind: 'object', objectId: 'otex' })"); await evaluate("__gm.state.select({ kind: 'object', objectId: 'otex' })");
await sleep(500); await sleep(500);
const inspector = await json('__gm.textOverflows()'); 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 // 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. // 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']) { for (const tab of ['scene', 'graph', 'texture', 'container', 'list', 'particles']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await showTab(tab);
await sleep(400);
const dropped = await json('__gm.labelsDropped()'); const dropped = await json('__gm.labelsDropped()');
check( check(
`no button is left without its label in the ${tab} tab`, `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. // The narrowest buttons in the editor sit on a behaviour's variable rows.
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'mover' })"); await evaluate("__gm.state.select({ kind: 'asset', assetId: 'mover' })");
await sleep(400);
const onVariables = await json('__gm.labelsDropped()'); const onVariables = await json('__gm.labelsDropped()');
check( check(
'the variable rows keep their buttons labelled', '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. // 15c. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
await clickText('↔ ▾'); await clickText('↔ ▾');
await sleep(400);
await clickText('Fit to 64 × 48'); await clickText('Fit to 64 × 48');
await sleep(500); await sleep(500);
const fitted = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; 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')); const before = await json(spriteProp('width'));
await clickText('↕ ▾'); await clickText('↕ ▾');
await sleep(400);
await clickText('Fit to 40 × 10'); await clickText('Fit to 40 × 10');
await sleep(500); await sleep(500);
const framed = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; 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 select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await sleep(600); await sleep(600);
await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90"); 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'); 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 ↺. // 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); check('and shows it beside the override buttons', (await json('__gm.textOverflows()')).length === 0);
await shot('43-aspect-beside-the-override-buttons'); await shot('43-aspect-beside-the-override-buttons');
await clickText('↔ ▾'); await clickText('↔ ▾');
await sleep(400);
await clickText('Fit to 64 × 48'); await clickText('Fit to 64 × 48');
await sleep(500); await sleep(500);
const override = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides"); 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, object,
objects, objects,
storedObjects, storedObjects,
showTab,
} = t; } = t;
await press('Escape'); 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. // 16a. The tab opens on the container the project has, and its header says which one.
await openTab(t, 'container'); await openTab(t, 'container');
await sleep(300);
const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`); const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
const count = template.length; const count = template.length;
check( 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. // 16f. Delete works on a template object like on any other, as one undo step.
await press('Delete'); await press('Delete');
await sleep(250);
check( check(
'Delete removes a template object', 'Delete removes a template object',
(await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length === count, (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, count + 1,
); );
await press('Delete'); await press('Delete');
await sleep(250);
// 16g. Fit frames the content. Proven against an object parked far outside the view: without // 16g. Fit frames the content. Proven against an object parked far outside the view: without
// fitting it is nowhere near the canvas. // fitting it is nowhere near the canvas.
await evaluate( await mutate(
`(() => { 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(); })()`, `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 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 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)})`); const far = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
await clickText('Fit'); await clickText('Fit');
await sleep(300);
const fitted = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`); const fitted = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
check( check(
'Fit brings an object parked far outside back into view', '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(); })()`, `(() => { 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 clickText('Fit');
await sleep(300);
await shot('32-container-fitted'); await shot('32-container-fitted');
// 16h. A template object exists only in this tab, so selecting one from elsewhere switches to // 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. // it – the rule a graph node and a texture frame already follow. A scene object does not.
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(300); await select({ kind: 'object', holderId: containerId, objectId: root.id });
await evaluate(
`__gm.state.select({ kind: 'object', holderId: ${JSON.stringify(containerId)}, objectId: ${JSON.stringify(root.id)} })`,
);
await sleep(300);
check( check(
'selecting a template object switches to the Container tab', 'selecting a template object switches to the Container tab',
(await json('__gm.tab()')) === 'container', (await json('__gm.tab()')) === 'container',
); );
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(300);
await evaluate("__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'otex' })"); 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('selecting a scene object leaves the Scene tab alone', (await json('__gm.tab()')) === 'scene');
check( check(
'the hierarchy is back on the scene with it', '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 // 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. // picker does. The hierarchy has to follow it, title and rows both.
await evaluate("__gm.showTab('container')"); await showTab('container');
await sleep(300);
await mutate(`const src = __gm.state.project.assets.find(a => a.id === '${containerId}'); await mutate(`const src = __gm.state.project.assets.find(a => a.id === '${containerId}');
const copy = JSON.parse(JSON.stringify(src)); const copy = JSON.parse(JSON.stringify(src));
copy.id = 'c2'; copy.name = 'Spare box'; copy.id = 'c2'; copy.name = 'Spare box';
copy.objects = copy.objects.slice(0, 1).map((o) => ({ ...o, id: 'c2o1', parentId: '' })); copy.objects = copy.objects.slice(0, 1).map((o) => ({ ...o, id: 'c2o1', parentId: '' }));
__gm.state.project.assets.push(copy)`); __gm.state.project.assets.push(copy)`);
await select({ kind: 'asset', assetId: 'c2' }); await select({ kind: 'asset', assetId: 'c2' });
await sleep(400);
check( check(
'picking another container moves the tab and the hierarchy to it', 'picking another container moves the tab and the hierarchy to it',
(await json('__gm.container.current()')) === 'c2' && (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>`. // object, and the inner container's content appears under `<instance>~<outer>/<inner>`.
const innerObject = nestedAfter.find((o) => o.assetId === containerId); const innerObject = nestedAfter.find((o) => o.assetId === containerId);
const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`); const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(300);
const storedBefore = (await storedObjects()).length; const storedBefore = (await storedObjects()).length;
const listedBefore = (await objects()).length; const listedBefore = (await objects()).length;
const spot = await json('__gm.viewportCenter()'); const spot = await json('__gm.viewportCenter()');
@@ -331,5 +317,4 @@ export async function containerTab(t) {
await shot('34-container-nested'); await shot('34-container-nested');
await undo(); // the placement await undo(); // the placement
await undo(); // the nesting await undo(); // the nesting
await sleep(200);
} }
+17 -11
View File
@@ -43,7 +43,21 @@ const driftGraph = {
}; };
export async function editorRun(t) { 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 // 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. // test is the switch, not another way of building a graph.
@@ -62,7 +76,6 @@ export async function editorRun(t) {
${MAKE_OBJECT} ${MAKE_OBJECT}
scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter')); 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 boxX = async () => (await object('er-box')).position.x;
const startX = await boxX(); const startX = await boxX();
@@ -93,7 +106,6 @@ export async function editorRun(t) {
JSON.stringify({ enabledBox, runBox, variables }), JSON.stringify({ enabledBox, runBox, variables }),
); );
await click(runBox.x, runBox.y); await click(runBox.x, runBox.y);
await sleep(300);
const logic = await object('er-logic'); const logic = await object('er-logic');
check( check(
@@ -144,7 +156,6 @@ export async function editorRun(t) {
// 17e. Taking the switch back stops it again. // 17e. Taking the switch back stops it again.
await click(runBox.x, runBox.y); await click(runBox.x, runBox.y);
await sleep(300);
const stoppedAt = await boxX(); const stoppedAt = await boxX();
await sleep(500); await sleep(500);
const stopped = await json('__gm.editorRun()'); 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. // undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
await undo(); await undo();
await undo(); await undo();
await sleep(300);
const afterUndo = await object('er-box'); const afterUndo = await object('er-box');
check( check(
'undo takes back the switch, never the movement it caused', '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()}`, `${JSON.stringify(onScene)} x=${sceneBefore} → ${await walkX()}`,
); );
await evaluate(`__gm.showTab('game')`); await showTab('game');
await sleep(300);
// Clicking the view first: a checkbox left focused by an earlier check would still want the keys. // Clicking the view first: a checkbox left focused by an earlier check would still want the keys.
const gameCentre = await json('__gm.gameCenter()'); const gameCentre = await json('__gm.gameCenter()');
await click(gameCentre.x, gameCentre.y); await click(gameCentre.x, gameCentre.y);
await sleep(200);
const onGame = await json('__gm.editorRun()'); const onGame = await json('__gm.editorRun()');
const gameBefore = await walkX(); const gameBefore = await walkX();
await holdKey('ArrowRight', 500); await holdKey('ArrowRight', 500);
@@ -268,8 +276,7 @@ export async function editorRun(t) {
); );
await shot('37-editor-run-keys'); await shot('37-editor-run-keys');
await evaluate(`__gm.showTab('scene')`); await showTab('scene');
await sleep(300);
const backOnScene = await walkX(); const backOnScene = await walkX();
await holdKey('ArrowRight', 400); await holdKey('ArrowRight', 400);
await sleep(200); await sleep(200);
@@ -285,7 +292,6 @@ export async function editorRun(t) {
scene.objects = scene.objects.filter((o) => !o.id.startsWith('er-')); scene.objects = scene.objects.filter((o) => !o.id.startsWith('er-'));
p.assets = p.assets.filter((a) => !['drifter', 'walker', 'er-level'].includes(a.id)); p.assets = p.assets.filter((a) => !['drifter', 'walker', 'er-level'].includes(a.id));
`); `);
await sleep(300);
check( check(
'the step leaves nothing of its own behind', 'the step leaves nothing of its own behind',
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0 && (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, select,
evaluate, evaluate,
asset, asset,
showTab,
} = t; } = t;
const playInfo = () => json('__gm.playInfo()'); const playInfo = () => json('__gm.playInfo()');
const boxX = async () => (await entity('p-box'))?.x ?? NaN; 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 // 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. // ticking while the picture happens to look the same would pass a position check alone.
await clickText('❚❚ Pause'); await clickText('❚❚ Pause');
await sleep(200);
const held = await playInfo(); const held = await playInfo();
const heldX = await boxX(); const heldX = await boxX();
await sleep(1000); await sleep(1000);
@@ -108,11 +108,9 @@ export async function pause(t) {
const hitsWhilePaused = await keyHits(); const hitsWhilePaused = await keyHits();
await press('ArrowRight'); await press('ArrowRight');
await press('ArrowRight'); await press('ArrowRight');
await sleep(200);
// 18d. Ctrl+Enter resumes, the same shortcut that started play. // 18d. Ctrl+Enter resumes, the same shortcut that started play.
await press('Enter', { ctrl: true }); await press('Enter', { ctrl: true });
await sleep(400);
const resumed = await playInfo(); const resumed = await playInfo();
const resumedX = await boxX(); const resumedX = await boxX();
check( 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. // Without this the check above would also pass on a graph that never logs anything at all.
await press('ArrowRight'); await press('ArrowRight');
await sleep(300);
check( check(
'the same key does fire once the game is running again', 'the same key does fire once the game is running again',
(await keyHits()) === hitsBefore + 1, (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 // 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. // moving. A pause left standing would leave the editor unable to run anything.
await press('Enter', { ctrl: true }); await press('Enter', { ctrl: true });
await sleep(200);
check('Ctrl+Enter holds it again', (await playInfo()).paused === true); check('Ctrl+Enter holds it again', (await playInfo()).paused === true);
await stop(); await stop();
const afterStop = await playInfo(); 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 // 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. // selects the Move node, Delete removes it, and the running world stops drifting the box.
await select({ kind: 'asset', assetId: 'pauser' }); 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); await sleep(150);
const outputs = (id) => json(`__gm.graph.nodeOutputs(${JSON.stringify(id)})`); const outputs = (id) => json(`__gm.graph.nodeOutputs(${JSON.stringify(id)})`);
const live = { const live = {
@@ -193,7 +191,6 @@ export async function pause(t) {
); );
const beforeKey = await beadsOn('p-e4'); const beforeKey = await beadsOn('p-e4');
await press('ArrowRight'); await press('ArrowRight');
await sleep(80);
const onKey = await beadsOn('p-e4'); const onKey = await beadsOn('p-e4');
await sleep(500); await sleep(500);
const afterKey = await beadsOn('p-e4'); const afterKey = await beadsOn('p-e4');
@@ -204,7 +201,6 @@ export async function pause(t) {
); );
const movePos = await json(`__gm.graph.nodeAt('p-move')`); const movePos = await json(`__gm.graph.nodeAt('p-move')`);
await click(movePos.x, movePos.y); await click(movePos.x, movePos.y);
await sleep(100);
check( check(
'a click selects a node while playing', 'a click selects a node while playing',
JSON.stringify(await json('__gm.graph.selected()')) === '["p-move"]', 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()'); const area = await json('__gm.graph.canvasRect()');
await click(area.x + area.w - 15, area.y + area.h - 15, 'right'); 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…')); check('right-clicking the canvas opens the node palette while playing', await has('Search nodes…'));
await press('Escape'); await press('Escape');
await sleep(100);
check( check(
'Escape closes the palette and leaves the game running', 'Escape closes the palette and leaves the game running',
!(await has('Search nodes…')) && (await playInfo()).playing === true, !(await has('Search nodes…')) && (await playInfo()).playing === true,
); );
await click(movePos.x, movePos.y); await click(movePos.x, movePos.y);
await press('Delete'); await press('Delete');
await sleep(150);
const stoppedAt = await boxX(); const stoppedAt = await boxX();
await sleep(500); await sleep(500);
check( check(
@@ -232,7 +225,6 @@ export async function pause(t) {
`x ${stoppedAt} → ${await boxX()}`, `x ${stoppedAt} → ${await boxX()}`,
); );
await stop(); await stop();
await sleep(200);
check( check(
'stopping takes the values off the nodes', 'stopping takes the values off the nodes',
JSON.stringify(await outputs('p-owner')) === '["object"]', JSON.stringify(await outputs('p-owner')) === '["object"]',
@@ -267,7 +259,6 @@ export async function pause(t) {
await sleep(400); await sleep(400);
const runningClock = await clock(); const runningClock = await clock();
await clickText('❚❚ Pause'); await clickText('❚❚ Pause');
await sleep(150);
const heldClock = await clock(); const heldClock = await clock();
await sleep(800); await sleep(800);
const stillHeldClock = await clock(); 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 // 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. // reads is the step the clock took, not a second measurement of the wall clock.
await press('Enter', { ctrl: true }); await press('Enter', { ctrl: true });
await sleep(150);
const slowFrom = { t: await clock(), x: await boxX() }; const slowFrom = { t: await clock(), x: await boxX() };
await sleep(600); await sleep(600);
const slowTo = { t: await clock(), x: await boxX() }; const slowTo = { t: await clock(), x: await boxX() };
+3 -33
View File
@@ -87,7 +87,6 @@ export async function nestedBehavior(t) {
${MAKE_OBJECT} ${MAKE_OBJECT}
scene.objects.push(make('nb-box', 'crate', '', 700, 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 700, 'Caller')); 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 // 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 // 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 boxX = async () => (await object('nb-box')).position.x;
const idleX = await boxX(); const idleX = await boxX();
await t.play(); await t.play();
await sleep(300);
const before = (await entity('nb-box')).x; const before = (await entity('nb-box')).x;
await sleep(1000); await sleep(1000);
const after = (await entity('nb-box')).x; 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`, `moved ${speed.toFixed(1)} px in ~1 s – 2 × ${SCALE} means the returned value arrived, 0 would mean nothing came back`,
); );
await stop(); await stop();
await sleep(300);
check( check(
'the stored object is untouched by the run, as in any play session', 'the stored object is untouched by the run, as in any play session',
(await boxX()) === idleX, (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 // 19c. The node in the graph carries the *other* behavior's ports and name – neither is part of
// its own type. // its own type.
await evaluate( await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] });
`__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`,
);
await sleep(300);
const inPort = await portAt('nb-call', 'in1', false); const inPort = await portAt('nb-call', 'in1', false);
const outPort = await portAt('nb-call', 'out1', true); const outPort = await portAt('nb-call', 'out1', true);
check( 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 // 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 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))); check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false)));
await evaluate( await t.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] });
`__gm.state.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] })`,
);
await sleep(300);
const addButton = await locate('+ Input'); const addButton = await locate('+ Input');
check('the start node offers its inputs in the inspector', Boolean(addButton), JSON.stringify(addButton)); check('the start node offers its inputs in the inspector', Boolean(addButton), JSON.stringify(addButton));
await shot('38-behavior-ports'); await shot('38-behavior-ports');
await click(addButton.x, addButton.y); await click(addButton.x, addButton.y);
await sleep(200);
await clickText('Number'); await clickText('Number');
await sleep(300);
const declared = await startPorts(); const declared = await startPorts();
check( check(
'adding an input declares it on the start node', 'adding an input declares it on the start node',
declared.length === 2 && declared[1].key === 'in2' && declared[1].kind === 'number', declared.length === 2 && declared[1].key === 'in2' && declared[1].kind === 'number',
JSON.stringify(declared), JSON.stringify(declared),
); );
await evaluate( await t.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] });
`__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`,
);
await sleep(300);
check( check(
'and the node in the other graph grew that input without being touched', 'and the node in the other graph grew that input without being touched',
Boolean(await portAt('nb-call', 'in2', false)), 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. // 19e. Undo takes the port back, and with it the port on the calling node.
await undo(); await undo();
await sleep(300);
const afterUndo = await startPorts(); const afterUndo = await startPorts();
check( check(
'undo removes it in both graphs', 'undo removes it in both graphs',
@@ -180,14 +165,11 @@ export async function nestedBehavior(t) {
const idsBefore = await nodeIds(); const idsBefore = await nodeIds();
const bare = await json('__gm.graph.emptySpot()'); const bare = await json('__gm.graph.emptySpot()');
await click(bare.x, bare.y, 'right'); await click(bare.x, bare.y, 'right');
await sleep(150);
check('node palette opens', Boolean(await locate('Search nodes…'))); check('node palette opens', Boolean(await locate('Search nodes…')));
await typeText('Double'); await typeText('Double');
await sleep(200);
// Enter takes the first match. NOT a click on the label: "Double" also names the asset in the // 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. // list on the left, and that is what a click by text would find first.
await press('Enter'); await press('Enter');
await sleep(300);
const added = (await asset('nb-caller')).graph.nodes.find((n) => !idsBefore.includes(n.id)); const added = (await asset('nb-caller')).graph.nodes.find((n) => !idsBefore.includes(n.id));
check( check(
'picking a behavior in the palette drops in a node already set to it', '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 }), JSON.stringify(added && { type: added.type, params: added.params }),
); );
await undo(); await undo();
await sleep(200);
// 19g. Dragging a behavior out of the asset panel drops a call into the graph, where the // 19g. Dragging a behavior out of the asset panel drops a call into the graph, where the
// pointer let go of it. // pointer let go of it.
@@ -213,7 +194,6 @@ export async function nestedBehavior(t) {
}); });
const dropAt = spot(0.25, 0.75); const dropAt = spot(0.25, 0.75);
await t.dragAsset('nb-double', dropAt.x, dropAt.y); await t.dragAsset('nb-double', dropAt.x, dropAt.y);
await sleep(300);
const dropped = await newNodeIn(beforeDrag); const dropped = await newNodeIn(beforeDrag);
check( check(
'a behavior dragged from the asset panel becomes a call node', '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 beforeEmpty = await nodeIds();
const emptyRow = await json(`__gm.assetRow('nb-empty')`); 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 t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y);
await sleep(300);
const emptyCall = await newNodeIn(beforeEmpty); const emptyCall = await newNodeIn(beforeEmpty);
check( check(
'a behavior without a start node can be added just the same', '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 beforeSelf = await nodeIds();
const selfRow = await json(`__gm.assetRow('nb-caller')`); 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 t.dragAsset('nb-caller', spot(0.45, 0.5).x, spot(0.45, 0.5).y);
await sleep(300);
const afterSelf = await nodeIds(); const afterSelf = await nodeIds();
const { notice } = await t.state(); const { notice } = await t.state();
// `dragging` tells a refusal apart from a drag that never arrived – both leave the graph alone. // `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('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); 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 t.doubleClick(callNode.x, callNode.y);
await sleep(400);
const opened = await evaluate('__gm.graph.current()'); const opened = await evaluate('__gm.graph.current()');
check( check(
'double-clicking a call node opens the behavior it runs', 'double-clicking a call node opens the behavior it runs',
@@ -307,7 +284,6 @@ export async function nestedBehavior(t) {
await trail(), await trail(),
); );
await t.doubleClick(inner.x, inner.y); await t.doubleClick(inner.x, inner.y);
await sleep(400);
check( check(
'each call opened further down adds a crumb', 'each call opened further down adds a crumb',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await trail()) === 'Caller › Double › Empty*', (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 clickCrumb = async (name) => {
const crumb = (await crumbs()).find((c) => c.name === name); const crumb = (await crumbs()).find((c) => c.name === name);
await click(crumb.x, crumb.y); await click(crumb.x, crumb.y);
await sleep(300);
}; };
await clickCrumb('Double'); await clickCrumb('Double');
await shot('39c-breadcrumb-back'); await shot('39c-breadcrumb-back');
@@ -350,12 +325,10 @@ export async function nestedBehavior(t) {
const enterDouble = async () => { const enterDouble = async () => {
const call = await json(`__gm.graph.nodeAt('nb-call')`); const call = await json(`__gm.graph.nodeAt('nb-call')`);
await t.doubleClick(call.x, call.y); await t.doubleClick(call.x, call.y);
await sleep(400);
}; };
await enterDouble(); await enterDouble();
const emptyAgain = await json(`__gm.assetRow('nb-empty')`); const emptyAgain = await json(`__gm.assetRow('nb-empty')`);
await click(emptyAgain.x, emptyAgain.y); await click(emptyAgain.x, emptyAgain.y);
await sleep(300);
check( check(
'choosing another behavior in the asset panel ends the trail', 'choosing another behavior in the asset panel ends the trail',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0, (await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
@@ -366,10 +339,8 @@ export async function nestedBehavior(t) {
await enterDouble(); await enterDouble();
const picker = await json('__gm.graph.pickerAt()'); const picker = await json('__gm.graph.pickerAt()');
await click(picker.x, picker.y); await click(picker.x, picker.y);
await sleep(150);
const emptyItem = await locate('Empty', true); const emptyItem = await locate('Empty', true);
await click(emptyItem.x, emptyItem.y); await click(emptyItem.x, emptyItem.y);
await sleep(300);
check( check(
'choosing another behavior in the picker ends the trail', 'choosing another behavior in the picker ends the trail',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0, (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)); p.assets = p.assets.filter((a) => !mine.includes(a.id));
scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic'); scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic');
`); `);
await sleep(300);
const left = await json(`{ const left = await json(`{
objects: __gm.sceneObjects().filter((o) => o.id.startsWith('nb-')).length, objects: __gm.sceneObjects().filter((o) => o.id.startsWith('nb-')).length,
assets: __gm.state.project.assets.filter((a) => a.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 t.play();
await sleep(300);
// 20b. A hold shorter than the delay is still just a press. Without this the check below would // 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. // 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)`, `plain +${afterLong.plain - afterShort.plain}, repeating +${gained} over 800 ms (press + ~${Math.round((800 - DELAY) / INTERVAL)} repeats expected)`,
); );
await stop(); await stop();
await sleep(300);
// 20d. Both settings are on the node itself, so a graph can be tuned where it is built. // 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 select({ kind: 'node', assetId: 'repeater', nodeId: 'r-repeat' });
await sleep(300);
const afterField = await json(`__gm.inspectorField('Repeat after (ms)')`); const afterField = await json(`__gm.inspectorField('Repeat after (ms)')`);
const intervalField = await json(`__gm.inspectorField('Repeat interval (ms)')`); const intervalField = await json(`__gm.inspectorField('Repeat interval (ms)')`);
check( check(
@@ -105,7 +102,6 @@ export async function keyRepeat(t) {
await click(afterField.x, afterField.y); await click(afterField.x, afterField.y);
await typeText('750'); await typeText('750');
await click(intervalField.x, intervalField.y); await click(intervalField.x, intervalField.y);
await sleep(200);
const params = await json( const params = await json(
`__gm.state.project.assets.find((a) => a.id === 'repeater').graph.nodes.find((n) => n.id === 'r-repeat').params`, `__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 // 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. // repeating node falls silent although nothing else about the graph changed.
await t.play(); await t.play();
await sleep(300);
const beforeTuned = await hits(); const beforeTuned = await hits();
await holdKey('ArrowRight', 500); await holdKey('ArrowRight', 500);
await sleep(200); 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`, `repeating ${beforeTuned.repeating} → ${afterTuned.repeating} over 500 ms with the delay raised to 750 ms`,
); );
await stop(); await stop();
await sleep(300);
const left = await rig.remove(); const left = await rig.remove();
check( check(
-33
View File
@@ -31,7 +31,6 @@ export async function listTab(t) {
}); });
__gm.state.select({ kind: 'asset', assetId: 'listy' }); __gm.state.select({ kind: 'asset', assetId: 'listy' });
`); `);
await sleep(150);
// 21a. The inspector shows the count, not a comma-separated line. // 21a. The inspector shows the count, not a comma-separated line.
const button = await find('3 items'); const button = await find('3 items');
@@ -40,7 +39,6 @@ export async function listTab(t) {
await shot('01-count-button'); await shot('01-count-button');
await click(button.x, button.y); await click(button.x, button.y);
await sleep(200);
check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list'); check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list');
let info = await json('__gm.list.info()'); let info = await json('__gm.list.info()');
check('which shows the list it was opened from', info.title === 'Listy · nums', JSON.stringify(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 click(second.x, second.y);
await typeText('42'); await typeText('42');
await press('Enter'); await press('Enter');
await sleep(200);
info = await json('__gm.list.info()'); info = await json('__gm.list.info()');
check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]); check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]);
await undo(); await undo();
@@ -67,7 +64,6 @@ export async function listTab(t) {
a.props.nums = Array.from({ length: 60 }, (_, i) => i); a.props.nums = Array.from({ length: 60 }, (_, i) => i);
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(250);
const longRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')]; 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)); check('a long list stacks its rows', longRows[0].y !== longRows[1].y, JSON.stringify(longRows));
await mutate(` await mutate(`
@@ -75,7 +71,6 @@ export async function listTab(t) {
a.props.nums = [30, 11, 44]; a.props.nums = [30, 11, 44];
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(250);
const shortRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')]; const shortRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')];
check( check(
'and a short list after it still stacks its own', '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. // 21d. `+ Item` asks for a kind, since the list does not hold one for it.
await clickText('+ Item'); await clickText('+ Item');
await sleep(150);
check( check(
'a list of its own offers every kind, the struct included', 'a list of its own offers every kind, the struct included',
(await json('__gm.list.info()')).offers.join(',') === (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. // 21e. Back on the scene tab the button carries the current count.
await mutate('__gm.state.openList(null)'); await mutate('__gm.state.openList(null)');
await sleep(200);
check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene'); check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene');
await mutate(` await mutate(`
__gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2]; __gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2];
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(200);
check('and the count follows the value', (await find('2 items')) !== null); 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 // 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. // 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')`); const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })"); await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
await sleep(200);
let mark = await json('__gm.list.markAt(0)'); let mark = await json('__gm.list.markAt(0)');
await click(mark.x, mark.y); await click(mark.x, mark.y);
await sleep(150);
await pickKind('Text'); await pickKind('Text');
await sleep(250); await sleep(250);
let listy = await numsOf(); 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); check('the variable itself says nothing about items', listy.variables[0].items === undefined);
await undo(); await undo();
await sleep(250);
listy = await numsOf(); listy = await numsOf();
check( check(
'undo puts the item back as a number', '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. // 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)'); mark = await json('__gm.list.markAt(1)');
await click(mark.x, mark.y); await click(mark.x, mark.y);
await sleep(150);
await pickKind('Color'); await pickKind('Color');
await sleep(250); await sleep(250);
listy = await numsOf(); listy = await numsOf();
@@ -168,7 +156,6 @@ export async function listTab(t) {
); );
await shot('03b-kinds'); await shot('03b-kinds');
await undo(); 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. // 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. // 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(); __gm.state.preview();
`); `);
await sleep(200);
mark = await json('__gm.list.markAt(0)'); mark = await json('__gm.list.markAt(0)');
await click(mark.x, mark.y); await click(mark.x, mark.y);
await sleep(150);
await pickKind('Texture'); await pickKind('Texture');
await sleep(250); await sleep(250);
// Switching kind carries the value along, here into an id that resolves to nothing – the same as // 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)'); const pic = await json('__gm.list.fieldAt(0)');
await click(pic.x, pic.y); 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); check('the row carries the picture picker, sheet frames and all', (await find('Sheet / Frame 1')) !== null);
await pickKind('Sheet / Frame 1'); await pickKind('Sheet / Frame 1');
await sleep(250); await sleep(250);
@@ -218,7 +202,6 @@ export async function listTab(t) {
await shot('03c-texture'); await shot('03c-texture');
await undo(); await undo();
await undo(); await undo();
await sleep(250);
check( check(
'undo puts the plain number back', 'undo puts the plain number back',
JSON.stringify((await numsOf()).props.nums) === '[1,2]', JSON.stringify((await numsOf()).props.nums) === '[1,2]',
@@ -230,10 +213,8 @@ export async function listTab(t) {
delete __gm.__pictures; delete __gm.__pictures;
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(200);
await mutate('__gm.state.openList(null)'); 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. // The mark of a list is plain now, and still may not push the value off the inspector's grid.
const lefts = await json( const lefts = await json(
`[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`, `[__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. // 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 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'); check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list');
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
@@ -254,7 +234,6 @@ export async function listTab(t) {
__gm.state.select(null); __gm.state.select(null);
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(200);
info = await json('__gm.list.info()'); info = await json('__gm.list.info()');
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title); check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title);
await shot('04-empty'); await shot('04-empty');
@@ -274,7 +253,6 @@ export async function listTab(t) {
__gm.state.select({ kind: 'asset', assetId: 'celly' }); __gm.state.select({ kind: 'asset', assetId: 'celly' });
__gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' }); __gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' });
`); `);
await sleep(250);
info = await json('__gm.list.info()'); 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('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info));
check( check(
@@ -296,7 +274,6 @@ export async function listTab(t) {
await click(firstField.x, firstField.y); await click(firstField.x, firstField.y);
await typeText('77'); await typeText('77');
await press('Enter'); await press('Enter');
await sleep(250);
const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`); const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check( check(
'editing one field of one item leaves every other value untouched', 'editing one field of one item leaves every other value untouched',
@@ -308,7 +285,6 @@ export async function listTab(t) {
JSON.stringify(cells), JSON.stringify(cells),
); );
await undo(); await undo();
await sleep(200);
const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`); const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check( check(
'and undo takes back just that field', '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`); 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); check('a struct item carries the add-a-field control', (await find('+ Field')) !== null);
await clickText('+ Field'); await clickText('+ Field');
await sleep(150);
await pickKind('Boolean'); await pickKind('Boolean');
await sleep(250); await sleep(250);
check( check(
@@ -356,7 +331,6 @@ export async function listTab(t) {
await press('a', { ctrl: true }); await press('a', { ctrl: true });
await typeText('row'); await typeText('row');
await press('Enter'); await press('Enter');
await sleep(250);
check( check(
'renaming a field of one item leaves the other item alone', 'renaming a field of one item leaves the other item alone',
JSON.stringify(await cellsOf()) === JSON.stringify(await cellsOf()) ===
@@ -368,7 +342,6 @@ export async function listTab(t) {
); );
const fieldMark = await json("__gm.list.markAt(0, 'x')"); const fieldMark = await json("__gm.list.markAt(0, 'x')");
await click(fieldMark.x, fieldMark.y); await click(fieldMark.x, fieldMark.y);
await sleep(150);
await pickKind('Color'); await pickKind('Color');
await sleep(250); await sleep(250);
check( check(
@@ -379,7 +352,6 @@ export async function listTab(t) {
await undo(); await undo();
await undo(); await undo();
await undo(); await undo();
await sleep(250);
check( check(
'and three undos take the shape back to what it was', 'and three undos take the shape back to what it was',
JSON.stringify(await cellsOf()) === 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.select({ kind: 'node', assetId: 'celly', nodeId: 'counter', nodeIds: ['counter'] });
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(300);
const portButton = await find('0 items'); const portButton = await find('0 items');
check('an unconnected list input reads as an item count', portButton !== null); check('an unconnected list input reads as an item count', portButton !== null);
await click(portButton.x, portButton.y); await click(portButton.x, portButton.y);
await sleep(250);
check('clicking it opens the List tab on the port', (await json('__gm.tab()')) === 'list'); check('clicking it opens the List tab on the port', (await json('__gm.tab()')) === 'list');
info = await json('__gm.list.info()'); info = await json('__gm.list.info()');
check('titled by the node and the port it belongs to', info.title === 'Count · list', info.title); check('titled by the node and the port it belongs to', info.title === 'Count · list', info.title);
await clickText('+ Item'); await clickText('+ Item');
await sleep(150);
await pickKind('Number'); await pickKind('Number');
await sleep(250); await sleep(250);
const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`); 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.showTab('scene');
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(200);
await mutate('__gm.state.openList(null)'); await mutate('__gm.state.openList(null)');
await sleep(200);
const left = await json( const left = await json(
'__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length', '__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' }); __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. // 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); 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 click(xField.x, xField.y);
await typeText('42'); await typeText('42');
await press('Enter'); await press('Enter');
await sleep(200);
check('typing in a field writes that field', (await piece()).x === 42, JSON.stringify(await piece())); check('typing in a field writes that field', (await piece()).x === 42, JSON.stringify(await piece()));
const tagField = await json("__gm.inspectorField('tag')"); const tagField = await json("__gm.inspectorField('tag')");
@@ -70,7 +68,6 @@ export async function structVariable(t) {
await press('a', { ctrl: true }); await press('a', { ctrl: true });
await typeText('ell'); await typeText('ell');
await press('Enter'); await press('Enter');
await sleep(200);
check( check(
'and the next field edited keeps the one before it', 'and the next field edited keeps the one before it',
JSON.stringify(await piece()) === '{"x":42,"tag":"ell"}', 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. // 22d. Adding a field reaches the declaration and the value at once.
await clickText('+ Field'); await clickText('+ Field');
await sleep(150);
await pickKind('Boolean'); await pickKind('Boolean');
await sleep(250); await sleep(250);
const grown = await piece(); 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); check('and the new field has a row', (await find('field1')) !== null);
await shot('02-field-added'); await shot('02-field-added');
await undo(); await undo();
await sleep(200);
check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece())); 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. // 22e. Renaming a field carries its value along.
@@ -102,7 +97,6 @@ export async function structVariable(t) {
await press('a', { ctrl: true }); await press('a', { ctrl: true });
await typeText('column'); await typeText('column');
await press('Enter'); await press('Enter');
await sleep(250);
check( check(
'renaming a field keeps its value', 'renaming a field keeps its value',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}', 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, // 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. // and it opens the List tab one level down – on the field, not on the whole bundle.
await clickText('+ Field'); 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. // "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)`); const listOption = await json(`__gm.locate('List', true)`);
await click(listOption.x, listOption.y); await click(listOption.x, listOption.y);
await sleep(250);
check( check(
'+ Field ▸ List gives the bundle an empty list', '+ Field ▸ List gives the bundle an empty list',
Array.isArray((await piece()).field1), 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); check('and that field reads as an item count', itemsButton !== null);
await click(itemsButton.x, itemsButton.y); await click(itemsButton.x, itemsButton.y);
await sleep(250);
check('clicking it opens the List tab', (await json('__gm.tab()')) === 'list'); check('clicking it opens the List tab', (await json('__gm.tab()')) === 'list');
const info = await json('__gm.list.info()'); const info = await json('__gm.list.info()');
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title); 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 // 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. // would be dropped by the next write. It is left out of the menu rather than doing nothing.
await clickText('+ Item'); await clickText('+ Item');
await sleep(150);
check( check(
'the menu of a list in a struct field leaves the struct kind out', 'the menu of a list in a struct field leaves the struct kind out',
(await find('Number')) !== null && (await find('Struct')) === null, (await find('Number')) !== null && (await find('Struct')) === null,
@@ -150,7 +140,6 @@ export async function structVariable(t) {
await shot('03-list-field'); await shot('03-list-field');
await undo(); await undo();
await undo(); await undo();
await sleep(200);
check( check(
'undo takes the list field back out of the bundle', 'undo takes the list field back out of the bundle',
!('field1' in (await piece())), !('field1' in (await piece())),
@@ -158,7 +147,6 @@ export async function structVariable(t) {
); );
await mutate('__gm.state.openList(null)'); 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 // 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 // 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 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); 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 // 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 press('a', { ctrl: true });
await typeText('7'); await typeText('7');
await press('Enter'); await press('Enter');
await sleep(250);
check( check(
'the first override of one field keeps the asset values of the others', 'the first override of one field keeps the asset values of the others',
JSON.stringify(await placed()) === '{"column":7,"tag":"tee"}', JSON.stringify(await placed()) === '{"column":7,"tag":"tee"}',
@@ -245,7 +231,6 @@ export async function structVariable(t) {
await shot('04-object-override'); await shot('04-object-override');
await undo(); await undo();
await sleep(200);
check( check(
'and undo takes the override away again, not just the one field', '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, (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.select(null);
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(200);
const left = await json( const left = await json(
'__gm.state.project.assets.filter((a) => a.id === "structy").length +' + '__gm.state.project.assets.filter((a) => a.id === "structy").length +' +
' __gm.state.activeScene.objects.filter((o) => o.id === "ostruct").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) { export async function variableNodes(t) {
const { const { check, sleep, json, shot, click, locate, undo, entity, asset, mutate, stop, typeText, press, has } =
check, t;
sleep,
evaluate,
json,
shot,
click,
locate,
undo,
entity,
asset,
mutate,
stop,
typeText,
press,
has,
} = t;
const portAt = (nodeId, key, isOutput) => const portAt = (nodeId, key, isOutput) =>
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const portsOf = async (nodeId) => const portsOf = async (nodeId) =>
(await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []; (await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? [];
const select = async (nodeId) => { const select = async (nodeId) => {
await evaluate( await t.select({ kind: 'node', assetId: 'vp-logic', nodeId, nodeIds: [nodeId] });
`__gm.state.select({ kind: 'node', assetId: 'vp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
);
await sleep(300);
}; };
/** The inspector is drawn over the graph, so the last match is the row rather than the port. */ /** The inspector is drawn over the graph, so the last match is the row rather than the port. */
const clickLast = async (text) => { const clickLast = async (text) => {
const p = await locate(text, true); const p = await locate(text, true);
await click(p.x, p.y); 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. */ /** The Set checkbox of the newest variable row: the control right of the last "Set" label. */
const clickLastSet = async () => { const clickLastSet = async () => {
const box = await json(`__gm.inspectorField('Set', true)`); const box = await json(`__gm.inspectorField('Set', true)`);
await click(box.x, box.y); await click(box.x, box.y);
await sleep(300);
return box; return box;
}; };
@@ -93,12 +73,10 @@ export async function variableNodes(t) {
${MAKE_OBJECT} ${MAKE_OBJECT}
scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds')); 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 // 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. // speeds stay apart – 2 to the right for every 3 down.
await t.play(); await t.play();
await sleep(300);
const before = await entity('vp-box'); const before = await entity('vp-box');
await sleep(1000); await sleep(1000);
const after = await entity('vp-box'); 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`, `moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`,
); );
await stop(); await stop();
await sleep(300);
// 23c. Every variable is an output; a written one is an input too, and only a writing node has // 23c. Every variable is an output; a written one is an input too, and only a writing node has
// flow pins. // 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)); check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add));
await shot('01-variable-ports'); await shot('01-variable-ports');
await click(add.x, add.y); await click(add.x, add.y);
await sleep(200);
await clickLast('vz'); await clickLast('vz');
await sleep(300);
const grown = await portsOf('vp-set'); const grown = await portsOf('vp-set');
check( check(
'picking one adds it as a read port, typed from the variable', '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 shot('02-variable-set');
await undo(); await undo();
await sleep(300);
await undo(); await undo();
await sleep(300);
check('undo takes both back', (await portsOf('vp-set')).length === 2); 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 // 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. // rewiring. `vp-e3` runs from this port to Move's dx.
await select('vp-get'); await select('vp-get');
await clickLast('vx'); await clickLast('vx');
await sleep(200);
await clickLast('vz'); await clickLast('vz');
await sleep(300);
const swapped = await portsOf('vp-get'); const swapped = await portsOf('vp-get');
const edgeStill = await json( const edgeStill = await json(
`(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`, `(__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 }), JSON.stringify({ swapped, edgeStill }),
); );
await undo(); await undo();
await sleep(300);
check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx'); 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. // 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 toggle = async (id) => {
const chevron = await json(`__gm.graph.chevronAt(${JSON.stringify(id)})`); const chevron = await json(`__gm.graph.chevronAt(${JSON.stringify(id)})`);
await click(chevron.x, chevron.y); 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. */ /** `locate` matches exactly, so a title cut short by a too-narrow node is not found. */
const drawnWhole = async (text) => { const drawnWhole = async (text) => {
@@ -222,7 +191,6 @@ export async function variableNodes(t) {
JSON.stringify({ switched, narrow }), JSON.stringify({ switched, narrow }),
); );
await undo(); await undo();
await sleep(300);
await toggle('vp-set'); await toggle('vp-set');
await toggle('vp-get'); await toggle('vp-get');
const open = [await titleOf('vp-set'), await titleOf('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.props.shapes = [{ x: 0, y: 0 }];
a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))}); a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))});
`); `);
await sleep(300);
await select('vp-pack'); await select('vp-pack');
const from = await locate('From variable'); const from = await locate('From variable');
check('a Pack offers the shapes its behavior declares', Boolean(from), JSON.stringify(from)); check('a Pack offers the shapes its behavior declares', Boolean(from), JSON.stringify(from));
await click(from.x, from.y); await click(from.x, from.y);
await sleep(200);
await shot('03-from-variable'); await shot('03-from-variable');
await clickLast('piece'); await clickLast('piece');
await sleep(300);
const seeded = await portsOf('vp-pack'); const seeded = await portsOf('vp-pack');
check( check(
'picking a struct variable declares every field at once, each of its own kind', '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))); check('and the node grew those inputs', Boolean(await portAt('vp-pack', 'cells', false)));
await shot('04-fields-seeded'); await shot('04-fields-seeded');
await undo(); await undo();
await sleep(300);
check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0); 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 // 23h. A list declares nothing, so the shape comes off the struct items in it – the bundle a Pack
// builds for such a list. // builds for such a list.
await click(from.x, from.y); await click(from.x, from.y);
await sleep(200);
await clickLast('shapes (item)'); await clickLast('shapes (item)');
await sleep(300);
const fromItem = await portsOf('vp-pack'); const fromItem = await portsOf('vp-pack');
check( check(
'a list offers the shape of the struct items it holds', 'a list offers the shape of the struct items it holds',
@@ -286,7 +248,6 @@ export async function variableNodes(t) {
JSON.stringify(fromItem), JSON.stringify(fromItem),
); );
await undo(); 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. // 23i. A behavior's own inputs are not fields of a bundle, so the helper stays out of that section.
await mutate(` await mutate(`
@@ -294,7 +255,6 @@ export async function variableNodes(t) {
${JSON.stringify(node('vp-start', 'BehaviorStart', 560, 600))}, ${JSON.stringify(node('vp-start', 'BehaviorStart', 560, 600))},
); );
`); `);
await sleep(200);
await select('vp-start'); await select('vp-start');
// `locate` throws when the text is absent, which is the wrong shape for "is it there?". // `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)`); 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. // Bare canvas below every node, so the click cannot land on the Pack.
const bareSpot = await json('__gm.graph.emptySpot()'); const bareSpot = await json('__gm.graph.emptySpot()');
await click(bareSpot.x, bareSpot.y, 'right'); 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. // Enter takes the first hit in category order, so search by a phrase only its description has.
await typeText('switched to Set'); await typeText('switched to Set');
await sleep(200);
await press('Enter'); await press('Enter');
await sleep(300);
const added = (await asset('vp-logic')).graph.nodes.find((n) => !idsBefore.includes(n.id)); 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)); check('the palette offers the Variables node', added?.type === 'Variables', JSON.stringify(added));
const variableId = added?.id ?? ''; const variableId = added?.id ?? '';
@@ -324,7 +281,6 @@ export async function variableNodes(t) {
const bare = !(await portAt(variableId, 'exec', false)); const bare = !(await portAt(variableId, 'exec', false));
await clickLast('+ Variable'); await clickLast('+ Variable');
await clickLast('vz'); await clickLast('vz');
await sleep(300);
await clickLastSet(); await clickLastSet();
check( check(
'it starts without pins, and picking its variable leaves the title Variables', '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'))}, ${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'vz'))},
); );
`); `);
await sleep(300);
await select('vp-gone'); await select('vp-gone');
check( check(
'a port naming a variable the behavior does not have is marked in the inspector', '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); await sleep(1000);
const counted = (await entity('vp-brain'))?.props.vz; const counted = (await entity('vp-brain'))?.props.vz;
await stop(); await stop();
await sleep(300);
check( check(
'in the run it reads through its own output and writes the sum back, so vz climbs', 'in the run it reads through its own output and writes the sum back, so vz climbs',
typeof counted === 'number' && counted > 20, typeof counted === 'number' && counted > 20,
@@ -372,7 +326,6 @@ export async function variableNodes(t) {
__gm.state.select(null); __gm.state.select(null);
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(300);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length'); 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)); 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) { 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 { mutate, stop, typeText, press } = t;
const portsOf = async (nodeId) => const portsOf = async (nodeId) =>
((await asset('pp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []).map( ((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 title = (nodeId) => json(`__gm.graph.nodeLabel(${JSON.stringify(nodeId)})`);
const settings = async (nodeId) => (await json(`__gm.graph.nodeSettings(${JSON.stringify(nodeId)})`)) ?? []; const settings = async (nodeId) => (await json(`__gm.graph.nodeSettings(${JSON.stringify(nodeId)})`)) ?? [];
const select = async (nodeId) => { const select = async (nodeId) => {
await evaluate( await t.select({ kind: 'node', assetId: 'pp-logic', nodeId, nodeIds: [nodeId] });
`__gm.state.select({ kind: 'node', assetId: 'pp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
);
await sleep(300);
}; };
/** A port section takes its choices when it is built, and re-selecting the same node is a no-op. */ /** A port section takes its choices when it is built, and re-selecting the same node is a no-op. */
const rebuildInspector = async () => { const rebuildInspector = async () => {
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await sleep(150);
await select('pp-set'); await select('pp-set');
}; };
/** The inspector is drawn over the graph, so the last match is the row rather than the port. */ /** The inspector is drawn over the graph, so the last match is the row rather than the port. */
const clickLast = async (text) => { const clickLast = async (text) => {
const p = await locate(text, true); const p = await locate(text, true);
await click(p.x, p.y); await click(p.x, p.y);
await sleep(200);
}; };
const offered = async (shotName) => { const offered = async (shotName) => {
await clickLast('+ Property'); await clickLast('+ Property');
const texts = await json('__gm.overlayTexts()'); const texts = await json('__gm.overlayTexts()');
if (shotName) await shot(shotName); if (shotName) await shot(shotName);
await press('Escape'); await press('Escape');
await sleep(200);
return texts; return texts;
}; };
const addProperty = async (search) => { const addProperty = async (search) => {
await clickLast('+ Property'); await clickLast('+ Property');
await typeText(search); await typeText(search);
await sleep(150);
await press('Enter'); await press('Enter');
await sleep(300);
}; };
const choose = async (setting, option) => { const choose = async (setting, option) => {
const field = await json(`__gm.inspectorField(${JSON.stringify(setting)}, true)`); const field = await json(`__gm.inspectorField(${JSON.stringify(setting)}, true)`);
await click(field.x, field.y); await click(field.x, field.y);
await sleep(200);
await clickLast(option); 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. */ /** The name field, not the node's port or the unconnected-input row below it that say the same. */
const rename = async (from, to) => { const rename = async (from, to) => {
@@ -85,7 +75,6 @@ export async function propertyNodes(t) {
await doubleClick(field.x, field.y); await doubleClick(field.x, field.y);
await typeText(to); await typeText(to);
await press('Enter'); await press('Enter');
await sleep(300);
}; };
// 24a. A box wearing a behavior that reshapes it through one node. // 24a. A box wearing a behavior that reshapes it through one node.
@@ -104,15 +93,12 @@ export async function propertyNodes(t) {
${MAKE_OBJECT} ${MAKE_OBJECT}
scene.objects.push(make('pp-box', 'crate', '', 900, 'Reshaped box'), make('pp-brain', 'pp-logic', 'pp-box', 900, 'Reshape')); 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. // 24b. The run: one node writes both properties, and its own output reads the width back after.
await t.play(); await t.play();
await sleep(400);
const box = (await entity('pp-box'))?.props; const box = (await entity('pp-box'))?.props;
const seen = (await entity('pp-brain'))?.props.seen; const seen = (await entity('pp-brain'))?.props.seen;
await stop(); await stop();
await sleep(300);
check( check(
'one node writes several properties and reads them back', 'one node writes several properties and reads them back',
box?.width === 40 && box?.color === 0xff0000 && seen === 40, 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)`); const toggle = await json(`__gm.inspectorField('Set', true)`);
await click(toggle.x, toggle.y); await click(toggle.x, toggle.y);
await sleep(300);
const inputs = await json(`__gm.graph.nodeInputs('pp-set')`); const inputs = await json(`__gm.graph.nodeInputs('pp-set')`);
check( check(
"switched to Set, its typed-in value starts at the property's own default", "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 shot('03-height-set');
await undo(); await undo();
await undo(); await undo();
await sleep(300);
check('undo takes both back', (await portsOf('pp-set')).length === 2, (await portsOf('pp-set')).join()); 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. // 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 shot('04-camera');
await undo(); await undo();
await sleep(300);
check('undo switches back', (await settings('pp-set')).join() === 'Type: Sprite', await settings('pp-set')); 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 // 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.select(null);
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(300);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "pp-logic").length'); 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)); 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) { 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 { mouse, typeText } = t;
const { entity, stop } = t; const { entity, stop } = t;
const info = () => json('__gm.animation.current()'); const info = () => json('__gm.animation.current()');
@@ -77,7 +78,6 @@ export async function animation(t) {
await press('Escape'); await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)'); 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. // 25a. A strip of four cells, cut into four frames and moved to the head of the asset list.
await evaluate(PASTE_STRIP); 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 })); 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); p.assets.unshift(texture);
`); `);
await sleep(400);
// 25b. The texture's context menu makes an animation of its frames, as one undo step. // 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)})`); const row = await json(`__gm.assetRow(${JSON.stringify(strip)})`);
await click(row.x, row.y, 'right'); await click(row.x, row.y, 'right');
await sleep(300);
await clickText('Create animation from texture'); await clickText('Create animation from texture');
await sleep(400);
const made = await json( 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 }))", "__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 }), JSON.stringify({ selection: await json('__gm.state.selection'), walk }),
); );
await undo(); await undo();
await sleep(300);
const undone = await json("__gm.state.project.assets.filter((a) => a.type === 'animation').length"); const undone = await json("__gm.state.project.assets.filter((a) => a.type === 'animation').length");
await redo(); await redo();
await sleep(300);
check( check(
'undo takes asset and frames back in one step, redo brings both', 'undo takes asset and frames back in one step, redo brings both',
undone === 0 && JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS), 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. // 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 evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(walk.id)} })`);
await openTab(t, 'animation'); await openTab(t, 'animation');
await sleep(300);
check( check(
'the inspector edits frames per second and playback', 'the inspector edits frames per second and playback',
(await t.has('Frames per second')) && (await t.has('Playback')) && (await t.has('Loop')), (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. // 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 click(...Object.values(await json('__gm.animation.playButton()')));
await sleep(200);
const paused = await info(); const paused = await info();
await sleep(350); await sleep(350);
check( check(
@@ -204,7 +197,6 @@ export async function animation(t) {
const tile = async (i) => json(`__gm.animation.tileCentre(${i})`); const tile = async (i) => json(`__gm.animation.tileCentre(${i})`);
const third = await tile(2); const third = await tile(2);
await click(third.x, third.y); await click(third.x, third.y);
await sleep(250);
const picked = await info(); const picked = await info();
check( check(
'clicking a tile selects it and the preview shows it', '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', 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 drag(pointer.x, pointer.y, pointer.x + 40, pointer.y - 30, { button: 'middle' });
await sleep(200);
const panned = (await info()).preview; const panned = (await info()).preview;
check( check(
'the middle button pans it', 'the middle button pans it',
@@ -251,7 +242,6 @@ export async function animation(t) {
); );
await shot('02b-zoomed'); await shot('02b-zoomed');
await clickText('Fit'); await clickText('Fit');
await sleep(250);
check( check(
'Fit puts the preview back', 'Fit puts the preview back',
JSON.stringify((await info()).preview) === JSON.stringify(fitted), 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. // 25f. Dragging a tile into another gap reorders the frames, as one undo step.
const first = await tile(0); const first = await tile(0);
await drag(first.x, first.y, third.x + 39, third.y, 10); await drag(first.x, first.y, third.x + 39, third.y, 10);
await sleep(300);
const reordered = await frames(walk.id); const reordered = await frames(walk.id);
check( check(
'dragging the first tile behind the third moves that frame there', '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() }), JSON.stringify({ reordered, info: await info() }),
); );
await undo(); await undo();
await sleep(250);
check('undo puts it back', JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS)); check('undo puts it back', JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS));
// 25g. Delete removes the selected tile. // 25g. Delete removes the selected tile.
const second = await tile(1); const second = await tile(1);
await click(second.x, second.y); await click(second.x, second.y);
await sleep(200);
await press('Delete'); await press('Delete');
await sleep(250);
const deleted = await frames(walk.id); const deleted = await frames(walk.id);
check( check(
'Delete removes the selected frame and nothing else', 'Delete removes the selected frame and nothing else',
@@ -287,24 +273,23 @@ export async function animation(t) {
JSON.stringify(deleted), JSON.stringify(deleted),
); );
await undo(); await undo();
await sleep(250);
// 25h. + Frames offers a sheet's frames at once and inserts them after the selection. // 25h. + Frames offers a sheet's frames at once and inserts them after the selection.
await clickText('+ Frames'); 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()'); const offered = await json('__gm.overlayTexts()');
check( check(
'+ Frames offers the whole strip and each cell, but no animation', '+ Frames offers the whole strip and each cell, but no animation',
offered.includes('strip – all 4 frames') && offered.includes('strip – all 4 frames') &&
offered.includes('strip / Cell 3') && offered.includes('strip / Cell 3') &&
offered.includes('strip / Cell 4') &&
!offered.some((s) => s.startsWith('strip 2')), !offered.some((s) => s.startsWith('strip 2')),
JSON.stringify(offered), 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 clickText('strip – all 4 frames');
await sleep(300);
const grown = await frames(walk.id); const grown = await frames(walk.id);
check( check(
'all four go in right after the selected frame', 'all four go in right after the selected frame',
@@ -312,7 +297,6 @@ export async function animation(t) {
JSON.stringify(grown), JSON.stringify(grown),
); );
await undo(); await undo();
await sleep(250);
// 25i. A sprite wears the animation through its Texture picker. // 25i. A sprite wears the animation through its Texture picker.
await mutate(` await mutate(`
@@ -331,7 +315,6 @@ export async function animation(t) {
await sleep(500); await sleep(500);
const picker = await json("__gm.inspectorField('Texture', true)"); const picker = await json("__gm.inspectorField('Texture', true)");
await click(picker.x, picker.y); await click(picker.x, picker.y);
await sleep(300);
const choices = await json('__gm.overlayTexts()'); const choices = await json('__gm.overlayTexts()');
check( check(
'the Texture picker offers the animation first, marked as one', 'the Texture picker offers the animation first, marked as one',
@@ -339,14 +322,13 @@ export async function animation(t) {
JSON.stringify(choices), JSON.stringify(choices),
); );
await clickText('strip 2 (animation)'); await clickText('strip 2 (animation)');
await sleep(300);
check( check(
'picking it makes the sprite wear the animation', 'picking it makes the sprite wear the animation',
(await json("__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture")) === walk.id, (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. // 25j. The scene shows the first frame and does not play.
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(700); await sleep(700);
const box = await json("__gm.objectRect('an-box')"); const box = await json("__gm.objectRect('an-box')");
const sceneColour = async () => const sceneColour = async () =>
@@ -374,22 +356,18 @@ export async function animation(t) {
// lies outside; `outward` says which way the neighbouring cell would be. // lies outside; `outward` says which way the neighbouring cell would be.
const edgePixels = async (side, target, outward) => { const edgePixels = async (side, target, outward) => {
await t.play(); await t.play();
await sleep(300);
const first = await edgesOf(); const first = await edgesOf();
await stop(); await stop();
await sleep(200);
const shift = (target - fraction(first[side]) + 1) % 1; const shift = (target - fraction(first[side]) + 1) % 1;
await mutate( await mutate(
`__gm.state.activeScene.objects.find((o) => o.id === 'an-box').position.x += ${shift / first.scale}`, `__gm.state.activeScene.objects.find((o) => o.id === 'an-box').position.x += ${shift / first.scale}`,
); );
await t.play(); await t.play();
await sleep(300);
const e = await edgesOf(); const e = await edgesOf();
const x = Math.floor(e[side]); const x = Math.floor(e[side]);
const edge = rgb(await json(`__gm.pixelAt(${x}, ${e.y})`)); const edge = rgb(await json(`__gm.pixelAt(${x}, ${e.y})`));
const outside = rgb(await json(`__gm.pixelAt(${x + outward * 2}, ${e.y})`)); const outside = rgb(await json(`__gm.pixelAt(${x + outward * 2}, ${e.y})`));
await stop(); await stop();
await sleep(200);
return { fraction: fraction(e[side]).toFixed(2), edge, outside }; 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 // 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( await mutate(
`__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = ${JSON.stringify(walk.id)}`, `__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. // 25k. Play runs it: the pixels the game draws change colour over time.
await t.play(); await t.play();
await sleep(150);
const colours = new Set(); const colours = new Set();
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const at = await json("__gm.gameScreen('an-box')"); const at = await json("__gm.gameScreen('an-box')");
@@ -434,7 +410,6 @@ export async function animation(t) {
const clock = (await entity('an-box'))?.animation; const clock = (await entity('an-box'))?.animation;
await shot('04-playing'); await shot('04-playing');
await stop(); await stop();
await sleep(300);
check( check(
'in play the sprite runs through the frames', 'in play the sprite runs through the frames',
[...colours].filter((c) => NAMES.includes(c)).length >= 3 && clock?.id === walk.id && clock.time > 0, [...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')); scene.objects.push(make('an-brain', 'an-logic', 'an-box', 0, 'Animation logic'));
__gm.state.select(null); __gm.state.select(null);
`); `);
await sleep(400);
await t.play(); await t.play();
await sleep(900); await sleep(900);
const brain = (await entity('an-brain'))?.props; 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)})`)) ? nearestCell(await json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`))
: 'none'; : 'none';
await stop(); await stop();
await sleep(300);
check( check(
'On animation finished fires once when a once animation ends', 'On animation finished fires once when a once animation ends',
brain?.done === 1, brain?.done === 1,
@@ -487,11 +460,8 @@ export async function animation(t) {
const pickPicture = async (label, name) => { const pickPicture = async (label, name) => {
const field = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`); const field = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`);
await click(field.x, field.y); await click(field.x, field.y);
await sleep(200);
await typeText(name); await typeText(name);
await sleep(150);
await press('Enter'); await press('Enter');
await sleep(300);
}; };
await mutate(` await mutate(`
const logic = __gm.state.project.assets.find((a) => a.id === 'an-logic'); const logic = __gm.state.project.assets.find((a) => a.id === 'an-logic');
@@ -500,7 +470,6 @@ export async function animation(t) {
logic.props.pic = ''; logic.props.pic = '';
__gm.state.select({ kind: 'node', assetId: 'an-logic', nodeId: 'an-pic', nodeIds: ['an-pic'] }); __gm.state.select({ kind: 'node', assetId: 'an-logic', nodeId: 'an-pic', nodeIds: ['an-pic'] });
`); `);
await sleep(400);
await pickPicture('Value', walk.name); await pickPicture('Value', walk.name);
const picNode = (await t.asset('an-logic')).graph.nodes.find((n) => n.id === 'an-pic'); const picNode = (await t.asset('an-logic')).graph.nodes.find((n) => n.id === 'an-pic');
const picRows = (await json("__gm.graph.nodeSettings('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 }), JSON.stringify({ params: picNode?.params, rows: picRows }),
); );
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'an-logic' })"); await evaluate("__gm.state.select({ kind: 'asset', assetId: 'an-logic' })");
await sleep(400);
await pickPicture('pic', walk.name); await pickPicture('pic', walk.name);
check( check(
'a texture variable is picked with the same picker', '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. // 25m. Deleting the texture counts the animations that play its frames.
const again = await json(`__gm.assetRow(${JSON.stringify(strip)})`); const again = await json(`__gm.assetRow(${JSON.stringify(strip)})`);
await click(again.x, again.y, 'right'); await click(again.x, again.y, 'right');
await sleep(300);
await clickText('Delete…'); await clickText('Delete…');
await sleep(300);
const dialog = await json('__gm.overlayTexts()'); const dialog = await json('__gm.overlayTexts()');
await shot('05-delete-dialog'); await shot('05-delete-dialog');
check( 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)); check('and its bold Delete button is not cut short', dialog.includes('Delete'), JSON.stringify(dialog));
const cancel = await t.locate('Cancel', true); const cancel = await t.locate('Cancel', true);
await click(cancel.x, cancel.y); await click(cancel.x, cancel.y);
await sleep(300);
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
@@ -546,7 +511,6 @@ export async function animation(t) {
__gm.state.select(null); __gm.state.select(null);
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(300);
const left = await json( const left = await json(
"__gm.state.project.assets.filter((a) => a.type === 'animation' || a.id === 'an-sprite' || a.id === 'an-logic').length", "__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, doubleClick,
drag, drag,
mouse, mouse,
showTab,
} = t; } = t;
const { typeText, undo, redo, world, play, stop } = t; const { settle, typeText, undo, redo, world, play, stop } = t;
const machines = () => const machines = () =>
json(`__gm.state.project.assets json(`__gm.state.project.assets
.filter((a) => a.type === 'statemachine') .filter((a) => a.type === 'statemachine')
@@ -34,29 +35,22 @@ export async function stateMachine(t) {
const clickLast = async (text) => { const clickLast = async (text) => {
const p = await locate(text, true); const p = await locate(text, true);
await click(p.x, p.y); await click(p.x, p.y);
await sleep(200);
}; };
const stateLabel = (id) => json(`__gm.stateMachine.nodeLabel(${JSON.stringify(id)})`); const stateLabel = (id) => json(`__gm.stateMachine.nodeLabel(${JSON.stringify(id)})`);
const selection = () => json('__gm.state.selection'); const selection = () => json('__gm.state.selection');
const outputsOf = async (nodeId) => (await json(`__gm.graph.nodeOutputs(${JSON.stringify(nodeId)})`)) ?? []; 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 inputsOf = async (nodeId) => (await json(`__gm.graph.nodeInputs(${JSON.stringify(nodeId)})`)) ?? [];
const selectNode = async (nodeId) => { const selectNode = async (nodeId) => {
await evaluate( await t.select({ kind: 'node', assetId: 'sm-logic', nodeId, nodeIds: [nodeId] });
`__gm.state.select({ kind: 'node', assetId: 'sm-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
);
await sleep(400);
}; };
await press('Escape'); await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await sleep(200);
// 26a. The + menu creates a state machine; selected, it opens its own tab. // 26a. The + menu creates a state machine; selected, it opens its own tab.
await clickText('+'); await clickText('+');
await sleep(200);
await clickLast('State machine'); await clickLast('State machine');
await sleep(400);
const made = (await machines())[0]; const made = (await machines())[0];
check( check(
'the + menu creates a state machine asset', 'the + menu creates a state machine asset',
@@ -86,9 +80,7 @@ export async function stateMachine(t) {
const canvas = await json('__gm.stateMachine.canvasRect()'); const canvas = await json('__gm.stateMachine.canvasRect()');
const addState = async (dy) => { const addState = async (dy) => {
await click(canvas.x + 80, canvas.y + dy, 'right'); await click(canvas.x + 80, canvas.y + dy, 'right');
await sleep(300);
await clickLast('State'); await clickLast('State');
await sleep(300);
}; };
await addState(60); await addState(60);
await addState(220); await addState(220);
@@ -122,7 +114,6 @@ export async function stateMachine(t) {
const preSnap = JSON.stringify(await states()); const preSnap = JSON.stringify(await states());
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre. // The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
await click(snapLabel.x + 30, snapLabel.y); await click(snapLabel.x + 30, snapLabel.y);
await sleep(150);
check( check(
'the Snap toggle switches it on without moving a state', 'the Snap toggle switches it on without moving a state',
(await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap, (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 from = await json(`__gm.stateMachine.nodeAt(${JSON.stringify(dragged)})`);
const wasAt = await stateXY(dragged); const wasAt = await stateXY(dragged);
await drag(from.x, from.y, from.x + 50, from.y + 50); await drag(from.x, from.y, from.x + 50, from.y + 50);
await sleep(200);
const nowAt = await stateXY(dragged); const nowAt = await stateXY(dragged);
check( check(
'a dragged state lands on the grid, not where the pointer let go', '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}`, `${backAt.x},${backAt.y}`,
); );
await click(snapLabel.x + 30, snapLabel.y); await click(snapLabel.x + 30, snapLabel.y);
await sleep(150);
check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false); check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false);
// 26c. The inspector renames the selected state; the node follows. // 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 doubleClick(nameField.x, nameField.y);
await typeText('Open'); await typeText('Open');
await press('Enter'); await press('Enter');
await sleep(300);
const renamed = (await states())[1]; const renamed = (await states())[1];
check( check(
'typing in the Name field renames the state, on the node too', '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. // 26e. The canvas palette offers a state and nothing else.
await click(canvas.x + canvas.w - 40, canvas.y + canvas.h - 40, 'right'); await click(canvas.x + canvas.w - 40, canvas.y + canvas.h - 40, 'right');
await sleep(300);
const offered = await json('__gm.overlayTexts()'); const offered = await json('__gm.overlayTexts()');
check( check(
'right-click offers a State and no behavior node', 'right-click offers a State and no behavior node',
@@ -189,7 +176,6 @@ export async function stateMachine(t) {
JSON.stringify(offered), JSON.stringify(offered),
); );
await press('Escape'); await press('Escape');
await sleep(200);
// 26e2. A transition is an ordinary edge, but it hangs on ports the editor never draws: a // 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. // 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 makeTransition = async (fromId, toId) => {
const from = await centreOfState(fromId); const from = await centreOfState(fromId);
await click(from.x, from.y, 'right'); await click(from.x, from.y, 'right');
await sleep(300);
await clickLast('Make transition'); await clickLast('Make transition');
await sleep(250);
const to = await centreOfState(toId); const to = await centreOfState(toId);
await click(to.x, to.y); await click(to.x, to.y);
await sleep(350);
}; };
// The right-click way in, since there is no pin to drag from any more. // The right-click way in, since there is no pin to drag from any more.
const firstCentre = await centreOfState(first); const firstCentre = await centreOfState(first);
await click(firstCentre.x, firstCentre.y, 'right'); await click(firstCentre.x, firstCentre.y, 'right');
await sleep(300);
check( check(
"a state's menu offers Make transition", "a state's menu offers Make transition",
(await json('__gm.overlayTexts()')).includes('Make transition'), (await json('__gm.overlayTexts()')).includes('Make transition'),
JSON.stringify(await json('__gm.overlayTexts()')), JSON.stringify(await json('__gm.overlayTexts()')),
); );
await clickLast('Make transition'); await clickLast('Make transition');
await sleep(250);
check( check(
'armed, the canvas says which click it is waiting for', 'armed, the canvas says which click it is waiting for',
(await json('__gm.stateMachine.hint()')).includes('Click the state'), (await json('__gm.stateMachine.hint()')).includes('Click the state'),
@@ -247,7 +228,6 @@ export async function stateMachine(t) {
); );
await shot('05a-armed'); await shot('05a-armed');
await press('Escape'); await press('Escape');
await sleep(250);
check( check(
'Escape drops the attempt, drawing nothing', 'Escape drops the attempt, drawing nothing',
(await transitions()).length === 0 && (await json('__gm.stateMachine.danglingEdge()')) === false, (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 json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(drawn[0]?.id)})`)) === true,
); );
await undo(); await undo();
await sleep(300);
check('undo takes the transition back', (await transitions()).length === 0); check('undo takes the transition back', (await transitions()).length === 0);
await redo(); await redo();
await sleep(300);
check('redo puts it back', (await transitions()).length === 1); check('redo puts it back', (await transitions()).length === 1);
await shot('05-transition'); await shot('05-transition');
// 26e3. Naming a transition, from the list the inspector shows on the state it leaves. // 26e3. Naming a transition, from the list the inspector shows on the state it leaves.
const edgeId = (await transitions())[0]?.id; const edgeId = (await transitions())[0]?.id;
await evaluate( await t.select({ kind: 'node', assetId: made.id, nodeId: first, nodeIds: [first] });
`__gm.state.select({ kind: 'node', assetId: ${JSON.stringify(made.id)}, nodeId: ${JSON.stringify(first)}, nodeIds: [${JSON.stringify(first)}] })`,
);
await sleep(400);
check( check(
'the inspector lists the transitions of the selected state, both ways round', 'the inspector lists the transitions of the selected state, both ways round',
(await has('TRANSITIONS OUT')) && (await has('TRANSITIONS IN')), (await has('TRANSITIONS OUT')) && (await has('TRANSITIONS IN')),
); );
await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`); await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`);
await sleep(400); await settle();
check( check(
'a named transition carries the name on the edge itself', 'a named transition carries the name on the edge itself',
(await transitions())[0]?.label === 'alarm', (await transitions())[0]?.label === 'alarm',
@@ -297,17 +272,15 @@ export async function stateMachine(t) {
const field = await locate('alarm', true); const field = await locate('alarm', true);
// Double-click, so the name is selected and replaced – a single click only parks the caret in it. // 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 doubleClick(field.x, field.y);
await sleep(200);
await typeText('lost'); await typeText('lost');
await press('Enter'); await press('Enter');
await sleep(400);
check( check(
'and the panel row is a real field: typing in it renames the transition', 'and the panel row is a real field: typing in it renames the transition',
(await transitions())[0]?.label === 'lost', (await transitions())[0]?.label === 'lost',
JSON.stringify(await transitions()), JSON.stringify(await transitions()),
); );
await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`); await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`);
await sleep(300); await settle();
await shot('05b-named'); await shot('05b-named');
// 26e4. Both ways round between the same pair: the two arrows must not lie on top of each other. // 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)); check('both arrows are stroked', drawnBoth.every(Boolean), JSON.stringify(drawnBoth));
await shot('05c-both-ways'); await shot('05c-both-ways');
await undo(); await undo();
await sleep(300);
// 26f. A behavior reads the machine: Set state on start, State branch every frame. // 26f. A behavior reads the machine: Set state on start, State branch every frame.
const rig = await scaffoldBehavior(t, { 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' }, id: 'sm-e5', from: { node: 'sm-set', port: 'exec' }, to: { node: 'sm-next', port: 'exec' },
}); });
`); `);
await sleep(300);
await play(); await play();
await sleep(600); await sleep(600);
const moved = (await world())?.log ?? []; 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'); 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'; b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'no such transition';
`); `);
await sleep(300);
await play(); await play();
await sleep(600); await sleep(600);
const stayed = (await world())?.log ?? []; 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'); const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'alarm'; 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 // 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`. // instance before anything reads the machine – and heard as `On state entered`.
await evaluate("__gm.showTab('statemachine')"); await showTab('statemachine');
await sleep(300);
const startOf = () => const startOf = () =>
json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).start ?? ''`); 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 ringOn = (id) => json(`__gm.stateMachine.startRing(${JSON.stringify(id)})`);
const marks = () => json('__gm.stateMachine.stateMarks()'); const marks = () => json('__gm.stateMachine.stateMarks()');
const startCentre = await centreOfState(first); const startCentre = await centreOfState(first);
await click(startCentre.x, startCentre.y, 'right'); await click(startCentre.x, startCentre.y, 'right');
await sleep(300);
const stateMenu = await json('__gm.overlayTexts()'); const stateMenu = await json('__gm.overlayTexts()');
check( check(
'a state offers to become the start state', 'a state offers to become the start state',
@@ -440,7 +406,6 @@ export async function stateMachine(t) {
JSON.stringify(stateMenu), JSON.stringify(stateMenu),
); );
await clickLast('Set as start state'); await clickLast('Set as start state');
await sleep(400);
check( check(
'the mark is kept on the asset and drawn as a ring on that state alone', '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, (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'))}); b.graph.edges.push(${JSON.stringify(edge('sm-e6', 'sm-entered', first, 'sm-entered-log', 'exec'))});
`); `);
await sleep(300);
await play(); await play();
await sleep(600); await sleep(600);
const started = (await world())?.log ?? []; 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. // 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 evaluate('__gm.state.select(null)');
await sleep(400);
const loose = await marks(); const loose = await marks();
check( check(
'while playing, the state the machine is in is marked', 'while playing, the state the machine is in is marked',
@@ -486,7 +449,6 @@ export async function stateMachine(t) {
await evaluate( await evaluate(
"__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: 'sm-box', objectIds: ['sm-box'] })", "__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: 'sm-box', objectIds: ['sm-box'] })",
); );
await sleep(400);
const picked2 = await marks(); const picked2 = await marks();
check( check(
"selecting the object makes its instance's state the strong one", "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 shot('06-live-state');
await stop(); await stop();
await evaluate("__gm.showTab('statemachine')"); await showTab('statemachine');
await sleep(400);
check( check(
'nothing is marked once the run is over', 'nothing is marked once the run is over',
Object.keys(await marks()).length === 0, 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. // 26f5. The same entry takes the mark off again, and the machine is back to starting nowhere.
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await sleep(200);
const againCentre = await centreOfState(first); const againCentre = await centreOfState(first);
await click(againCentre.x, againCentre.y, 'right'); await click(againCentre.x, againCentre.y, 'right');
await sleep(300);
const onMenu = await json('__gm.overlayTexts()'); const onMenu = await json('__gm.overlayTexts()');
check( check(
'the same place offers to take the mark off again', 'the same place offers to take the mark off again',
@@ -516,7 +475,6 @@ export async function stateMachine(t) {
JSON.stringify(onMenu), JSON.stringify(onMenu),
); );
await clickLast('Remove start state'); await clickLast('Remove start state');
await sleep(400);
check( check(
'the mark and its ring are gone', 'the mark and its ring are gone',
(await startOf()) === '' && (await ringOn(first)) === false, (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 = b.graph.edges.filter((e) => e.id !== 'sm-e6');
b.graph.edges.push(${JSON.stringify(edge('sm-e1', 'sm-start', 'exec', 'sm-set', first))}); 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. // 26g. An unconnected machine input shows a picker; picking a machine grows the state ports.
await mutate(` await mutate(`
@@ -550,7 +507,6 @@ export async function stateMachine(t) {
// The row is named after the port, as the node body names it. // The row is named after the port, as the node body names it.
const machineField = await json("__gm.inspectorField('machine', true)"); const machineField = await json("__gm.inspectorField('machine', true)");
await click(machineField.x, machineField.y); await click(machineField.x, machineField.y);
await sleep(300);
const choices = await json('__gm.overlayTexts()'); const choices = await json('__gm.overlayTexts()');
check( check(
'the Machine picker offers the machine by name', 'the Machine picker offers the machine by name',
@@ -558,7 +514,6 @@ export async function stateMachine(t) {
JSON.stringify(choices), JSON.stringify(choices),
); );
await clickLast(made.name); await clickLast(made.name);
await sleep(400);
check( check(
'picking it grows the state outputs', 'picking it grows the state outputs',
names(await outputsOf('sm-fresh')).includes('Open'), 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)}); 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)}); m.graph.nodes = m.graph.nodes.filter((n) => n.id !== ${JSON.stringify(second)});
`); `);
await sleep(400);
const ghosted = await outputsOf('sm-branch'); const ghosted = await outputsOf('sm-branch');
check( check(
'the deleted state leaves a row named after its key, and takes its name away', '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)}); p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(made.id)});
`); `);
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await sleep(300);
check( check(
'the step leaves nothing behind', 'the step leaves nothing behind',
left.objects === 0 && left.assets === 0 && (await machines()).length === 0, left.objects === 0 && left.assets === 0 && (await machines()).length === 0,
+6 -67
View File
@@ -28,6 +28,7 @@ export async function shader(t) {
play, play,
stop, stop,
shot, shot,
showTab,
} = t; } = t;
const { doubleClick, typeText } = t; const { doubleClick, typeText } = t;
const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`); const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`);
@@ -40,7 +41,6 @@ export async function shader(t) {
const clickLast = async (text) => { const clickLast = async (text) => {
const p = await locate(text, true); const p = await locate(text, true);
await click(p.x, p.y); await click(p.x, p.y);
await sleep(200);
}; };
const assetProps = (patch) => const assetProps = (patch) =>
mutate( mutate(
@@ -50,7 +50,6 @@ export async function shader(t) {
await press('Escape'); await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)'); 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. // 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); const seeded = (await shaders()).find((s) => s.id === ADJUST);
@@ -60,9 +59,7 @@ export async function shader(t) {
JSON.stringify(seeded), JSON.stringify(seeded),
); );
await clickText('+'); await clickText('+');
await sleep(200);
await clickLast('Shader'); await clickLast('Shader');
await sleep(400);
const made = (await shaders()).find((s) => s.id !== ADJUST); const made = (await shaders()).find((s) => s.id !== ADJUST);
const selection = await json('__gm.state.selection'); const selection = await json('__gm.state.selection');
check( 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. // 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette.
const canvas = await json('__gm.shader.canvasRect()'); const canvas = await json('__gm.shader.canvasRect()');
await click(canvas.x + 80, canvas.y + 60, 'right'); await click(canvas.x + 80, canvas.y + 60, 'right');
await sleep(300);
const offered = await json('__gm.overlayTexts()'); const offered = await json('__gm.overlayTexts()');
check( check(
'right-click offers shader nodes and no behavior node', '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. // A dozen entries scroll; searched, the row is the only one and in view.
await typeText('Texture color'); await typeText('Texture color');
await sleep(150);
await clickLast('Texture color'); await clickLast('Texture color');
await sleep(300);
await click(canvas.x + 420, canvas.y + 60, 'right'); await click(canvas.x + 420, canvas.y + 60, 'right');
await sleep(300);
await clickLast('Output'); await clickLast('Output');
await sleep(300);
const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? []; const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? [];
check( check(
'both nodes are in the graph, the Output where its right-click was', '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()), JSON.stringify(await madeNodes()),
); );
await click(canvas.x + 420, canvas.y + 320, 'right'); await click(canvas.x + 420, canvas.y + 320, 'right');
await sleep(300);
const again = await json('__gm.overlayTexts()'); const again = await json('__gm.overlayTexts()');
check( check(
'the palette offers the Output only once', 'the palette offers the Output only once',
@@ -117,7 +108,6 @@ export async function shader(t) {
JSON.stringify(again), JSON.stringify(again),
); );
await press('Escape'); await press('Escape');
await sleep(200);
check( check(
'an Output with nothing connected compiles: the texel passes through, no property declared', 'an Output with nothing connected compiles: the texel passes through, no property declared',
(await info()) === '0 properties', (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. // 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 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. // Property is the twelfth entry, below the palette's scroll mask: search it into view first.
await typeText('Property'); await typeText('Property');
await sleep(150);
await clickLast('Property'); await clickLast('Property');
await sleep(300);
const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty'); const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty');
check( check(
'the placed property is named "value" and counted', 'the placed property is named "value" and counted',
@@ -143,7 +130,6 @@ export async function shader(t) {
await doubleClick(nameField.x, nameField.y); await doubleClick(nameField.x, nameField.y);
await typeText('glow'); await typeText('glow');
await press('Enter'); await press('Enter');
await sleep(300);
check( check(
'typing in the Name field renames the property, on the node too', 'typing in the Name field renames the property, on the node too',
(await madeNodes()).find((n) => n.id === propertyNode?.id)?.name === 'glow' && (await madeNodes()).find((n) => n.id === propertyNode?.id)?.name === 'glow' &&
@@ -154,7 +140,6 @@ export async function shader(t) {
await mutate(` await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); 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';`); 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)})`); const problem = await json(`__gm.shader.nodeProblem(${JSON.stringify(propertyNode?.id)})`);
check( check(
'a name that is not one word is a problem on the node and the reason in the readout', '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(` await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); 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';`); 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 // 28c-ii. Where a node sits is the editor's business: dropping it somewhere else compiles nothing
// again, a changed param does – once (#65). // again, a changed param does – once (#65).
@@ -172,7 +156,6 @@ export async function shader(t) {
const compiledBefore = await json('__gm.shaderCompiles()'); const compiledBefore = await json('__gm.shaderCompiles()');
const grab = await json(`__gm.shader.nodeAt(${nodeRef})`); const grab = await json(`__gm.shader.nodeAt(${nodeRef})`);
await drag(grab.x, grab.y, grab.x + 60, grab.y + 40); await drag(grab.x, grab.y, grab.x + 60, grab.y + 40);
await sleep(300);
const dropped = await json(`__gm.shader.nodeAt(${nodeRef})`); const dropped = await json(`__gm.shader.nodeAt(${nodeRef})`);
const compiledAfterMove = await json('__gm.shaderCompiles()'); const compiledAfterMove = await json('__gm.shaderCompiles()');
check( check(
@@ -188,7 +171,6 @@ export async function shader(t) {
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); 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)};`); a.graph.nodes.find((n) => n.id === ${nodeRef}).params.default = ${JSON.stringify(value)};`);
await setDefault(2); await setDefault(2);
await sleep(300);
const compiledAfterParam = await json('__gm.shaderCompiles()'); const compiledAfterParam = await json('__gm.shaderCompiles()');
check( check(
'changing a param compiles it again, once', 'changing a param compiles it again, once',
@@ -196,7 +178,6 @@ export async function shader(t) {
JSON.stringify({ compiledAfterMove, compiledAfterParam }), JSON.stringify({ compiledAfterMove, compiledAfterParam }),
); );
await setDefault(defaultBefore); await setDefault(defaultBefore);
await sleep(300);
// 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets. // 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets.
await mutate(` await mutate(`
@@ -212,9 +193,8 @@ export async function shader(t) {
__gm.state.select(null)`); __gm.state.select(null)`);
await sleep(600); await sleep(600);
// The pixels are the scene view's; the tab is still the shader's. // 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 select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
await sleep(400);
const box = await json("__gm.objectRect('oshade')"); const box = await json("__gm.objectRect('oshade')");
check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box)); check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box));
if (!box) return; if (!box) return;
@@ -245,7 +225,6 @@ export async function shader(t) {
JSON.stringify(shaderField), JSON.stringify(shaderField),
); );
await click(shaderField.x, shaderField.y); await click(shaderField.x, shaderField.y);
await sleep(300);
const choices = await json('__gm.overlayTexts()'); const choices = await json('__gm.overlayTexts()');
check( check(
'the dropdown offers None and every shader asset', 'the dropdown offers None and every shader asset',
@@ -255,7 +234,6 @@ export async function shader(t) {
JSON.stringify({ choices, shaderField }), JSON.stringify({ choices, shaderField }),
); );
await clickLast(choices.find((text) => text.includes('Adjust colors'))); await clickLast(choices.find((text) => text.includes('Adjust colors')));
await sleep(400);
const rows = await Promise.all([field('brightness'), field('contrast'), field('saturation')]); const rows = await Promise.all([field('brightness'), field('contrast'), field('saturation')]);
check( check(
'picking Adjust colors adds its three property rows below the dropdown', '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 shot('28-shader-rows');
await undo(); await undo();
await sleep(300);
check('undoing the choice takes the rows away again', (await field('brightness')) === null); check('undoing the choice takes the rows away again', (await field('brightness')) === null);
await select(null); await select(null);
await sleep(200);
// 28e. Each property is visible in the pixels, with 1 neutral. // 28e. Each property is visible in the pixels, with 1 neutral.
await assetProps({ shader: ADJUST }); await assetProps({ shader: ADJUST });
await sleep(300);
const neutral = await centre(); const neutral = await centre();
check( check(
'Adjust colors at its defaults draws exactly what no shader draws', '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 passes = '({ shaders: __gm.shaderCount(), filters: __gm.filterCount() })';
const passesOn = await json(passes); const passesOn = await json(passes);
await assetProps({ shader: '' }); await assetProps({ shader: '' });
await sleep(300);
const passesOff = await json(passes); const passesOff = await json(passes);
await assetProps({ shader: ADJUST }); await assetProps({ shader: ADJUST });
await sleep(300);
check( check(
'a shaded sprite draws with its own shader and no filter pass – and plain without', '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, 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 assetProps({ brightness: 1.25 });
await sleep(300);
const lighter = await centre(); const lighter = await centre();
check('brightness 1.25 adds a quarter to every channel', colourDistance(lighter, '#80c0ff') < 24, lighter); check('brightness 1.25 adds a quarter to every channel', colourDistance(lighter, '#80c0ff') < 24, lighter);
await assetProps({ brightness: 1, contrast: 0 }); await assetProps({ brightness: 1, contrast: 0 });
await sleep(300);
const flat = await centre(); const flat = await centre();
check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat); check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat);
await assetProps({ contrast: 1, saturation: 0 }); await assetProps({ contrast: 1, saturation: 0 });
await sleep(300);
const grey = await centre(); const grey = await centre();
check('saturation 0 leaves the luma as a grey', colourDistance(grey, '#7c7c7c') < 24, grey); check('saturation 0 leaves the luma as a grey', colourDistance(grey, '#7c7c7c') < 24, grey);
await shot('28-shader-desaturated'); await shot('28-shader-desaturated');
@@ -323,11 +293,9 @@ export async function shader(t) {
JSON.stringify({ at, played }), JSON.stringify({ at, played }),
); );
await stop(); 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. // 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 assetProps({ brightness: 1, shader: made.id });
await sleep(300);
const through = await centre(); const through = await centre();
check( check(
'the new shader passes the texel through', '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'); 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.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' } });`); 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(); const red = await centre();
check( check(
'wiring a red Color into the Output recompiles: the sprite turns red', '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-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-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' } });`); { id: 'sh-fade-out', from: { node: 'sh-fade', port: 'result' }, to: { node: out.id, port: 'alpha' } });`);
await sleep(400);
const opaque = await centre(); const opaque = await centre();
check( check(
'an opacity property at its default 1 changes nothing', '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() }), JSON.stringify({ red, opaque, info: await info() }),
); );
await assetProps({ opacity: 0 }); await assetProps({ opacity: 0 });
await sleep(300);
const behind = await centre(); const behind = await centre();
check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind }); check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind });
await assetProps({ opacity: 0.5 }); await assetProps({ opacity: 0.5 });
await sleep(300);
const half = await centre(); const half = await centre();
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
const expected = const expected =
@@ -391,7 +355,6 @@ export async function shader(t) {
expected, expected,
}); });
await assetProps({ opacity: 1 }); await assetProps({ opacity: 1 });
await sleep(300);
check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red); 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, // 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-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-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' } });`); { id: 'sh-dim-out', from: { node: 'sh-dim', port: 'result' }, to: { node: out.id, port: 'color' } });`);
await sleep(400);
const dimmed = await centre(); const dimmed = await centre();
check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, { check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, {
red, red,
dimmed, dimmed,
}); });
await evaluate("__gm.showTab('shader')"); await showTab('shader');
await sleep(300);
check( check(
'the Number node draws its value as a setting row', 'the Number node draws its value as a setting row',
(await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.500', (await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.500',
JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")), JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")),
); );
await shot('28-number-node'); await shot('28-number-node');
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await mutate(` await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); 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`); 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, { check('the Number typed to 1 gives the red from before, byte for byte', (await centre()) === red, {
red, red,
now: await centre(), now: await centre(),
@@ -468,7 +428,6 @@ export async function shader(t) {
[edge('uv', port, 'cut', 'value'), edge('cut', 'result', 'mix', 't')], [edge('uv', port, 'cut', 'value'), edge('cut', 'result', 'mix', 't')],
); );
await cutAt('u'); await cutAt('u');
await sleep(400);
const uLeft = await pixel(-0.3, -0.3); const uLeft = await pixel(-0.3, -0.3);
const uRight = await pixel(0.3, -0.3); const uRight = await pixel(0.3, -0.3);
check( check(
@@ -477,7 +436,6 @@ export async function shader(t) {
{ uLeft, uRight }, { uLeft, uRight },
); );
await cutAt('v'); await cutAt('v');
await sleep(400);
const vTop = await pixel(-0.3, -0.3); const vTop = await pixel(-0.3, -0.3);
const vBottom = await pixel(-0.3, 0.3); const vBottom = await pixel(-0.3, 0.3);
check( check(
@@ -501,7 +459,6 @@ export async function shader(t) {
], ],
); );
await cutAfterPow(2); await cutAfterPow(2);
await sleep(400);
const squaredNear = await pixel(0.1, -0.3); const squaredNear = await pixel(0.1, -0.3);
const squaredFar = await pixel(0.3, -0.3); const squaredFar = await pixel(0.3, -0.3);
check( check(
@@ -510,7 +467,6 @@ export async function shader(t) {
{ squaredNear, squaredFar }, { squaredNear, squaredFar },
); );
await cutAfterPow(0.5); await cutAfterPow(0.5);
await sleep(400);
const rootNear = await pixel(-0.3, -0.3); const rootNear = await pixel(-0.3, -0.3);
const rootFar = await pixel(-0.15, -0.3); const rootFar = await pixel(-0.15, -0.3);
check( check(
@@ -534,7 +490,6 @@ export async function shader(t) {
edge('cut', 'result', 'mix', 't'), edge('cut', 'result', 'mix', 't'),
], ],
); );
await sleep(400);
const foldLeft = await pixel(-0.4, -0.3); const foldLeft = await pixel(-0.4, -0.3);
const foldMiddle = await pixel(0, -0.3); const foldMiddle = await pixel(0, -0.3);
const foldRight = await pixel(0.4, -0.3); const foldRight = await pixel(0.4, -0.3);
@@ -564,7 +519,6 @@ export async function shader(t) {
], ],
); );
await namedProperty('width'); await namedProperty('width');
await sleep(400);
const spriteProp = await info(); const spriteProp = await info();
check( check(
'a property named like a sprite row is a problem on the node, not a second width row (#63)', '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')") }), JSON.stringify({ spriteProp, problem: await json("__gm.shader.nodeProblem('named')") }),
); );
await namedProperty('amount'); await namedProperty('amount');
await sleep(400);
check( check(
'the same graph under a free name compiles: a quarter of the red', '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, (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 dividedBy(2);
await sleep(400);
const halved = await pixel(-0.3, -0.3); const halved = await pixel(-0.3, -0.3);
await dividedBy(0); await dividedBy(0);
await sleep(400);
const byZero = await pixel(-0.3, -0.3); const byZero = await pixel(-0.3, -0.3);
check( check(
'Divide by 2 halves the red, and dividing by 0 is 0 – a black pixel, not the driver’s choice (#64)', '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')], [edge('red', 'color', 'hold', 'value'), edge('hold', 'result', 'out', 'color')],
); );
await sleep(400);
const held = await pixel(-0.3, -0.3); 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); 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)], [node('flip', 'ShaderSubtract', { a: 1 }, 260, 0)],
[edge('uv', 'u', 'flip', 'b'), edge('flip', 'result', 'sample', 'u')], [edge('uv', 'u', 'flip', 'b'), edge('flip', 'result', 'sample', 'u')],
); );
await sleep(400);
const flipLeft = await pixel(-0.3, -0.3); const flipLeft = await pixel(-0.3, -0.3);
const flipRight = await pixel(0.3, -0.3); const flipRight = await pixel(0.3, -0.3);
check( check(
@@ -675,7 +624,6 @@ export async function shader(t) {
[edge('uv', 'u', 'shift', 'a'), edge('shift', 'result', 'sample', 'u')], [edge('uv', 'u', 'shift', 'a'), edge('shift', 'result', 'sample', 'u')],
{ edge: edgeMode }, { edge: edgeMode },
); );
await sleep(400);
return { left: await pixel(-0.3, -0.3), right: await pixel(0.3, -0.3) }; return { left: await pixel(-0.3, -0.3), right: await pixel(0.3, -0.3) };
}; };
const repeat = await scrolled('repeat'); const repeat = await scrolled('repeat');
@@ -698,7 +646,6 @@ export async function shader(t) {
[edge('uv', 'u', 'far', 'a'), edge('far', 'result', 'sample', 'u')], [edge('uv', 'u', 'far', 'a'), edge('far', 'result', 'sample', 'u')],
outParams, outParams,
); );
await sleep(400);
return pixel(0.3, -0.3); return pixel(0.3, -0.3);
}; };
const byDefault = await scrolledFar({}); const byDefault = await scrolledFar({});
@@ -708,17 +655,14 @@ export async function shader(t) {
colourDistance(byDefault, RIGHT) < 12 && colourDistance(byMirror, LEFT) < 12, colourDistance(byDefault, RIGHT) < 12 && colourDistance(byMirror, LEFT) < 12,
{ byDefault, byMirror }, { byDefault, byMirror },
); );
await evaluate("__gm.showTab('shader')"); await showTab('shader');
await sleep(300);
await shot('28-shader-scroll-graph'); await shot('28-shader-scroll-graph');
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(300);
const sizeAtLeast = async (px) => { const sizeAtLeast = async (px) => {
await redOrBlue( await redOrBlue(
[node('size', 'ShaderTextureSize'), node('cut', 'ShaderStep', { edge: px }, 260, 0)], [node('size', 'ShaderTextureSize'), node('cut', 'ShaderStep', { edge: px }, 260, 0)],
[edge('size', 'width', 'cut', 'value'), edge('cut', 'result', 'mix', 't')], [edge('size', 'width', 'cut', 'value'), edge('cut', 'result', 'mix', 't')],
); );
await sleep(400);
return pixel(-0.3, -0.3); return pixel(-0.3, -0.3);
}; };
const wide = await sizeAtLeast(31.5); const wide = await sizeAtLeast(31.5);
@@ -788,21 +732,17 @@ export async function shader(t) {
await sleep(500); await sleep(500);
const playing = await json('__gm.animates()'); const playing = await json('__gm.animates()');
await stop(); await stop();
await sleep(300);
check('and so does the game view while playing', playing.game === true, JSON.stringify(playing)); check('and so does the game view while playing', playing.game === true, JSON.stringify(playing));
await assetProps({ shader: '' }); await assetProps({ shader: '' });
await sleep(400);
check( check(
'with the shader off the scene view stops redrawing', 'with the shader off the scene view stops redrawing',
(await json('__gm.animates()')).scene === false, (await json('__gm.animates()')).scene === false,
JSON.stringify(await json('__gm.animates()')), JSON.stringify(await json('__gm.animates()')),
); );
await assetProps({ texture: '', color: 0x4080ff, shader: made.id }); 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. // 28j. Back to None: no filter at all, the plain colour returns bit for bit.
await assetProps({ shader: '' }); await assetProps({ shader: '' });
await sleep(300);
const back = await centre(); const back = await centre();
check( check(
'switching back to None restores the plain colour', 'switching back to None restores the plain colour',
@@ -817,7 +757,6 @@ export async function shader(t) {
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'oshade')`); scene.objects = scene.objects.filter((o) => o.id !== 'oshade')`);
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await sleep(200);
check( check(
'the step leaves nothing behind but the fixture’s shader', 'the step leaves nothing behind but the fixture’s shader',
(await json("__gm.state.project.assets.some((a) => a.id === 'shade-sprite')")) === false && (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, entity,
stop, stop,
shot, shot,
showTab,
} = t; } = t;
const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`); const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`);
const rect = (id) => json(`__gm.objectRect(${JSON.stringify(id)})`); const rect = (id) => json(`__gm.objectRect(${JSON.stringify(id)})`);
@@ -47,7 +48,6 @@ export async function transform(t) {
); );
const selectObject = async (id) => { const selectObject = async (id) => {
await select({ kind: 'object', holderId: 'scene1', objectIds: [id], objectId: id }); await select({ kind: 'object', holderId: 'scene1', objectIds: [id], objectId: id });
await sleep(300);
}; };
const remove = (ids) => const remove = (ids) =>
mutate(` mutate(`
@@ -57,8 +57,7 @@ export async function transform(t) {
await press('Escape'); await press('Escape');
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(200);
// 29a. A plain red 100×40 sprite in its lane, and a second camera to read the inspector off; // 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. // 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. // 29c. Rotation 90: the box stands up – its frame swaps sides, the pixels move with it.
await override(BOX, { rotation: 90 }); await override(BOX, { rotation: 90 });
await sleep(300);
const turned = await rect(BOX); const turned = await rect(BOX);
const turnedPx = { right: await px(40, 0), below: await px(0, 40) }; const turnedPx = { right: await px(40, 0), below: await px(0, 40) };
check( 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. // 29d. Scale 2 on top: twice the frame, red where the upright box would have ended long ago.
await override(BOX, { scale: 2 }); await override(BOX, { scale: 2 });
await sleep(300);
const grown = await rect(BOX); const grown = await rect(BOX);
const far = await px(0, 90); const far = await px(0, 90);
check( check(
@@ -159,7 +156,6 @@ export async function transform(t) {
JSON.stringify(plain), JSON.stringify(plain),
); );
await override(BOX, { mirrorX: true }); await override(BOX, { mirrorX: true });
await sleep(300);
const mirrored = { left: await px(-30, 0), right: await px(30, 0) }; const mirrored = { left: await px(-30, 0), right: await px(30, 0) };
check( check(
'Mirror X swaps the halves: green left, red right', 'Mirror X swaps the halves: green left, red right',
@@ -167,7 +163,6 @@ export async function transform(t) {
JSON.stringify(mirrored), JSON.stringify(mirrored),
); );
await override(BOX, { mirrorY: true }); await override(BOX, { mirrorY: true });
await sleep(300);
const both = { left: await px(-30, 0), right: await px(30, 0) }; const both = { left: await px(-30, 0), right: await px(30, 0) };
check( check(
'Mirror Y on top leaves the halves where Mirror X put them', '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 override(BOX, { mirrorX: false, mirrorY: false });
await spriteProps({ texture: '', color: 0xff0000 }); 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 // 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. // 100 below it – and is drawn turned itself.
await mutate(` await mutate(`
const scene = __gm.state.activeScene; 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: {} });`); 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) }; const flat = { parent: await screen(BOX), child: await screen(CHILD) };
await override(BOX, { rotation: 90 }); await override(BOX, { rotation: 90 });
await sleep(300);
const hung = { parent: await screen(BOX), child: await screen(CHILD), childRect: await rect(CHILD) }; const hung = { parent: await screen(BOX), child: await screen(CHILD), childRect: await rect(CHILD) };
check( check(
'a child follows its parent’s turn: 100 to the right becomes 100 below, drawn turned', '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 // 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. // 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 drag(hung.child.x, hung.child.y, hung.child.x, hung.child.y + 30, { steps: 8 });
await sleep(300);
const dragged = await object(CHILD); const dragged = await object(CHILD);
check( check(
'a drag under a turned parent lands in the parent’s frame: local x + 30 / scale, y unchanged', '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 }), JSON.stringify({ position: dragged.position, expected: 100 + 30 / s }),
); );
await undo(); await undo();
await sleep(200);
check('undo puts the child back', (await object(CHILD)).position.x === 100); check('undo puts the child back', (await object(CHILD)).position.x === 100);
await selectObject(CHILD); await selectObject(CHILD);
const gizmos = await json('__gm.gizmos()'); const gizmos = await json('__gm.gizmos()');
@@ -236,7 +226,6 @@ export async function transform(t) {
); );
__gm.state.select(null)`); __gm.state.select(null)`);
await press('Enter', { ctrl: true }); await press('Enter', { ctrl: true });
await sleep(300);
await holdKey('ArrowRight', 1000); await holdKey('ArrowRight', 1000);
const body = await entity('otf-body'); const body = await entity('otf-body');
await stop(); await stop();
@@ -255,7 +244,6 @@ export async function transform(t) {
project.assets = project.assets.filter((a) => !gone.has(a.id)); project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.lookAt(0, 0)`); __gm.lookAt(0, 0)`);
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await sleep(200);
check( check(
'the step leaves nothing behind', 'the step leaves nothing behind',
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(SPRITE)})`)) === false && (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; const MONO_BYTES = 343140;
export async function font(t) { 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 { select } = t;
const assetsOfType = (type) => const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(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 clickLast = async (text) => {
const p = await locate(text, true); const p = await locate(text, true);
await click(p.x, p.y); 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. */ /** A face arrives over the network, so what it changes is awaited rather than slept for. */
const waitFor = async (read, done, tries = 20) => { const waitFor = async (read, done, tries = 20) => {
@@ -42,8 +42,7 @@ export async function font(t) {
await press('Escape'); await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(200);
// 30a. What the server holds, straight from the API: the two files the seed put there. // 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. // `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. // 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture.
await evaluate('__gm.console.clear()'); await evaluate('__gm.console.clear()');
await clickText('+'); await clickText('+');
await sleep(200);
await pickAssetType(t, 'Font'); await pickAssetType(t, 'Font');
await sleep(500); await sleep(500);
const picker = await json('__gm.fontPicker()'); 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)})`); const row = await json(`__gm.fontPickerRow(${JSON.stringify(serif.file)})`);
await click(row.x, row.y); await click(row.x, row.y);
await sleep(200);
await clickText('Use font'); await clickText('Use font');
await sleep(400);
const fonts = await assetsOfType('font'); const fonts = await assetsOfType('font');
check( check(
'picking a row makes a font asset of that file, named after the file rather than "Font"', '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 }, name: '', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 },
overrides: {}, parentId: '', instanceEdits: {} }); overrides: {}, parentId: '', instanceEdits: {} });
__gm.lookAt(0, ${LANE});`); __gm.lookAt(0, ${LANE});`);
await sleep(400);
const plain = await info(); const plain = await info();
check( check(
'a text wearing no font is drawn in the editor’s own typeface', '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. // 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 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 // 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. // 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)"); const fontRow = await json("__gm.inspectorField('Font', true)");
@@ -140,9 +134,7 @@ export async function font(t) {
JSON.stringify({ fontRow, none }), JSON.stringify({ fontRow, none }),
); );
await click(fontRow.x, fontRow.y); await click(fontRow.x, fontRow.y);
await sleep(250);
await clickLast('Bookish (font)'); await clickLast('Bookish (font)');
await sleep(400);
const chosen = await json( const chosen = await json(
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font`, `__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'); await shot('30-font-scene');
// The asset's own inspector is where a font is looked at; it has nothing else to show. // 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 select({ kind: 'asset', assetId: fontId });
await sleep(300);
await shot('30-font-asset'); await shot('30-font-asset');
// 30e. The game draws through the same path, so the face is in play as well. The camera frames // 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 stop();
await place(`{ x: 0, y: ${LANE} }`); 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. // 30f. An id that no longer resolves is no font at all – the text falls back rather than vanishing.
await mutate( await mutate(
@@ -223,7 +213,6 @@ export async function font(t) {
(o) => o.id !== ${JSON.stringify(TEXT_OBJECT)}); (o) => o.id !== ${JSON.stringify(TEXT_OBJECT)});
__gm.state.select(null); __gm.state.select(null);
__gm.lookAt(0, 0);`); __gm.lookAt(0, 0);`);
await sleep(300);
check( check(
'the step leaves nothing of its own behind', 'the step leaves nothing of its own behind',
(await assetsOfType('font')).length === 0 && (await info()) === null, (await assetsOfType('font')).length === 0 && (await info()) === null,
+7 -54
View File
@@ -16,7 +16,8 @@ const SOURCE = 'pt-source';
const RATE = 20; const RATE = 20;
export async function particles(t) { 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 { drag } = t;
const { select } = t; const { select } = t;
/** Freeze, restart every source's clock, then step the clock to `seconds` and let it redraw. */ /** 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 drawn = (limit = 8) => json(`__gm.particlesOf(${JSON.stringify(SOURCE)}, 'scene', ${limit})`);
const selectSource = async () => { const selectSource = async () => {
await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE }); await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE });
await sleep(350);
}; };
const clickLast = async (text) => { const clickLast = async (text) => {
const p = await locate(text, true); const p = await locate(text, true);
await click(p.x, p.y); 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. */ /** Places a node from the palette. The list scrolls, so the name is searched into view first. */
const placeNodeAt = async (label, point) => { const placeNodeAt = async (label, point) => {
await click(point.x, point.y, 'right'); await click(point.x, point.y, 'right');
await sleep(300);
await typeText(label); await typeText(label);
await sleep(200);
await clickLast(label); await clickLast(label);
await sleep(300);
}; };
const placeNode = async (label, dx, dy) => { const placeNode = async (label, dx, dy) => {
const canvas = await json('__gm.particles.canvasRect()'); 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({ __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, id: ${JSON.stringify(id)}, type: ${JSON.stringify(type)}, x: 0, y: 0,
params: ${JSON.stringify(params)} });`); params: ${JSON.stringify(params)} });`);
await sleep(150);
}; };
const link = async (id, from, fromPort, to, toPort) => { const link = async (id, from, fromPort, to, toPort) => {
await mutate(` await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.edges.push({ __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)} }, id: ${JSON.stringify(id)}, from: { node: ${JSON.stringify(from)}, port: ${JSON.stringify(fromPort)} },
to: { node: ${JSON.stringify(to)}, port: ${JSON.stringify(toPort)} } });`); to: { node: ${JSON.stringify(to)}, port: ${JSON.stringify(toPort)} } });`);
await sleep(150);
}; };
let assetId = ''; let assetId = '';
await press('Escape'); await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(200);
// 31a. "+ → Particles" makes the asset and opens its tab; with no Output there is nothing to run. // 31a. "+ → Particles" makes the asset and opens its tab; with no Output there is nothing to run.
await evaluate('__gm.console.clear()'); await evaluate('__gm.console.clear()');
await clickText('+'); await clickText('+');
await sleep(200);
const picked = await pickAssetType(t, 'Particles'); const picked = await pickAssetType(t, 'Particles');
await sleep(400);
const created = await json(`{ const created = await json(`{
tab: __gm.tab(), tab: __gm.tab(),
current: __gm.particles.current(), current: __gm.particles.current(),
@@ -107,12 +98,10 @@ export async function particles(t) {
assetId = created.assets[0]; assetId = created.assets[0];
// Renamed away from "Particles": the tab button says that too, and a lookup by text would find it. // 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 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. // 31b. The palette of this tab holds particle nodes and nothing else.
const canvas = await json('__gm.particles.canvasRect()'); const canvas = await json('__gm.particles.canvasRect()');
await click(canvas.x + 80, canvas.y + 60, 'right'); await click(canvas.x + 80, canvas.y + 60, 'right');
await sleep(300);
const palette = await json('__gm.overlayTexts()'); const palette = await json('__gm.overlayTexts()');
check( check(
'the palette offers particle nodes, not behavior ones', 'the palette offers particle nodes, not behavior ones',
@@ -120,7 +109,6 @@ export async function particles(t) {
JSON.stringify(palette.slice(0, 12)), JSON.stringify(palette.slice(0, 12)),
); );
await press('Escape'); 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. // 31c. One Output node is a whole graph: a particle without rules is one at the source, full size.
await placeNode('Output', 80, 60); 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. // 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()'); const track = await json('__gm.particles.sliderAt()');
await click(track.x, track.y); await click(track.x, track.y);
await sleep(200);
const dragged = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); const dragged = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
check( check(
'dragging the time slider holds the panel, and the button then offers to run it again', '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), JSON.stringify(dragged),
); );
await clickText('Restart'); await clickText('Restart');
await sleep(200);
const restarted = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); const restarted = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
check( check(
'Restart puts a held panel back to second 0, and the readout and slider with it', '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), JSON.stringify(restarted),
); );
await clickText('Run'); await clickText('Run');
await sleep(250);
const running = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); const running = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
check( check(
'the same button lets it run again and says "Hold" once it does', '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), JSON.stringify(beside),
); );
await drag(beside.split.x, beside.split.y, beside.split.x - 80, beside.split.y, { steps: 8 }); await drag(beside.split.x, beside.split.y, beside.split.x - 80, beside.split.y, { steps: 8 });
await sleep(200);
const widened = await json(`{ const widened = await json(`{
canvas: __gm.particles.canvasRect(), canvas: __gm.particles.canvasRect(),
preview: __gm.particles.previewRect(), 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 }), 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 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 // 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 // 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 evaluate('__gm.particles.hold(0.5)');
await sleep(150); await sleep(150);
await clickText('Restart'); await clickText('Restart');
await sleep(200);
await evaluate('__gm.particleTime(2003)'); await evaluate('__gm.particleTime(2003)');
await sleep(200); await sleep(200);
const waited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); const waited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
await clickText('Run'); await clickText('Run');
await sleep(200);
const letGo = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); const letGo = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
check( check(
'three seconds spent on hold are not owed: letting it run again carries on from second 0', '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.lifetime = 2;
a.props.rate = 5; a.props.rate = 5;
`); `);
await sleep(200);
await evaluate('__gm.particles.hold(1.5)'); await evaluate('__gm.particles.hold(1.5)');
await sleep(200); await sleep(200);
const edited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); 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)}); const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)});
a.props.timeScale = 2; a.props.timeScale = 2;
`); `);
await sleep(300);
const scaled = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); const scaled = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
check( check(
'a time scale of 2 halves what the slider spans and doubles what a held second shows', '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.rate;
delete a.props.timeScale; delete a.props.timeScale;
`); `);
await sleep(200);
await evaluate('__gm.particles.hold(null)'); await evaluate('__gm.particles.hold(null)');
await evaluate('__gm.particleTime(null)'); await evaluate('__gm.particleTime(null)');
await sleep(150); 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 }, name: 'Fountain', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 },
overrides: {}, parentId: '', instanceEdits: {} }); overrides: {}, parentId: '', instanceEdits: {} });
__gm.lookAt(0, ${LANE});`); __gm.lookAt(0, ${LANE});`);
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(400);
await rewind(); await rewind();
await at(0.5); await at(0.5);
const inScene = await drawn(); 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 // 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. // old, is exactly 100 px below the source at strength 800.
await evaluate("__gm.showTab('particles')"); await showTab('particles');
await sleep(250);
// Not a fixed point: the Output node is already on the canvas, and since the preview took the // 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. // right of the tab (#86) there is no corner left that is free by construction.
await placeNodeAt('Gravity', await json('__gm.particles.emptySpot()')); 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; 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' }, graph.edges.push({ id: 'pt-e1', from: { node: ${JSON.stringify(gravityId)}, port: 'y' },
to: { node: ${JSON.stringify(outputId)}, port: 'y' } });`); to: { node: ${JSON.stringify(outputId)}, port: 'y' } });`);
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(300);
await rewind(); await rewind();
await at(0.5); await at(0.5);
const falling = await drawn(2); const falling = await drawn(2);
@@ -352,7 +327,6 @@ export async function particles(t) {
await addNode('pt-age', 'ParticleAge'); await addNode('pt-age', 'ParticleAge');
await link('pt-e2', 'pt-age', 'age', 'pt-sub', 'b'); await link('pt-e2', 'pt-age', 'age', 'pt-sub', 'b');
await link('pt-e3', 'pt-sub', 'result', outputId, 'alpha'); await link('pt-e3', 'pt-sub', 'result', outputId, 'alpha');
await sleep(300);
await rewind(); await rewind();
await at(0.5); await at(0.5);
const fading = await drawn(11); 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. // 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 addNode('pt-prop', 'ParticleProperty', { name: 'drift', default: 0, min: -200, max: 200 });
await link('pt-e4', 'pt-prop', 'value', outputId, 'x'); await link('pt-e4', 'pt-prop', 'value', outputId, 'x');
await sleep(300);
await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE }); await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE });
await sleep(350);
const driftRow = await json("__gm.inspectorField('drift')"); const driftRow = await json("__gm.inspectorField('drift')");
const rateRow = await json("__gm.inspectorField('Rate')"); const rateRow = await json("__gm.inspectorField('Rate')");
check( check(
@@ -379,7 +351,6 @@ export async function particles(t) {
); );
await mutate(` await mutate(`
__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.drift = 60;`); __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.drift = 60;`);
await sleep(250);
await rewind(); await rewind();
await at(0.5); await at(0.5);
const drifted = await drawn(2); const drifted = await drawn(2);
@@ -404,7 +375,6 @@ export async function particles(t) {
); );
await mutate(` await mutate(`
__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.emitting = true;`); __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.emitting = true;`);
await sleep(200);
await rewind(); await rewind();
await at(0.5); await at(0.5);
check('switching it back on fills the field again', (await drawn(1))?.count === RATE / 2 + 1, ''); 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(` await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
Object.assign(o.overrides, { mode: 'burst', count: 7 });`); Object.assign(o.overrides, { mode: 'burst', count: 7 });`);
await sleep(200);
await rewind(); await rewind();
await at(0.5); await at(0.5);
const burst = await drawn(7); const burst = await drawn(7);
@@ -431,7 +400,6 @@ export async function particles(t) {
await mutate(` await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
o.overrides.prewarm = true;`); o.overrides.prewarm = true;`);
await sleep(200);
await rewind(); await rewind();
await at(0.5); await at(0.5);
const warmBurst = await drawn(7); 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)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
Object.assign(o.overrides, { mode: 'continuous', drift: 0 }); Object.assign(o.overrides, { mode: 'continuous', drift: 0 });
delete o.overrides.count;`); delete o.overrides.count;`);
await sleep(200);
await rewind(); await rewind();
await at(0); await at(0);
const warmStart = await drawn(1); const warmStart = await drawn(1);
await mutate(` await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
delete o.overrides.prewarm;`); delete o.overrides.prewarm;`);
await sleep(200);
await rewind(); await rewind();
await at(0); await at(0);
const coldStart = await drawn(1); const coldStart = await drawn(1);
@@ -469,14 +435,12 @@ export async function particles(t) {
await mutate(` await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
o.overrides.timeScale = 2;`); o.overrides.timeScale = 2;`);
await sleep(200);
await rewind(); await rewind();
await at(0.5); await at(0.5);
const fast = await drawn(3); const fast = await drawn(3);
await mutate(` await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
delete o.overrides.timeScale;`); delete o.overrides.timeScale;`);
await sleep(200);
await rewind(); await rewind();
await at(1); await at(1);
const plain = await drawn(3); const plain = await drawn(3);
@@ -503,13 +467,11 @@ export async function particles(t) {
await mutate(` await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
Object.assign(o.overrides, { mode: 'burst', count: 7 });`); 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. // 31l. It really draws: the pixel where a burst particle sits is not the scene background.
await mutate(` await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
Object.assign(o.overrides, { size: 40, color: 0x3cc85a, drift: 0 });`); Object.assign(o.overrides, { size: 40, color: 0x3cc85a, drift: 0 });`);
await sleep(200);
await rewind(); await rewind();
await at(0.1); await at(0.1);
const spotOn = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`); const spotOn = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`);
@@ -531,9 +493,7 @@ export async function particles(t) {
await sleep(400); await sleep(400);
const thawed = await drawn(1); const thawed = await drawn(1);
await t.play(); await t.play();
await sleep(300);
await t.stop(); await t.stop();
await sleep(400);
const afterRun = await drawn(1); const afterRun = await drawn(1);
check( check(
'the source draws on after the clock is pulled out from under it: a freeze released, a run ended', '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)}); const source = scene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
source.parentId = 'pt-anchor'; source.parentId = 'pt-anchor';
source.position = { x: 200, y: 0, z: 0 };`); source.position = { x: 200, y: 0, z: 0 };`);
await sleep(300);
const linked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); const linked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`);
await mutate(` await mutate(`
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
@@ -587,7 +546,6 @@ export async function particles(t) {
source.parentId = ''; source.parentId = '';
source.position = { x: 0, y: ${LANE}, z: 0 }; source.position = { x: 0, y: ${LANE}, z: 0 };
scene.objects = scene.objects.filter((o) => o.id !== 'pt-anchor');`); scene.objects = scene.objects.filter((o) => o.id !== 'pt-anchor');`);
await sleep(300);
const unlinked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); const unlinked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`);
check( check(
'a parented source hangs off its owner by a line, a free one by none', '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)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
Object.assign(o.overrides, { shader: ${JSON.stringify(SHADER)}, level: 1 });`); Object.assign(o.overrides, { shader: ${JSON.stringify(SHADER)}, level: 1 });`);
await sleep(300);
await selectSource(); await selectSource();
// The tab bar has a button reading "Shader" too, and it is drawn first: the row wanted here is the // 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. // *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(` await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
o.overrides.level = 0.25;`); o.overrides.level = 0.25;`);
await sleep(300);
await at(0.1); await at(0.1);
const dimmed = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`); const dimmed = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`);
check( check(
@@ -657,7 +613,6 @@ export async function particles(t) {
const shader = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SHADER)}); 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.nodes.find((n) => n.id === 'lv-prop').params.name = 'rate';
shader.graph.edges = [];`); shader.graph.edges = [];`);
await sleep(300);
await select(null); await select(null);
await selectSource(); await selectSource();
const own = await json('__gm.locateAll("Rate")'); const own = await json('__gm.locateAll("Rate")');
@@ -673,7 +628,6 @@ export async function particles(t) {
delete o.overrides.level; delete o.overrides.level;
__gm.state.project.assets = __gm.state.project.assets.filter( __gm.state.project.assets = __gm.state.project.assets.filter(
(a) => a.id !== ${JSON.stringify(SHADER)});`); (a) => a.id !== ${JSON.stringify(SHADER)});`);
await sleep(300);
// 31p. The graph tab draws no Pixi complaint of its own. // 31p. The graph tab draws no Pixi complaint of its own.
const logged = await json('__gm.console.lines()'); 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( __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
(o) => o.id !== ${JSON.stringify(SOURCE)}); (o) => o.id !== ${JSON.stringify(SOURCE)});
__gm.lookAt(0, 0);`); __gm.lookAt(0, 0);`);
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(300);
check( check(
'the step leaves nothing of its own behind', 'the step leaves nothing of its own behind',
(await json("__gm.state.project.assets.filter((a) => a.type === 'particles').length")) === 0 && (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) { 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) => { const selectAsset = async (id) => {
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(id)} })`); await t.select({ kind: 'asset', assetId: id });
await sleep(300);
}; };
const selectNode = async (nodeId) => { const selectNode = async (nodeId) => {
await evaluate( await t.select({ kind: 'node', assetId: 'sc-logic', nodeId });
`__gm.state.select({ kind: 'node', assetId: 'sc-logic', nodeId: ${JSON.stringify(nodeId)} })`,
);
await sleep(300);
}; };
/** The inspector is drawn over the graph, so the last match is the row rather than a port. */ /** The inspector is drawn over the graph, so the last match is the row rather than a port. */
const clickLast = async (text) => { const clickLast = async (text) => {
const p = await locate(text, true); const p = await locate(text, true);
await click(p.x, p.y); await click(p.x, p.y);
await sleep(250);
}; };
// 32a. A Variables asset, a behavior counting on it, and two boxes wearing that behavior. // 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'), 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. // 32b. The run: two instances, one number. A per-instance variable would stand at 1.
await t.play(); await t.play();
@@ -93,7 +87,6 @@ export async function scope(t) {
`objects carrying "score": ${JSON.stringify(carriers)}`, `objects carrying "score": ${JSON.stringify(carriers)}`,
); );
await stop(); await stop();
await sleep(400);
// 32c. The asset is where the declaration lives, and it says what it is for. // 32c. The asset is where the declaration lives, and it says what it is for.
await selectAsset('sc-vars'); 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; const g = __gm.state.project.assets.find((a) => a.id === 'sc-logic').graph;
g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = ''; 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.'); const complainsNow = await has('"score" is not a variable of this behavior.');
check( check(
'and clearing it points the node back at the behavior, which has no such variable', '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 machineScope = await has('Local');
const pick = await locate('Local', true); const pick = await locate('Local', true);
await click(pick.x, pick.y); await click(pick.x, pick.y);
await sleep(250);
await clickLast('Global'); await clickLast('Global');
await sleep(350);
const machine = await asset('sc-machine'); const machine = await asset('sc-machine');
check( check(
'a state machine is switched between one current state per object and one for the run', '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-')); p.assets = p.assets.filter((a) => !a.id.startsWith('sc-'));
__gm.state.select(null); __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. * The step runs last and leaves the editor as it found it.
*/ */
export async function renderOnChange(t) { 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()'); const stats = () => evaluate('__gm.renderStats()');
/** Frames drawn over `ms` of doing nothing at all. */ /** 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 * 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. * 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) { for (let waited = 0; waited < timeoutMs; waited += 100) {
if (!(await stats()).awake) return true; if (!(await stats()).awake) return true;
await sleep(100); await sleep(100);
+2 -22
View File
@@ -19,7 +19,7 @@ const BLIP_BYTES = 4044;
const DRONE_BYTES = 12044; const DRONE_BYTES = 12044;
export async function sound(t) { 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 { select } = t;
const assetsOfType = (type) => const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(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 press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(200);
// 34a. What the server holds, straight from the API: the two files the seed put there. // 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())`); 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. // 34b. "+ → Sound" offers them; a sound is picked rather than created empty, like a typeface.
await clickText('+'); await clickText('+');
await sleep(200);
await pickAssetType(t, 'Sound'); await pickAssetType(t, 'Sound');
await sleep(500); await sleep(500);
const picker = await json('__gm.soundPicker()'); 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)})`); const row = await json(`__gm.soundPickerRow(${JSON.stringify(blip.file)})`);
await click(row.x, row.y); await click(row.x, row.y);
await sleep(200);
await clickText('Use sound'); await clickText('Use sound');
await sleep(400);
const sounds = await assetsOfType('sound'); const sounds = await assetsOfType('sound');
check( check(
'picking a row makes a sound asset of that file, named after the file rather than "Sound"', '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. // 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 select({ kind: 'asset', assetId: soundId });
await sleep(300);
const volumeRow = await json("__gm.inspectorField('Volume', true)"); const volumeRow = await json("__gm.inspectorField('Volume', true)");
const loopRow = await json("__gm.inspectorField('Loop', true)"); const loopRow = await json("__gm.inspectorField('Loop', true)");
check( check(
@@ -153,7 +148,6 @@ export async function sound(t) {
make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'), make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'),
); );
__gm.lookAt(0, ${LANE});`); __gm.lookAt(0, ${LANE});`);
await sleep(400);
await evaluate('__gm.sound.stopAll()'); await evaluate('__gm.sound.stopAll()');
const before = await stats(); const before = await stats();
@@ -168,14 +162,12 @@ export async function sound(t) {
JSON.stringify(played), JSON.stringify(played),
); );
await stop(); await stop();
await sleep(300);
// 34f. A looping sound is music: it plays on, and ending the run ends it – nothing may outlive // 34f. A looping sound is music: it plays on, and ending the run ends it – nothing may outlive
// the world that started it. // the world that started it.
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.loop = true;`); p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.loop = true;`);
await sleep(300);
await play(); await play();
const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true); const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true);
check( check(
@@ -187,7 +179,6 @@ export async function sound(t) {
await sleep(700); await sleep(700);
const stillLooping = await stats(); const stillLooping = await stats();
await stop(); await stop();
await sleep(400);
const afterStop = await stats(); const afterStop = await stats();
check( check(
'a loop keeps playing while the run is on and ends with it', '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; const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.find((n) => n.id === 'sn-play').params.loop = ${JSON.stringify(value)};`); graph.nodes.find((n) => n.id === 'sn-play').params.loop = ${JSON.stringify(value)};`);
await setNodeLoop('false'); await setNodeLoop('false');
await sleep(300);
const beforeOnce = await stats(); const beforeOnce = await stats();
await play(); await play();
const once = await waitFor(stats, (s) => s.started > beforeOnce.started); 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)); check('a node saying "off" plays a looping asset once', once.last?.loop === false, JSON.stringify(once));
await stop(); await stop();
await sleep(300);
await setNodeLoop(''); await setNodeLoop('');
await sleep(300);
// 34g. `Stop sound` silences it from inside the graph, without ending the run. // 34g. `Stop sound` silences it from inside the graph, without ending the run.
await mutate(` 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-stop', 'StopSound', 520, 0, { sound: soundId }))});
graph.nodes.push(${JSON.stringify(node('sn-key', 'OnKeyDown', 260, 220, { key: 's' }))}); 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'))});`); graph.edges.push(${JSON.stringify(edge('sn-e2', 'sn-key', 'exec', 'sn-stop', 'exec'))});`);
await sleep(300);
await play(); await play();
await waitFor(stats, (s) => s.playing > 0); await waitFor(stats, (s) => s.playing > 0);
await press('s'); await press('s');
@@ -231,7 +218,6 @@ export async function sound(t) {
JSON.stringify(silenced), JSON.stringify(silenced),
); );
await stop(); await stop();
await sleep(300);
// 34g-2. A voice belongs to the behavior playing it: destroy the object and its sounds go with it, // 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 // 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-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))},
${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))}, ${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))},
);`); );`);
await sleep(300);
await play(); await play();
await waitFor(stats, (s) => s.playing > 0); await waitFor(stats, (s) => s.playing > 0);
await press('d'); await press('d');
@@ -259,7 +244,6 @@ export async function sound(t) {
JSON.stringify(destroyed), JSON.stringify(destroyed),
); );
await stop(); await stop();
await sleep(300);
const setDetached = (value) => const setDetached = (value) =>
mutate(` mutate(`
@@ -267,7 +251,6 @@ export async function sound(t) {
const graph = p.assets.find((a) => a.id === 'sn-logic').graph; const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`); graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`);
await setDetached(true); await setDetached(true);
await sleep(300);
await play(); await play();
await waitFor(stats, (s) => s.playing > 0); await waitFor(stats, (s) => s.playing > 0);
await press('d'); await press('d');
@@ -280,14 +263,12 @@ export async function sound(t) {
JSON.stringify(detached), JSON.stringify(detached),
); );
await stop(); await stop();
await sleep(300);
check( check(
'ending the run silences a detached voice too', 'ending the run silences a detached voice too',
(await stats()).playing === 0, (await stats()).playing === 0,
JSON.stringify(await stats()), JSON.stringify(await stats()),
); );
await setDetached(false); await setDetached(false);
await sleep(300);
// 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file. // 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file.
const dropped = await evaluate(` const dropped = await evaluate(`
@@ -322,7 +303,6 @@ export async function sound(t) {
(o) => !['sn-box', 'sn-brain'].includes(o.id)); (o) => !['sn-box', 'sn-brain'].includes(o.id));
__gm.state.select(null); __gm.state.select(null);
__gm.lookAt(0, 0);`); __gm.lookAt(0, 0);`);
await sleep(300);
check( check(
'the step leaves nothing of its own behind', 'the step leaves nothing of its own behind',
(await assetsOfType('sound')).length === 0 && (await stats()).playing === 0, (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) { 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 playInfo = () => json('__gm.playInfo()');
const playFrom = () => json('__gm.playFrom()'); const playFrom = () => json('__gm.playFrom()');
/** The switch is opened by position: its caption is elided at the width it has. */ /** The switch is opened by position: its caption is elided at the width it has. */
const openPlayFrom = async () => { const openPlayFrom = async () => {
const at = await json('__gm.playFromAt()'); const at = await json('__gm.playFromAt()');
await click(at.x, at.y); await click(at.x, at.y);
await sleep(250);
}; };
const stats = () => json('__gm.sound.stats()'); const stats = () => json('__gm.sound.stats()');
const waitFor = async (read, done, tries = 25) => { const waitFor = async (read, done, tries = 25) => {
@@ -56,8 +70,7 @@ export async function loadScene(t) {
await press('Escape'); await press('Escape');
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');
await evaluate("__gm.showTab('scene')"); await showTab('scene');
await sleep(200);
const firstId = await json('__gm.state.project.activeSceneId'); const firstId = await json('__gm.state.project.activeSceneId');
const firstName = await json('__gm.state.activeScene.name'); const firstName = await json('__gm.state.activeScene.name');
@@ -100,7 +113,6 @@ export async function loadScene(t) {
props: { volume: 1, loop: true }, props: { volume: 1, loop: true },
file: ${JSON.stringify(file)}, file: ${JSON.stringify(file)},
});`); });`);
await sleep(400);
// 35b. Without a start scene the switch says the active scene – the two questions have the same // 35b. Without a start scene the switch says the active scene – the two questions have the same
// answer until someone answers them differently. // 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 // 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. // 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 evaluate("__gm.state.select({ kind: 'asset', assetId: 'ls-scene' })");
await sleep(200);
check( check(
'selecting an asset scrolls its row into the panel', 'selecting an asset scrolls its row into the panel',
await has('Level 2'), await has('Level 2'),
JSON.stringify({ row: await json('__gm.assetRow("ls-scene")'), panel: await json('__gm.assetsRect()') }), JSON.stringify({ row: await json('__gm.assetRow("ls-scene")'), panel: await json('__gm.assetsRect()') }),
); );
await clickText('Level 2', 'right'); await clickText('Level 2', 'right');
await sleep(250);
await clickText('Set as start scene'); await clickText('Set as start scene');
await sleep(300);
check( check(
'the scene’s context menu names it the start scene, and the switch follows', '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', (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. // and not the other was the inconsistency. The run itself is untouched by it.
const select = await json('__gm.sceneSelect()'); const select = await json('__gm.sceneSelect()');
await click(select.x, select.y); await click(select.x, select.y);
await sleep(250);
const rows = await json('__gm.menuRows()'); const rows = await json('__gm.menuRows()');
// The topmost hit, because the scene's name is on its row in the Assets panel as well. // 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); const entry = await locate('Level 2', true);
await click(entry.x, entry.y); await click(entry.x, entry.y);
await sleep(300);
const playing = await playInfo(); const playing = await playInfo();
check( check(
'the scene selector stays usable in play mode, and the run runs on', '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() }), JSON.stringify({ humming, now: await stats() }),
); );
await stop(); await stop();
await sleep(300);
// 35f. The switch is the way to run what you are working on instead – and it is remembered // 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. // between runs, because it says how you work rather than what the game does.
await openPlayFrom(); await openPlayFrom();
await clickText('Active scene'); await clickText('Active scene');
await sleep(300);
await play(); await play();
const fromActive = await playInfo(); const fromActive = await playInfo();
check( check(
@@ -226,10 +231,8 @@ export async function loadScene(t) {
JSON.stringify({ fromActive, switch: await playFrom() }), JSON.stringify({ fromActive, switch: await playFrom() }),
); );
await stop(); await stop();
await sleep(300);
await openPlayFrom(); await openPlayFrom();
await clickText('Start scene'); await clickText('Start scene');
await sleep(300);
check( check(
'the switch goes back to the start scene', 'the switch goes back to the start scene',
(await playFrom()).mode === 'start' && (await playFrom()).scene === 'Level 2', (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']); const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-sound']);
p.assets = p.assets.filter((a) => !gone.has(a.id)); p.assets = p.assets.filter((a) => !gone.has(a.id));
__gm.state.select(null);`); __gm.state.select(null);`);
await sleep(300);
check( check(
'the step leaves nothing of its own behind', 'the step leaves nothing of its own behind',
(await json('__gm.state.project.assets.filter((a) => a.id.startsWith("ls-")).length')) === 0 && (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)}), make(${JSON.stringify(logicId)}, ${JSON.stringify(assetId)}, ${JSON.stringify(boxId)}, 0, ${JSON.stringify(name)}),
); );
`); `);
await t.sleep(400);
return { return {
assetId, assetId,
@@ -93,7 +92,6 @@ export async function scaffoldBehavior(t, options) {
scene.objects = scene.objects.filter((o) => !objects.includes(o.id)); scene.objects = scene.objects.filter((o) => !objects.includes(o.id));
p.assets = p.assets.filter((a) => !assets.includes(a.id)); p.assets = p.assets.filter((a) => !assets.includes(a.id));
`); `);
await t.sleep(300);
return t.json(`{ return t.json(`{
objects: __gm.sceneObjects().filter((o) => ${JSON.stringify([boxId, logicId])}.includes(o.id)).length, 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, 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()'); const row = await t.json('__gm.tabRow()');
if (row.hidden.includes(tab)) { if (row.hidden.includes(tab)) {
await t.clickText('»'); await t.clickText('»');
await t.sleep(300);
} }
await t.clickText(TAB_LABELS[tab]); await t.clickText(TAB_LABELS[tab]);
await t.sleep(400);
} }
/** /**
@@ -144,11 +140,9 @@ export async function pickAssetType(t, label) {
let scrolled = false; let scrolled = false;
if (box && (!p || p.y > box.y + box.h - 4)) { 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.wheel(box.x + box.w / 2, box.y + box.h / 2, 300);
await t.sleep(250);
p = await t.locate(label, true); p = await t.locate(label, true);
scrolled = true; scrolled = true;
} }
await t.click(p.x, p.y); await t.click(p.x, p.y);
await t.sleep(250);
return { box, p, scrolled }; return { box, p, scrolled };
} }
+86 -12
View File
@@ -6,7 +6,17 @@
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { launchBrowser } from './browser.mjs'; 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. */ /** Generous on purpose: a slow navigation still finishes, a wedged chromium never does. */
const CDP_TIMEOUT_MS = 30_000; 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. */ /** 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)`)); 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. */ /** Like `locate`, but null instead of throwing – which a check for absence needs. */
const find = async (text, last = false) => const find = async (text, last = false) =>
(await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`)) || null; (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('mouseMoved', x, y);
await mouse('mousePressed', x, y, button, extra); await mouse('mousePressed', x, y, button, extra);
await mouse('mouseReleased', 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 * 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('mousePressed', x, y, 'left', { clickCount });
await mouse('mouseReleased', 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. */ /** Mouse wheel over (x, y); positive `dy` scrolls down. */
const wheel = async (x, y, dy) => { const wheel = async (x, y, dy) => {
await mouse('mouseMoved', x, y); await mouse('mouseMoved', x, y);
await mouse('mouseWheel', x, y, 'none', { deltaX: 0, deltaY: dy }); await mouse('mouseWheel', x, y, 'none', { deltaX: 0, deltaY: dy });
await sleep(150); await settle();
}; };
const clickText = async (text, button = 'left') => { const clickText = async (text, button = 'left') => {
const p = await locate(text); const p = await locate(text);
@@ -206,7 +251,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
await sleep(16); await sleep(16);
} }
await mouse('mouseReleased', x2, y2, button, extra); 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 sleep(16);
} }
await mouse('mouseReleased', x, y); await mouse('mouseReleased', x, y);
await sleep(200); await settle();
}; };
const keyEvent = (type, name, modifiers) => { const keyEvent = (type, name, modifiers) => {
@@ -243,6 +288,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
const press = async (name, modifiers = {}) => { const press = async (name, modifiers = {}) => {
await keyEvent('keyDown', name, modifiers); await keyEvent('keyDown', name, modifiers);
await keyEvent('keyUp', name, modifiers); await keyEvent('keyUp', name, modifiers);
await settle();
}; };
/** Hold a key for `ms` milliseconds (arrow keys while playing). */ /** Hold a key for `ms` milliseconds (arrow keys while playing). */
const holdKey = async (name, ms) => { const holdKey = async (name, ms) => {
@@ -267,14 +313,13 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
throw new Error( throw new Error(
`typing "${text}" did not arrive: focus is ${typed.tag}, it holds ${JSON.stringify(typed.value)}`, `typing "${text}" did not arrive: focus is ${typed.tag}, it holds ${JSON.stringify(typed.value)}`,
); );
await settle();
}; };
const undo = async () => { const undo = async () => {
await press('z', { ctrl: true }); await press('z', { ctrl: true });
await sleep(200);
}; };
const redo = async () => { const redo = async () => {
await press('z', { ctrl: true, shift: true }); await press('z', { ctrl: true, shift: true });
await sleep(200);
}; };
/** Selection, mode, save status and notice – the facts most checks look at. */ /** 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 asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`);
const world = () => json('__gm.world()'); const world = () => json('__gm.world()');
const entity = async (objectId) => (await world())?.entities.find((e) => e.objectId === objectId); 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 * 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. * scrub or a command would – for putting the scene into a known state between checks.
*/ */
const mutate = async (body) => { const mutate = async (body) => {
await evaluate(`(() => { ${body}; __gm.state.preview(); })()`); 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 () => { const play = async () => {
await clickText('▶ Play'); await clickText('▶ Play');
await sleep(300); await waitUntil("__gm.state.mode === 'play'", { label: 'the run started' });
await settle();
}; };
const stop = async () => { const stop = async () => {
await press('Escape'); 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 // 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, sleep,
evaluate, evaluate,
json, json,
settle,
waitUntil,
find, find,
has, has,
locate, locate,
@@ -368,6 +440,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
world, world,
entity, entity,
select, select,
showTab,
saved,
mutate, mutate,
play, play,
stop, stop,