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 of4ea752fand2a3b90bwere 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>
294 lines
12 KiB
JavaScript
294 lines
12 KiB
JavaScript
/**
|
||
* 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)});
|
||
`);
|
||
}
|