/** * 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, findIcon, shot, click, clickText, drag, mouse, 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 items are drawn inline under it (#252), and its // title names the field inside the bundle – not the whole bundle. await clickText('+ Field'); // "List" is also a word elsewhere on screen, 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()), ); check('and that field reads as an item count', (await find('0 items')) !== null); const fieldList = async () => (await json(`__gm.values.lists('inspector')`)).find((l) => l.title.endsWith('· field1')) ?? null; const drawn = await fieldList(); check( 'whose items are drawn inline, titled by the field inside the bundle', drawn !== null, JSON.stringify(drawn), ); // Nothing bounds the depth any more (#239): a list in a struct field offers what any list does. const head = await json(`__gm.values.headAt('inspector', ${JSON.stringify(drawn?.title ?? '')})`); await click(head.add.x, head.add.y); check( 'the menu of a list in a struct field offers every kind, the struct included', (await find('Number')) !== null && (await find('Struct')) !== null && (await fieldList()).offers.includes('list'), JSON.stringify((await fieldList()).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]}' && (await fieldList()).rows === 1, 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()), ); // 22f-ii. A field may be a struct again (#239): a head row with a `+ Field` of its own, and its // fields one indent step further in. Both menus with real clicks. const declaredLast = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').variables[0].fields.at(-1)"); await clickText('+ Field'); await pickKind('Struct'); await sleep(250); check( '+ Field ▸ Struct gives the bundle an empty struct', JSON.stringify((await piece()).field1) === '{}', JSON.stringify(await piece()), ); // The nested `+ Field` is drawn after the head's, so it is the last one. const nestedAdd = await json(`__gm.locate('+ Field', true)`); const headAdd = await json(`__gm.locate('+ Field')`); check( 'and that struct has its own + Field', nestedAdd.y > headAdd.y, JSON.stringify({ nestedAdd, headAdd }), ); await click(nestedAdd.x, nestedAdd.y); await pickKind('Number'); await sleep(250); check( 'which adds a field one level down, in the value', JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":{"field1":0}}', JSON.stringify(await piece()), ); check( 'and in the declaration', JSON.stringify(await declaredLast()) === '{"key":"field1","kind":"struct","fields":[{"key":"field1","kind":"number"}]}', JSON.stringify(await declaredLast()), ); const outerName = await json(`__gm.locate('field1')`); const innerName = await json(`__gm.locate('field1', true)`); check( 'its field sits below it, one indent step further in', innerName.y > outerName.y && innerName.x > outerName.x, JSON.stringify({ outerName, innerName }), ); await shot('03b-nested-struct'); await undo(); await undo(); check('undo takes both back out', !('field1' in (await piece())), JSON.stringify(await piece())); // 22f-iii. Reordering by dragging the mark (#310): a variable past a struct of several rows, and a // field among the fields of its struct. A drop just above a mark lands in front of its row. await mutate(` const asset = __gm.state.project.assets.find((a) => a.id === 'structy'); asset.variables.push({ key: 'count', label: 'count', kind: 'number', default: 0 }); asset.props.count = 5; `); const order = () => json( "(() => { const v = __gm.state.project.assets.find((a) => a.id === 'structy').variables;" + " return v.map((d) => d.key).join(',') + '|' + v[v.findIndex((d) => d.key === 'piece')].fields.map((f) => f.key).join(','); })()", ); const dragMark = async (from, onto) => { const grip = await json(`__gm.inspectorMark(${JSON.stringify(from)})`); const target = await json(`__gm.inspectorMark(${JSON.stringify(onto)})`); await drag(grip.x, grip.y, target.x, target.y - 6); }; const count = await json('__gm.inspectorMark("count")'); await mouse('mouseMoved', count.x, count.y); await mouse('mouseMoved', count.x + 1, count.y); await sleep(50); const lit = await json('__gm.inspectorMark("count")'); check( 'a mark that drags shows its dots, lit under the pointer', count.dots === 'idle' && lit.dots === 'lit', `${count.dots} -> ${lit.dots}`, ); await dragMark('count', 'piece'); check( 'dragging a variable by its mark puts it in front of the struct, fields and all', (await order()) === 'count,piece|column,tag', await order(), ); await dragMark('tag', 'column'); check( 'a field moves among the fields of its struct', (await order()) === 'count,piece|tag,column', await order(), ); check( 'and the values stay with their names', JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}', ); await undo(); await undo(); check('each move is one undo step', (await order()) === 'piece,count|column,tag', await order()); await mutate(` const asset = __gm.state.project.assets.find((a) => a.id === 'structy'); asset.variables = asset.variables.filter((v) => v.key !== 'count'); delete asset.props.count; `); // 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 findIcon('kind-struct'); 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)); }