257 lines
11 KiB
JavaScript
257 lines
11 KiB
JavaScript
/**
|
||
* A variable of type struct: the inspector declares its fields and gives each one a value in the
|
||
* same row, and writing one field keeps the others – the trap a shared snapshot would fall into.
|
||
*/
|
||
export async function structVariable(t) {
|
||
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, select, undo } = t;
|
||
|
||
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
|
||
|
||
/** A kind menu entry, always the last drawn one: the panels behind carry those words too. */
|
||
const pickKind = async (label) => {
|
||
const at = await json(`__gm.locate(${JSON.stringify(label)}, true)`);
|
||
await click(at.x, at.y);
|
||
};
|
||
const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece");
|
||
/** Left edge of each row's control – one column grid means one number, in the browser's own font. */
|
||
const leftsOf = (labels) =>
|
||
json(`[${labels.map((l) => `__gm.inspectorField(${JSON.stringify(l)})?.left ?? null`).join(',')}]`);
|
||
const oneColumn = (xs) => xs.every((x) => x !== null && x === xs[0]);
|
||
|
||
// A throwaway behavior carrying one struct variable; removed again at the end of the step.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push({
|
||
id: 'structy',
|
||
type: 'behavior',
|
||
name: 'Structy',
|
||
props: { enabled: true, runInEditor: false, piece: { x: 30, tag: 'tee' } },
|
||
variables: [
|
||
{
|
||
key: 'piece',
|
||
label: 'piece',
|
||
kind: 'struct',
|
||
default: { x: 0, tag: '' },
|
||
fields: [
|
||
{ key: 'x', kind: 'number' },
|
||
{ key: 'tag', kind: 'text' },
|
||
],
|
||
},
|
||
],
|
||
graph: { nodes: [], edges: [] },
|
||
});
|
||
__gm.state.select({ kind: 'asset', assetId: 'structy' });
|
||
`);
|
||
|
||
// 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);
|
||
check('and a row for each declared field', (await find('x')) !== null && (await find('tag')) !== null);
|
||
check(
|
||
'whose value is shown, not a summary of the bundle',
|
||
(await find('30')) !== null && (await find('30.000')) === null,
|
||
);
|
||
const assetLefts = await leftsOf(['Name', 'Run in editor', 'x', 'tag']);
|
||
check(
|
||
'a field value starts on the same column as every other row',
|
||
oneColumn(assetLefts),
|
||
JSON.stringify(assetLefts),
|
||
);
|
||
await shot('01-struct-rows');
|
||
|
||
// 22b. Writing one field keeps the others. Two edits in a row without a rebuild in between is
|
||
// exactly where handlers sharing one snapshot of the bundle would revive the first value.
|
||
const xField = await json("__gm.inspectorField('x')");
|
||
await click(xField.x, xField.y);
|
||
await typeText('42');
|
||
await press('Enter');
|
||
check('typing in a field writes that field', (await piece()).x === 42, JSON.stringify(await piece()));
|
||
|
||
const tagField = await json("__gm.inspectorField('tag')");
|
||
await click(tagField.x, tagField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('ell');
|
||
await press('Enter');
|
||
check(
|
||
'and the next field edited keeps the one before it',
|
||
JSON.stringify(await piece()) === '{"x":42,"tag":"ell"}',
|
||
JSON.stringify(await piece()),
|
||
);
|
||
|
||
// 22c. One undo step per field.
|
||
await undo();
|
||
check('undo takes back just the last field', (await piece()).tag === 'tee' && (await piece()).x === 42);
|
||
await undo();
|
||
check('and the one before it', (await piece()).x === 30, JSON.stringify(await piece()));
|
||
|
||
// 22d. Adding a field reaches the declaration and the value at once.
|
||
await clickText('+ Field');
|
||
await pickKind('Boolean');
|
||
await sleep(250);
|
||
const grown = await piece();
|
||
check('+ Field grows the bundle', 'field1' in grown && grown.field1 === false, JSON.stringify(grown));
|
||
check('and the new field has a row', (await find('field1')) !== null);
|
||
await shot('02-field-added');
|
||
await undo();
|
||
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.
|
||
const nameField = await json("__gm.locate('x')");
|
||
await click(nameField.x, nameField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('column');
|
||
await press('Enter');
|
||
check(
|
||
'renaming a field keeps its value',
|
||
JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}',
|
||
JSON.stringify(await piece()),
|
||
);
|
||
|
||
// 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');
|
||
// "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);
|
||
check(
|
||
'+ Field ▸ List gives the bundle an empty list',
|
||
Array.isArray((await piece()).field1),
|
||
JSON.stringify(await piece()),
|
||
);
|
||
const itemsButton = await find('0 items');
|
||
check('and that field reads as an item count', itemsButton !== null);
|
||
|
||
await click(itemsButton.x, itemsButton.y);
|
||
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);
|
||
|
||
// 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');
|
||
check(
|
||
'the menu of a list in a struct field leaves the struct kind out',
|
||
(await find('Number')) !== null && (await find('Struct')) === null,
|
||
JSON.stringify((await json('__gm.list.info()')).offers),
|
||
);
|
||
await pickKind('Number');
|
||
await sleep(250);
|
||
check(
|
||
'and an item added there leaves the neighbouring fields alone',
|
||
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":[0]}',
|
||
JSON.stringify(await piece()),
|
||
);
|
||
await shot('03-list-field');
|
||
await undo();
|
||
await undo();
|
||
check(
|
||
'undo takes the list field back out of the bundle',
|
||
!('field1' in (await piece())),
|
||
JSON.stringify(await piece()),
|
||
);
|
||
|
||
await mutate('__gm.state.openList(null)');
|
||
|
||
// 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
|
||
// shows (the asset's values) rather than from the empty form of the declaration.
|
||
const placed = () => json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece");
|
||
await mutate(`
|
||
__gm.state.activeScene.objects.push({
|
||
id: 'ostruct',
|
||
assetId: 'structy',
|
||
name: 'Placed structy',
|
||
position: { x: 0, y: 0, z: 0 },
|
||
editorOffset: { x: 0, y: 0 },
|
||
overrides: {},
|
||
parentId: '',
|
||
instanceEdits: {},
|
||
});
|
||
`);
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: 'ostruct' });
|
||
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
|
||
// field with its kind mark, its name and its value. What is missing is the shape editing –
|
||
// a placed object overrides values, the declaration stays with the asset that owns it.
|
||
const bundleMark = await find('⊞');
|
||
const headName = await find('piece');
|
||
const fieldMark = await find('#');
|
||
const fieldName = await find('column');
|
||
const fieldValue = await json("__gm.inspectorField('column')");
|
||
check(
|
||
'the bundle is a head above its fields, marked as a struct',
|
||
bundleMark !== null &&
|
||
headName !== null &&
|
||
fieldName !== null &&
|
||
bundleMark.x < headName.x &&
|
||
headName.y < fieldName.y,
|
||
JSON.stringify({ bundleMark, headName, fieldName }),
|
||
);
|
||
check(
|
||
'and a field row runs kind mark, name, value',
|
||
fieldMark !== null &&
|
||
fieldName !== null &&
|
||
fieldMark.x < fieldName.x &&
|
||
fieldName.x < fieldValue.x &&
|
||
Math.abs(fieldMark.y - fieldName.y) < 4,
|
||
JSON.stringify({ fieldMark, fieldName, fieldValue }),
|
||
);
|
||
check('the shape itself cannot be changed from here', (await find('+ Field')) === null);
|
||
const placedLefts = await leftsOf(['Name', 'Run in editor', 'Offset X', 'column', 'tag']);
|
||
check(
|
||
'and its values share the column of position, offset and the switches',
|
||
oneColumn(placedLefts) && placedLefts[0] === assetLefts[0],
|
||
JSON.stringify({ placedLefts, asset: assetLefts[0] }),
|
||
);
|
||
|
||
// Typing over a name has to do nothing: a Label is not a TextField, and the declaration is
|
||
// the asset's. Plain letters are safe here – the only shortcuts are modified ones and Delete.
|
||
const declaration = () =>
|
||
json(
|
||
"(() => { const v = __gm.state.project.assets.find((a) => a.id === 'structy').variables[0];" +
|
||
' return `${v.key}:${v.fields.map((f) => f.key).join(",")}`; })()',
|
||
);
|
||
const before = await declaration();
|
||
// A missing label would throw here and kill the step; clicking nowhere keeps it a plain failure.
|
||
for (const cell of [headName, fieldName]) {
|
||
await click(cell?.x ?? 0, cell?.y ?? 0);
|
||
await typeText('renamed');
|
||
await press('Enter');
|
||
}
|
||
await sleep(200);
|
||
check('typing over the names of a placed bundle renames nothing', (await declaration()) === before, before);
|
||
|
||
const placedField = await json("__gm.inspectorField('column')");
|
||
await click(placedField.x, placedField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('7');
|
||
await press('Enter');
|
||
check(
|
||
'the first override of one field keeps the asset values of the others',
|
||
JSON.stringify(await placed()) === '{"column":7,"tag":"tee"}',
|
||
JSON.stringify(await placed()),
|
||
);
|
||
await shot('04-object-override');
|
||
|
||
await undo();
|
||
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,
|
||
JSON.stringify(await placed()),
|
||
);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => a.id !== 'structy');
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== 'ostruct');
|
||
__gm.state.select(null);
|
||
__gm.state.preview();
|
||
`);
|
||
const left = await json(
|
||
'__gm.state.project.assets.filter((a) => a.id === "structy").length +' +
|
||
' __gm.state.activeScene.objects.filter((o) => o.id === "ostruct").length',
|
||
);
|
||
check('the step leaves the fixture as it found it', left === 0, String(left));
|
||
}
|