Files
feuerwerk/tools/e2e/34-sound.mjs
T
admin 7e5003b590
game-maker / suite (push) Successful in 6m48s
A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
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.
2026-09-16 08:08:10 +02:00

312 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
/**
* Sound assets (#45): uploaded like a typeface – picked from what the server holds through
* "+ → Sound", or dropped onto the editor – and played by a graph through `Play sound`. Nothing
* wears a sound and nothing places it; the asset says how loud it is and whether it loops, and the
* node scales the one and can overrule the other in either direction.
*
* Nothing is ever *heard* here: CI runs the browser muted (see tools/lib/browser.mjs). The witness
* is `__gm.sound.stats()` – voices started, stopped and in flight – the way `renderStats` is the
* witness for a repaint that did not happen. No pixel check could see any of this.
*
* The seed puts two generated WAVs on the server without making assets of them: a 0.25 s blip and
* a 0.75 s drone, told apart by their exact size rather than by their name.
*/
const LANE = LANES.sound;
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, showTab } = t;
const { select } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`);
const stats = () => json('__gm.sound.stats()');
/** A voice starts only once its bytes have been decoded, so what it changes is awaited. */
const waitFor = async (read, done, tries = 25) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
await sleep(150);
value = await read();
}
return value;
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
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())`);
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
const drone = stored.find((item) => item.bytes === DRONE_BYTES);
check(
'the server holds the two seeded sounds, named by their content',
Boolean(blip && drone) && /^[a-f0-9]{64}\.wav$/.test(blip.file),
JSON.stringify({ blip: blip?.file, drone: drone?.file, count: stored.length }),
);
if (!blip || !drone) return;
check(
'the listing carries the name each file was uploaded under',
blip.name === 'blip.wav' && drone.name === 'drone.wav',
JSON.stringify({ blip: blip.name, drone: drone.name }),
);
// 34b. "+ → Sound" offers them; a sound is picked rather than created empty, like a typeface.
await clickText('+');
await pickAssetType(t, 'Sound');
await sleep(500);
const picker = await json('__gm.soundPicker()');
check(
'the + menu opens the sound picker on what the server holds, none of it in the project yet',
Boolean(picker) && picker.files.includes(blip.file) && picker.used.length === 0,
JSON.stringify(picker),
);
check(
'a row nobody uses yet is captioned with the name the file was uploaded under',
picker.captions[picker.files.indexOf(blip.file)] === 'blip.wav',
JSON.stringify(picker.captions),
);
await shot('34-sound-picker');
// 34c. The ▶ at a row's left is a preview: it plays that file without choosing it.
const playAt = await json(`__gm.soundPickerPlay(${JSON.stringify(drone.file)})`);
await click(playAt.x, playAt.y);
const previewed = await waitFor(stats, (s) => s.started > 0);
const afterPreview = await json('__gm.soundPicker()');
check(
'pressing a row’s ▶ plays that file and picks nothing',
previewed.started === 1 &&
previewed.last?.file === drone.file &&
afterPreview.played === drone.file &&
afterPreview.selected === null,
JSON.stringify({ previewed, selected: afterPreview.selected, played: afterPreview.played }),
);
const row = await json(`__gm.soundPickerRow(${JSON.stringify(blip.file)})`);
await click(row.x, row.y);
await clickText('Use sound');
const sounds = await assetsOfType('sound');
check(
'picking a row makes a sound asset of that file, named after the file rather than "Sound"',
sounds.length === 1 && sounds[0].file === blip.file && sounds[0].name === 'blip',
JSON.stringify(sounds),
);
if (sounds.length !== 1) return;
const soundId = sounds[0].id;
check(
'a fresh sound asset is full volume and one-shot',
sounds[0].props.volume === 1 && sounds[0].props.loop === false,
JSON.stringify(sounds[0].props),
);
// Closing the picker silences its preview; nothing it started may outlive the dialog.
check(
'closing the picker leaves nothing playing',
(await stats()).playing === 0,
JSON.stringify(await stats()),
);
// 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 });
const volumeRow = await json("__gm.inspectorField('Volume', true)");
const loopRow = await json("__gm.inspectorField('Loop', true)");
check(
'a sound asset has a Volume row and a Loop row',
Boolean(volumeRow) && Boolean(loopRow),
JSON.stringify({ volumeRow, loopRow }),
);
await shot('34-sound-asset');
// 34e. A graph plays it. The behavior sits in its own lane; the node's volume is a factor on the
// asset's, so 0.5 × 0.4 has to reach the library as 0.2 rather than as either of the two.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.volume = 0.4;
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, {
id: 'sn-logic',
name: 'Noisy',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify({
nodes: [
node('sn-start', 'OnStart', 0, 0),
node('sn-play', 'PlaySound', 260, 0, { sound: soundId, volume: 0.5, loop: '' }),
],
edges: [edge('sn-e1', 'sn-start', 'exec', 'sn-play', 'exec')],
})},
});
p.assets.unshift(asset);
${MAKE_OBJECT}
scene.objects.push(
make('sn-box', 'crate', '', ${LANE}, 'Noisy box'),
make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'),
);
__gm.lookAt(0, ${LANE});`);
await evaluate('__gm.sound.stopAll()');
const before = await stats();
await play();
const played = await waitFor(stats, (s) => s.started > before.started);
check(
'a graph playing a sound reaches the library, at the asset volume scaled by the node’s',
played.started === before.started + 1 &&
played.last?.file === blip.file &&
Math.abs(played.last.volume - 0.2) < 0.001 &&
played.last.loop === false,
JSON.stringify(played),
);
await stop();
// 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 play();
const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true);
check(
'the asset’s own loop flag reaches the library even though the node never asked for one',
looping.playing >= 1 && looping.last?.loop === true,
JSON.stringify(looping),
);
// Well past the blip's own 0.25 s, so a one-shot would have ended by itself here.
await sleep(700);
const stillLooping = await stats();
await stop();
const afterStop = await stats();
check(
'a loop keeps playing while the run is on and ends with it',
stillLooping.playing >= 1 && afterStop.playing === 0,
JSON.stringify({ stillLooping, afterStop }),
);
// 34f-2. The node overrules the asset in both directions – the reason `loop` is a choice of three
// and not a checkbox: music has to be playable as a one-shot.
const setNodeLoop = (value) =>
mutate(`
const p = __gm.state.project;
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');
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 setNodeLoop('');
// 34g. `Stop sound` silences it from inside the graph, without ending the run.
await mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
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 play();
await waitFor(stats, (s) => s.playing > 0);
await press('s');
const silenced = await waitFor(stats, (s) => s.playing === 0);
check(
'Stop sound silences a looping sound while the run carries on',
silenced.playing === 0 && (await json('__gm.playInfo()')).playing === true,
JSON.stringify(silenced),
);
await stop();
// 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
// sound of an object that destroys itself.
await mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(
${JSON.stringify(node('sn-key2', 'OnKeyDown', 260, 440, { key: 'd' }))},
${JSON.stringify(node('sn-owner', 'Owner', 260, 620))},
${JSON.stringify(node('sn-destroy', 'Destroy', 520, 440))},
);
graph.edges.push(
${JSON.stringify(edge('sn-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))},
${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))},
);`);
await play();
await waitFor(stats, (s) => s.playing > 0);
await press('d');
const destroyed = await waitFor(stats, (s) => s.playing === 0);
check(
'destroying the object silences the looping sound its behavior was playing',
destroyed.playing === 0 && (await json('__gm.playInfo()')).playing === true,
JSON.stringify(destroyed),
);
await stop();
const setDetached = (value) =>
mutate(`
const p = __gm.state.project;
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 play();
await waitFor(stats, (s) => s.playing > 0);
await press('d');
// Long enough that the reaping frame has been and gone – a voice still here now is here on purpose.
await sleep(600);
const detached = await stats();
check(
'a detached voice is nobody’s and plays on after the object that started it is gone',
detached.playing >= 1,
JSON.stringify(detached),
);
await stop();
check(
'ending the run silences a detached voice too',
(await stats()).playing === 0,
JSON.stringify(await stats()),
);
await setDetached(false);
// 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file.
const dropped = await evaluate(`
(async () => {
const bytes = await fetch('/game-maker/data/sounds/${drone.file}').then((r) => r.arrayBuffer());
const file = new File([bytes], 'drone.wav', { type: 'audio/wav' });
const data = new DataTransfer();
data.items.add(file);
const canvas = document.querySelector('canvas');
canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }));
return file.size;
})()`);
await waitFor(
() => assetsOfType('sound'),
(list) => list.length === 2,
);
const both = await assetsOfType('sound');
check(
'a dropped sound is uploaded and becomes an asset named after the file',
dropped === DRONE_BYTES &&
both.length === 2 &&
both.some((a) => a.file === drone.file && a.name === 'drone'),
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
);
// 34i. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`
const gone = new Set(['sn-logic', ...${JSON.stringify(both.map((a) => a.id))}]);
const project = __gm.state.project;
project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
(o) => !['sn-box', 'sn-brain'].includes(o.id));
__gm.state.select(null);
__gm.lookAt(0, 0);`);
check(
'the step leaves nothing of its own behind',
(await assetsOfType('sound')).length === 0 && (await stats()).playing === 0,
JSON.stringify(await assetsOfType('sound')),
);
}