Files
feuerwerk/tests/editor/list-slot.test.ts
T
adminandClaude Opus 5.5 085e6c8cce
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 8m55s
Keep edited list values when an item is deleted (#235)
The list tab read the list once when it built its rows. Editing a
value does not rebuild the rows, so the delete button later filtered
that old copy and wrote it back. Every edit since the last rebuild was
lost. Adding an item only triggered it, because adding rebuilds the
rows and that state became the old copy.

- ListAccess.value in src/editor/list/slot.ts is now read on every
  access instead of once when the access is built.
- The kind switch of a row and the struct field buttons (field type,
  rename, remove field, + Field) read the item when clicked, not
  when the row was built. They had the same stale copy.
- New test hook __gm.list.removeAt(i) clicks the delete button of one
  row, because the tab shows many buttons with the same label.

Not covered by a test of their own: the struct field buttons and the
kind switch after an edit. The cause is the same, the fix is in code.

Tests:
- ./check.sh: 2435 tests, CHECK EXIT 0.
- Unit test in tests/editor/list-slot.test.ts keeps one access, edits
  a value, adds and edits an item, deletes another. All edits stay.
  It fails without the fix.
- e2e 21b-ii with real clicks: set row 2 to 66, delete row 0, expect
  ["11","66"], two undos take both back. e2e 21: 52 checks passed.
- Counter probe: with the old snapshot the check fails with
  ["11","44"], the reported bug.
- Check total in README is now 1400 (+2), counted, not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:45:23 +02:00

285 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The address the List tab edits through. What matters here is not that a list can be read, but
* that writing one item goes through the same command (and the same undo) as the inspector row
* it replaces, and that a row keeps answering for the list as it is *now*.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { tagged } from '../../src/core/model/Properties';
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
import { FIELD_KINDS, LIST_ITEM_KINDS } from '../../src/core/properties/kinds';
import { variableDef } from '../../src/core/properties/PropertySchema';
import { resolveList } from '../../src/editor/list/slot';
import { EditorState, type ListSlot } from '../../src/editor/state';
function setup(): { state: EditorState; behavior: BehaviorAsset } {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('behavior', 'B');
expect(state.execute(create)).toBe(true);
const behavior = create.asset as BehaviorAsset;
expect(state.execute(new AddVariableCommand(behavior.id, 'nums', 'list'))).toBe(true);
return { state, behavior };
}
describe('the address a list is edited through', () => {
let f: ReturnType<typeof setup>;
let slot: ListSlot;
beforeEach(() => {
f = setup();
slot = { kind: 'asset', assetId: f.behavior.id, key: 'nums' };
resolveList(f.state, slot)!.replace([3, 1, 4]);
});
const list = () => resolveList(f.state, slot)!;
it('finds the list and what it belongs to', () => {
expect(list().value).toEqual([3, 1, 4]);
expect(list().def.kind).toBe('list');
expect(list().title).toBe('B · nums');
});
it('writes one item through the property command, and undo puts it back', () => {
list().itemHandlers(1).onChange(99);
expect(list().value).toEqual([3, 99, 4]);
f.state.commands.undo();
expect(list().value).toEqual([3, 1, 4]);
});
it('takes a preview back before committing, so undo lands before the preview', () => {
const handlers = list().itemHandlers(0);
handlers.onInput(7);
handlers.onInput(8);
handlers.onChange(8);
expect(list().value).toEqual([8, 1, 4]);
f.state.commands.undo();
expect(list().value).toEqual([3, 1, 4]);
});
/**
* A row's handlers outlive a commit – the tab only rebuilds when the length changes. Reading
* the list at edit time rather than at build time is what keeps the earlier edit alive.
*/
it('lets two rows built at the same time be edited one after the other', () => {
const first = list().itemHandlers(0);
const second = list().itemHandlers(2);
first.onChange(10);
second.onChange(20);
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');
expect(list().value).toEqual([3, 42, 4]);
list().itemHandlers(1).onChange('nonsense');
expect(list().value).toEqual([3, 0, 4]);
});
it('takes a whole list of mixed items as it is, here and on a node setting', () => {
list().replace(['5', true, tagged('color', 0xff0000)]);
expect(list().value).toEqual(['5', true, tagged('color', 0xff0000)]);
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
expect(f.state.execute(add)).toBe(true);
const node: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'value' };
resolveList(f.state, node)!.replace(['5', 'nonsense']);
expect(resolveList(f.state, node)!.value).toEqual(['5', 'nonsense']);
});
/** A preview writes past the command, so it is the one write that has to coerce for itself. */
it('leaves a well-typed list behind even mid-preview', () => {
list().itemHandlers(0).onInput('7');
const previewed = (findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.nums;
expect(previewed).toEqual([7, 1, 4]);
});
it('adds and removes whole items in one command', () => {
list().replace([...list().value, 9]);
expect(list().value).toEqual([3, 1, 4, 9]);
list().replace(list().value.filter((_, i) => i !== 0));
expect(list().value).toEqual([1, 4, 9]);
f.state.commands.undo();
expect(list().value).toEqual([3, 1, 4, 9]);
});
it('is nothing at all once its target is gone', () => {
expect(resolveList(f.state, { kind: 'asset', assetId: 'no-such', key: 'nums' })).toBe(null);
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'no-such' })).toBe(null);
});
it('refuses a key that is not a list', () => {
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'runInEditor' })).toBe(null);
});
it('keeps text items as text rather than coercing them to numbers', () => {
const behavior = findAsset(f.state.project, f.behavior.id) as BehaviorAsset;
behavior.variables.push(variableDef('words', 'list', []));
const words: ListSlot = { kind: 'asset', assetId: f.behavior.id, key: 'words' };
resolveList(f.state, words)!.replace(['red', 'green']);
resolveList(f.state, words)!.itemHandlers(1).onChange('blue');
expect(resolveList(f.state, words)!.value).toEqual(['red', 'blue']);
});
/** An unconnected `list` input carries its inline value in the same params bag a setting does. */
it('reaches the inline value of an unconnected list input', () => {
const add = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
expect(f.state.execute(add)).toBe(true);
const port: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'list' };
expect(resolveList(f.state, port)!.def.kind).toBe('list');
expect(resolveList(f.state, port)!.title).toBe('Count · list');
resolveList(f.state, port)!.replace([1, 2, 3]);
resolveList(f.state, port)!.itemHandlers(1).onChange('9');
expect(resolveList(f.state, port)!.value).toEqual([1, 9, 3]);
f.state.commands.undo();
expect(resolveList(f.state, port)!.value).toEqual([1, 2, 3]);
});
/** A fed port ignores its inline value, so editing it would be editing nothing. */
it('stops resolving a list input once an edge feeds it', () => {
const source = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
const sink = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
expect(f.state.execute(source)).toBe(true);
expect(f.state.execute(sink)).toBe(true);
const port: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: sink.node.id, key: 'list' };
expect(resolveList(f.state, port)).not.toBe(null);
expect(
f.state.execute(
new ConnectCommand(
f.behavior.id,
{ node: source.node.id, port: 'value' },
{ node: sink.node.id, port: 'list' },
),
),
).toBe(true);
expect(resolveList(f.state, port)).toBe(null);
});
it('reaches a node setting too, which is the other place a list lives', () => {
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
expect(f.state.execute(add)).toBe(true);
const node: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'value' };
resolveList(f.state, node)!.replace([1, 2]);
expect(resolveList(f.state, node)!.value).toEqual([1, 2]);
expect(resolveList(f.state, node)!.title).toBe('List · Value');
f.state.commands.undo();
expect(resolveList(f.state, node)!.value).toEqual([]);
});
});
/**
* A list one level down, inside a struct field. The bundle is what gets written, so the point of
* every check here is that the neighbouring fields survive an edit to the list.
*/
describe('a list a struct field holds', () => {
let f: ReturnType<typeof setup>;
let slot: ListSlot;
beforeEach(() => {
f = setup();
const id = f.behavior.id;
expect(f.state.execute(new AddVariableCommand(id, 'route', 'struct'))).toBe(true);
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'name', kind: 'text' }))).toBe(true);
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'stops', kind: 'list' }))).toBe(
true,
);
slot = { kind: 'asset', assetId: id, key: 'route', field: 'stops' };
});
const list = () => resolveList(f.state, slot)!;
const bundle = () => (findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.route;
it('starts empty and says where it sits', () => {
expect(list().value).toEqual([]);
expect(list().def.kind).toBe('list');
expect(list().title).toBe('B · route · stops');
});
it('writes the whole bundle, so the neighbouring field keeps its value', () => {
resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'route' });
(findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.route = { name: 'north', stops: [] };
list().replace([1, 2, 3]);
expect(bundle()).toEqual({ name: 'north', stops: [1, 2, 3] });
list().itemHandlers(1).onChange('9');
expect(bundle()).toEqual({ name: 'north', stops: [1, 9, 3] });
f.state.commands.undo();
expect(bundle()).toEqual({ name: 'north', stops: [1, 2, 3] });
});
/** A preview writes past the command, so mid-edit the bundle has to stay whole as well. */
it('keeps the bundle whole mid-preview', () => {
list().replace([5]);
list().itemHandlers(0).onInput('7');
expect(bundle()).toEqual({ name: '', stops: [7] });
});
it('is nothing at all for a field that is not a list, or is not there', () => {
expect(resolveList(f.state, { ...slot, field: 'name' })).toBe(null);
expect(resolveList(f.state, { ...slot, field: 'no-such' })).toBe(null);
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', field: 'x' })).toBe(
null,
);
});
it('survives being written out and read back in', () => {
list().replace([4, 5]);
const back = deserializeProject(serializeProject(f.state.project));
const asset = findAsset(back, f.behavior.id) as BehaviorAsset;
expect(asset.variables.find((v) => v.key === 'route')).toMatchObject({
fields: [
{ key: 'name', kind: 'text' },
{ key: 'stops', kind: 'list' },
],
});
expect(asset.props.route).toEqual({ name: '', stops: [4, 5] });
});
/** The one shape the model does not store: a list inside a struct inside a list. */
it('is dropped inside the struct items of a list', () => {
const def = variableDef('rows', 'list', [{ x: 1, inner: [1, 2] }]);
expect(def.default).toEqual([{ x: 1 }]);
});
/**
* And because they are dropped, the tab must not offer them: an item added here would vanish on
* the next write, which reads as the menu entry doing nothing at all.
*/
it('offers the flat kinds only, where the whole list has struct as well', () => {
expect(list().kinds).toEqual(FIELD_KINDS);
expect(list().kinds).not.toContain('struct');
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
expect(whole.kinds).toEqual(LIST_ITEM_KINDS);
expect(whole.kinds).toContain('struct');
});
});