Files
feuerwerk/tools/e2e/54-struct-types.mjs
T
adminandClaude Opus 5.5 8f9c250eb6
game-maker / gate (push) Successful in 3m29s
game-maker / suite (push) Successful in 16m29s
Lay patterns and sounds out on tracks and play them as a song #334
Patterns could only be heard one at a time, in their own tab or from
a behavior. A song lays patterns and sounds out on tracks along one
time axis and plays them through the scheduler of #332, the third
piece of the music tools of #330.

- New asset type song under the heading Songs, with four empty tracks,
  120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows
  in the inspector. Tracks, items and the loop are the asset's data,
  held to their rules on load and in every command.
- A track has a name, volume, pan, mute and solo. Each is heard on a
  bus of its own, so a change reaches what is sounding at once.
- A pattern item repeats its pattern for as long as it is, with a
  transpose and another instrument if wanted. A sound item plays its
  sound as a clip at its own speed, round and round, through a sound
  FX if wanted. Both have a volume on top of their track's.
- Cut back at the left end or split, an item starts as far into what
  it plays as the cut is: ticks into a pattern, seconds into a sound.
- The tab shows the heads with M, S, volume and pan, the ruler of the
  Pattern tab and the items, a pattern with its notes, a sound with
  its waveform, a missing asset red. Patterns and sounds are dragged
  in from the Assets panel. Body, right and left end move, lengthen
  and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits
  where playing starts, Delete, arrows. A double-click opens a
  pattern item in its tab.
- What is picked is a new selection of its own, so the inspector
  shows the item or the track, with a button to delete it.
- The scheduler plans clips beside notes. Where the playhead starts
  or jumps back inside a clip, it is taken up that far into its
  sound. A clip fades out at the end of its item or of the loop.
- The editor plays one piece at a time: a pattern started stops the
  song and the other way round.
- The rules are pure functions in core/music/song, editor/song/
  songView and songEdit.

Findings:
- The suites of 4ea752f and 2a3b90b were red at e2e 54: the Pattern
  pushed Struct below the fold of the + menu. The module now scrolls
  to it, as e2e 47 does for Vector art. It also left the menu open,
  which made e2e 55 fail behind it.
- The Assets panel only dragged assets a scene can place. Patterns
  and sounds are dragged now as well, and a scene says why it does
  not take one instead of calling every asset a scene.

Tests:
- song.test.ts (new, 14) and song-edit.test.ts (new, 9): model,
  round trip, sequence of patterns and clips, mute and solo, cuts,
  clips on the scheduler, moving, sizing, trimming, splitting,
  duplicating, hits, head parts. sound-library.test.ts +5: clip
  offset, end fade, pitched clip, track bus, peaks.
- e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and
  55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset
  types: ALL PASSED, 62 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests.
- Counter-checks: no taking up of a clip, no pattern offset, no clip
  end each fail 3, 1 and 1 tests.
- Probe on stein2: the tab with four tracks, before and while
  playing, looked at in two screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:28:25 +02:00

