A struct used to declare its fields wherever it stood. The same shape in two variables meant typing it twice, and Pack/Expand copied it once without staying in sync. A Struct asset now holds the shape in one place, and every struct that picks it as its type follows it. - New asset type "Struct" (not placeable, group Structs, own icon). The inspector shows its fields with name, kind and default value, and "Open" shows them in the variables tab. - Struct variables and struct fields get a Type row: own fields, any Struct asset, or "Make struct type". With a type the fields come from the asset and cannot be added, renamed or removed there. The values stay per variable, overrides on placed objects still work. - Pack and Expand get the setting Type. With a type their ports are the fields of the asset and read-only in the inspector. - A change to the asset reaches every typed place in one undo step (syncStructTypes). A new field arrives with its default. A removed field goes along with its port, edge and typed value. A renamed field keeps its value in variables, overrides and on ports with edges, also through nested types. - Undo for these edits records plain assignments (Recorder), because the reach of a change is not known in advance. - Cycles such as Loot in Loot, also through another type, are refused and not offered in the picker. - Deleting the asset names how many places use it. Afterwards each place keeps its fields as its own. - Loading reshapes a stale copy in a project file to its type. - "Make struct type" creates an asset from an existing struct and links the place to it. Differs from the plan: - A changed default value applies to new fields and newly typed places only. Existing values stay. - Removing a single field shows no warning. Undo brings it back. Typed lists are left for #254. Lists stay dynamic for now. Found by e2e: after deleting or renaming the type the inspector did not rebuild and kept showing the old name. Fixed, the check was red before. Tests: - ./check.sh: 182 files, 2661 tests, CHECK EXIT 0 - GM_STEPS=1,4,21,22,54: ALL PASSED, 176 checks - 14 unit tests in tests/core/struct-types.test.ts - New e2e module 54 with 14 checks, real mouse. Module 04 counts 23 asset types. - Counter-probes: without the rename pass 4 unit tests fail, without reshaping values 8, without the cycle rule 1. In the browser, without spreading changes and with + Field despite a type, 3 checks fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
11 KiB
JavaScript
281 lines
11 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 { 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()')) ?? [];
|
||
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 Pack/Expand 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)});
|
||
`);
|
||
}
|