/** * 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, shot, click, clickText, press, typeText, mutate, undo } = t; /** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */ const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`); const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece"); // 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' }); `); await sleep(150); // 1. 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.000')) !== null); await shot('01-struct-rows'); // 2. 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'); await sleep(200); 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'); await sleep(200); check( 'and the next field edited keeps the one before it', JSON.stringify(await piece()) === '{"x":42,"tag":"ell"}', JSON.stringify(await piece()), ); // 3. 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())); // 4. Adding a field reaches the declaration and the value at once. await clickText('+ Field'); await sleep(150); await clickText('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(); await sleep(200); check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece())); // 5. 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'); await sleep(250); check( 'renaming a field keeps its value', JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}', JSON.stringify(await piece()), ); // 6. 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'); await sleep(150); // "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); await sleep(250); 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); await sleep(250); 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); await clickText('+ Item'); 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(); await sleep(200); 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)'); await sleep(200); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'structy'); __gm.state.select(null); __gm.state.preview(); `); await sleep(200); const left = await json('__gm.state.project.assets.filter((a) => a.id === "structy").length'); check('the step leaves the fixture as it found it', left === 0, String(left)); }