294 lines
12 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.
/**
* Struct assets as types (#253), with the real mouse: a Struct asset made from the + menu and its
* fields declared in the inspector, a behavior's struct variable typed by it, an `Expand` pointed at
* it through its Type setting – then a field of the asset renamed, and the new name has to show up
* in the variable, in a placed object's override and on the `Expand` port with its edge. "Make
* struct type" turns a struct's own fields into an asset; deleting the asset leaves the fields.
*
* Everything it adds carries an `st54` id or is an asset it made, and goes again at the end.
*/
import { inView, MAKE_OBJECT } from './lib/scaffold.mjs';
export async function structTypes(t) {
const {
check,
json,
evaluate,
mutate,
click,
clickText,
press,
typeText,
sleep,
settle,
shot,
select,
undo,
} = t;
const inOverlay = async (text) => {
const box = await json('__gm.overlayRect()');
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
return (
hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null
);
};
const press_ = async (at, what) => {
if (!at) throw new Error(`nothing to click: ${what}`);
await click(at.x, at.y);
await sleep(150);
};
/** A menu entry, always the last drawn: the panels behind a menu carry the same words. */
const pick = async (label) => press_(await json(`__gm.locate(${JSON.stringify(label)}, true)`), label);
/**
* Types into the text field showing `text`, replacing it. The rightmost hit: the inspector is the
* right-hand panel, and an open graph shows the same names on its ports.
*/
const retype = async (text, next) => {
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
await press_(hits.sort((a, b) => b.x - a.x)[0], text);
await press('a', { ctrl: true });
await typeText(next);
await press('Enter');
await sleep(150);
};
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}) ?? null`);
const chest = () => asset('st54-chest');
const variable = async (key) => (await chest()).variables.find((v) => v.key === key);
const override = () =>
json("__gm.state.activeScene.objects.find((o) => o.id === 'st54-obj')?.overrides.drop ?? null");
const expand = async () => (await chest()).graph.nodes.find((n) => n.id === 'st54-expand');
const edgeFrom = async () =>
(await chest()).graph.edges.find((e) => e.id === 'st54-edge')?.from.port ?? null;
// A behavior with two structs holding their own fields, an Expand and a Log in its graph, and one
// placed object overriding `drop`.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.push({
id: 'st54-chest',
type: 'behavior',
name: 'Chest 54',
props: { enabled: true, runInEditor: false, drop: { Title: 'Gem' }, bag: { Gold: 5 } },
variables: [
{ key: 'drop', label: 'drop', kind: 'struct', default: { Title: '' }, fields: [{ key: 'Title', kind: 'text' }] },
{ key: 'bag', label: 'bag', kind: 'struct', default: { Gold: 0 }, fields: [{ key: 'Gold', kind: 'number' }] },
],
graph: {
nodes: [
{ id: 'st54-expand', type: 'ExpandStruct', x: 0, y: 0, params: {} },
{ id: 'st54-log', type: 'Log', x: 240, y: 0, params: {} },
],
edges: [],
},
});
${MAKE_OBJECT}
const placed = make('st54-obj', 'st54-chest', '', 400, 'Chest 54');
placed.overrides = { drop: { Title: 'Ruby' } };
scene.objects.push(placed);
`);
// 54a. The + menu makes a Struct; its inspector declares fields as a Variables asset declares
// variables, and each field has a default.
await t.showTab('scene');
await clickText('+');
const offered = (await json('__gm.menuRows()')) ?? [];
// Struct sits below the fold of the menu since the Pattern (#333); the lowest hit between the
// box's sides is the entry, not the tab above it.
await inView(
t,
'__gm.overlayRect()',
`(() => {
const box = __gm.overlayRect();
const hits = (__gm.locateAll('Struct') ?? []).filter(
(hit) => hit.x >= box.x && hit.x <= box.x + box.w && hit.y >= box.y,
);
return hits.sort((a, b) => a.y - b.y).at(-1) ?? null;
})()`,
);
const entry = await inOverlay('Struct');
check('the + menu offers Struct', offered.includes('Struct') && Boolean(entry), JSON.stringify(offered));
if (!entry) return;
await press_(entry, 'Struct');
await settle();
const lootId = (
await json("__gm.state.project.assets.filter((a) => a.type === 'struct').map((a) => a.id)")
).at(-1);
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(lootId)}).name = 'Loot 54'`);
const row = await json(`__gm.assetRow(${JSON.stringify(lootId)})`);
await press_(row, 'the Loot 54 row');
check(
'a new Struct selects itself and says it has no fields',
(await t.has('FIELDS')) && (await t.has('+ Field')) && (await t.has('No fields yet.')),
);
await clickText('+ Field');
await pick('Text');
await retype('field1', 'Title');
await clickText('+ Field');
await pick('Number');
await retype('field1', 'Amount');
await press_(await json("__gm.inspectorField('Title')"), 'Title value');
await typeText('Coin');
await press('Enter');
await press_(await json("__gm.inspectorField('Amount')"), 'Amount value');
await press('a', { ctrl: true });
await typeText('7');
await press('Enter');
await sleep(150);
const loot = await asset(lootId);
check(
'+ Field, a new name and a value give the struct its fields and their defaults',
JSON.stringify(loot.variables.map((v) => `${v.key}:${v.kind}`)) === '["Title:text","Amount:number"]' &&
JSON.stringify(loot.props) === '{"Title":"Coin","Amount":7}',
JSON.stringify({ variables: loot.variables.map((v) => v.key), props: loot.props }),
);
await shot('01-struct-fields');
// 54b. A struct variable picks the type under its head. It takes the type's fields, keeps what
// fits of its value and cannot grow fields of its own any more.
await select({ kind: 'asset', assetId: 'st54-chest' });
await sleep(150);
const addsBefore = ((await json("__gm.locateAll('+ Field')")) ?? []).length;
const typeRows = ((await json("__gm.locateAll('Own fields')")) ?? []).sort((a, b) => a.y - b.y);
check(
'each struct variable has a Type row reading "Own fields"',
typeRows.length === 2,
JSON.stringify(typeRows),
);
await press_(typeRows[0], 'Type of drop');
await pick('Loot 54');
const drop = await variable('drop');
const props = (await chest()).props;
check(
'picking Loot 54 gives drop its fields, keeps "Gem" and fills Amount with the default 7',
drop.structType === lootId &&
JSON.stringify(drop.fields.map((f) => f.key)) === '["Title","Amount"]' &&
JSON.stringify(props.drop) === '{"Title":"Gem","Amount":7}',
JSON.stringify({ drop, value: props.drop }),
);
check(
'and the placed override takes the shape as well',
JSON.stringify(await override()) === '{"Title":"Ruby","Amount":7}',
JSON.stringify(await override()),
);
const addsAfter = ((await json("__gm.locateAll('+ Field')")) ?? []).length;
check(
'a typed struct offers no + Field, its row names the type',
addsBefore === 2 && addsAfter === 1 && (await t.has('Loot 54')),
JSON.stringify({ addsBefore, addsAfter }),
);
await shot('02-typed-variable');
// 54c. Expand's Type setting points it at the asset: its ports are the fields.
await select({ kind: 'node', assetId: 'st54-chest', nodeId: 'st54-expand' });
await sleep(150);
await press_(await json("__gm.inspectorField('Type')"), 'Type of Expand');
// The asset picker names the kind after each asset.
await pick('Loot 54 (struct)');
const ports = (await expand()).ports ?? [];
check(
'Expand with the type has one output per field, and its fields are not edited there',
JSON.stringify(ports.map((p) => p.key)) === '["Title","Amount"]' &&
(await t.has('The fields of Loot 54. Change them there.')) &&
!(await t.has('+ Field')),
JSON.stringify(ports),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'st54-chest').graph;
graph.edges.push({ id: 'st54-edge', from: { node: 'st54-expand', port: 'Amount' }, to: { node: 'st54-log', port: 'message' } });
`);
await shot('03-typed-expand');
// 54d. Renaming a field on the asset reaches every place of the type in one step, values kept.
await select({ kind: 'asset', assetId: lootId });
await sleep(150);
await retype('Amount', 'Count');
const renamed = {
fields: (await variable('drop')).fields.map((f) => f.key),
value: (await chest()).props.drop,
override: await override(),
ports: ((await expand()).ports ?? []).map((p) => p.key),
edge: await edgeFrom(),
};
check(
'renaming Amount to Count reaches the variable, its value, the override, the port and its edge',
JSON.stringify(renamed) ===
JSON.stringify({
fields: ['Title', 'Count'],
value: { Title: 'Gem', Count: 7 },
override: { Title: 'Ruby', Count: 7 },
ports: ['Title', 'Count'],
edge: 'Count',
}),
JSON.stringify(renamed),
);
await undo();
const back = {
fields: (await variable('drop')).fields.map((f) => f.key),
value: (await chest()).props.drop,
override: await override(),
edge: await edgeFrom(),
};
check(
'one undo takes the rename back everywhere',
JSON.stringify(back) ===
JSON.stringify({
fields: ['Title', 'Amount'],
value: { Title: 'Gem', Amount: 7 },
override: { Title: 'Ruby', Amount: 7 },
edge: 'Amount',
}),
JSON.stringify(back),
);
// 54e. "Make struct type" turns the fields bag holds by itself into a Struct asset.
await select({ kind: 'asset', assetId: 'st54-chest' });
await sleep(150);
await press_(await json("__gm.locate('Own fields')"), 'Type of bag');
await pick('Make struct type');
const bag = await variable('bag');
const made = await asset(bag.structType ?? '');
check(
'Make struct type makes a Struct asset of bag, its value becoming the defaults',
made?.type === 'struct' &&
made.name === 'bag' &&
JSON.stringify(made.props) === '{"Gold":5}' &&
JSON.stringify((await chest()).props.bag) === '{"Gold":5}',
JSON.stringify({ bag, made }),
);
// 54f. Deleting the type asks first and says what happens; the places keep the fields.
const lootRow = await json(`__gm.assetRow(${JSON.stringify(lootId)})`);
await click(lootRow.x, lootRow.y, 'right');
await sleep(150);
await pick('Delete…');
const detail = '1 struct(s) and 1 node(s) use it and keep its fields as their own.';
check('the delete dialog counts the places using the type', await t.has(detail));
await shot('04-delete-dialog');
await pick('Delete');
const kept = await variable('drop');
check(
'after deleting, drop keeps Title and Amount and its value',
(await asset(lootId)) === null &&
JSON.stringify(kept.fields.map((f) => f.key)) === '["Title","Amount"]' &&
JSON.stringify((await chest()).props.drop) === '{"Title":"Gem","Amount":7}',
JSON.stringify(kept),
);
await select({ kind: 'asset', assetId: 'st54-chest' });
await sleep(150);
check(
'and its Type row reads "Own fields" again, with + Field back',
((await json("__gm.locateAll('+ Field')")) ?? []).length === 1 && (await t.has('Own fields')),
);
// Clean up.
await evaluate('__gm.state.select(null)');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'st54-obj');
const made = ${JSON.stringify(made?.id ?? '')};
p.assets = p.assets.filter((a) => a.id !== 'st54-chest' && a.id !== made && a.id !== ${JSON.stringify(lootId)});
`);
}