diff --git a/README.md b/README.md index bb24e0df..b9b47087 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1398 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1400 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index 9a125560..996f0a1a 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -88,6 +88,7 @@ export function documentHooks({ state, editor }: HookContext) { /** The kind mark of a row, or of one field of a struct item – both are pickers. */ markAt: (index: number, field = '') => editor.listEditor.markAt(index, field), nameAt: (index: number, field: string) => editor.listEditor.nameAt(index, field), + removeAt: (index: number) => editor.listEditor.removeAt(index), }, }; } diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/ListEditor.ts index c5562aea..2b16f3ca 100644 --- a/src/editor/list/ListEditor.ts +++ b/src/editor/list/ListEditor.ts @@ -54,6 +54,7 @@ const INDEX_WIDTH = 40; interface RowWidgets { value: Widget; mark: Widget; + remove?: Widget; parts: Map; marks: Map; names: Map; @@ -127,6 +128,11 @@ export class ListEditor extends VStack { return centreOf(field ? row?.marks.get(field) : row?.mark); } + /** For the check scripts: the button that deletes a row – every row has the same `×`. */ + removeAt(index: number): { x: number; y: number } | null { + return centreOf(this.rowWidgets[index]?.remove); + } + /** For the check scripts: the name field of one field of a struct item. */ nameAt(index: number, field: string): { x: number; y: number } | null { return centreOf(this.rowWidgets[index]?.names.get(field)); @@ -211,7 +217,7 @@ export class ListEditor extends VStack { // Through `untag`, so a color keeps its number when it becomes one. kindMenu( access.kinds.filter((next) => next !== kind), - (next) => this.replaceItem(access, index, makeItem(next, untag(item))), + (next) => this.replaceItem(access, index, makeItem(next, untag(access.value[index]))), ), ); if (tall) mark.minHeight = Theme.rowHeight; @@ -228,7 +234,10 @@ export class ListEditor extends VStack { this.setters.push((next) => field.setValue(untag(next) ?? '')); this.rowWidgets.push(rowWidgets(mark, field.widget)); } - row.add(removeButton(() => access.replace(access.value.filter((_, i) => i !== index)))); + const remove = removeButton(() => access.replace(access.value.filter((_, i) => i !== index))); + row.add(remove); + const widgets = this.rowWidgets[index]; + if (widgets) widgets.remove = remove; return row; } @@ -242,6 +251,8 @@ export class ListEditor extends VStack { ): void { const bundle = structItemOf(item); const fields = structItemFields(item); + // A field value edit does not rebuild the row, so every button reads the item as it is now. + const now = () => structItemOf(access.value[index]); const stack = new VStack({ gap: Theme.gap }); const widgets = rowWidgets(mark, stack); const setters = new Map void>(); @@ -255,13 +266,13 @@ export class ListEditor extends VStack { FIELD_KINDS.filter((next) => next !== field.kind), (next) => this.replaceItem(access, index, { - ...bundle, - [field.key]: makeFlatItem(next, untag(bundle[field.key])), + ...now(), + [field.key]: makeFlatItem(next, untag(now()[field.key])), }), ), ); const name = nameField(field.key, (to) => { - if (to && to !== field.key) this.replaceItem(access, index, renamedField(bundle, field.key, to)); + if (to && to !== field.key) this.replaceItem(access, index, renamedField(now(), field.key, to)); }); line.add(fieldMark); line.add(name); @@ -275,12 +286,12 @@ export class ListEditor extends VStack { widgets.parts.set(field.key, sub.widget); widgets.marks.set(field.key, fieldMark); widgets.names.set(field.key, name); - line.add(removeButton(() => this.replaceItem(access, index, withoutField(bundle, field.key)))); + line.add(removeButton(() => this.replaceItem(access, index, withoutField(now(), field.key)))); stack.add(line); } const foot = new HStack({ gap: Theme.gap, align: 'center' }); foot.minHeight = Theme.rowHeight; - foot.add(addFieldMenu((kind) => this.addField(access, index, bundle, kind))); + foot.add(addFieldMenu((kind) => this.addField(access, index, now(), kind))); foot.add(new Spacer(), 1); stack.add(foot); row.add(stack, 1); diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts index d70464ef..c6dc3dd3 100644 --- a/src/editor/list/slot.ts +++ b/src/editor/list/slot.ts @@ -30,6 +30,7 @@ export type ListDef = Extract; /** What the tab needs of a slot: what is in it, and the two ways of writing it back. */ export interface ListAccess { readonly def: ListDef; + /** What is in the list now – re-read on every access. */ readonly value: ListValue; /** * The kinds this list may hold. A list in a struct field takes primitives only – `struct → list → @@ -119,7 +120,11 @@ function access( def, kinds, title, - value: current(), + // Re-read, not a snapshot: an item edit does not rebuild the rows, so a delete or kind change + // clicked later has to build on the list with that edit in it, not on the one the rows showed. + get value() { + return current(); + }, itemHandlers: (index) => ({ ...previewingHandlers( { state }, diff --git a/tests/editor/list-slot.test.ts b/tests/editor/list-slot.test.ts index 7a0c3283..5266514e 100644 --- a/tests/editor/list-slot.test.ts +++ b/tests/editor/list-slot.test.ts @@ -74,6 +74,20 @@ describe('the address a list is edited through', () => { expect(list().value).toEqual([10, 1, 20]); }); + /** + * The tab resolves once per rebuild and keeps that access for its delete, add and kind buttons, + * and an item edit does not rebuild. A delete clicked after an edit has to keep that edit. + */ + it('deletes from the list as it is now, so an edit made after the rows were built stays', () => { + const held = list(); + held.itemHandlers(0).onChange(10); + held.replace([...held.value, 5]); + held.itemHandlers(3).onChange(50); + held.replace(held.value.filter((_, i) => i !== 1)); + expect(list().value).toEqual([10, 4, 50]); + expect(held.value).toEqual([10, 4, 50]); + }); + /** The row keeps the kind of the item in it: a number field may only ever hand back a number. */ it('writes what it is given as the kind the item already is', () => { list().itemHandlers(1).onChange('42'); diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs index 070b8012..1f84affe 100644 --- a/tools/e2e/21-list.mjs +++ b/tools/e2e/21-list.mjs @@ -57,6 +57,30 @@ export async function listTab(t) { info = await json('__gm.list.info()'); check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]); + // 21b-ii. An item edit does not rebuild the rows, so a delete clicked after it must build on the + // edited list – with the one the rows were built from, the edit came back undone (#235). + const edited = await json('__gm.list.fieldAt(2)'); + await click(edited.x, edited.y); + await typeText('66'); + await press('Enter'); + const removeFirst = await json('__gm.list.removeAt(0)'); + await click(removeFirst.x, removeFirst.y); + await sleep(200); + info = await json('__gm.list.info()'); + check( + 'deleting a row keeps an edit made to another row before', + JSON.stringify(info.items) === '["11","66"]', + JSON.stringify(info.items), + ); + await undo(); + await undo(); + info = await json('__gm.list.info()'); + check( + 'and two undos take delete and edit back', + JSON.stringify(info.items) === '["30","11","44"]', + info.items, + ); + // 21c. A short list opened after a long one. Its rows leave the content height pinned to the // viewport, so a layout skipped on "same size" would stack them all at the top. await mutate(`