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