Files
feuerwerk/tests/core/list-item-kind.test.ts
T
rootandClaude Opus 5 10d030376a Finish the struct and list leftovers: inline lists, list fields, item kinds
An unconnected list input is a button into the List tab instead of a number
field; a struct field can hold a list, so ListSlot carries an optional field
name and the three owner cases only say what sits at a key; and a list
variable picks its item kind in the inspector (marker doubles as the chooser),
so a list of structs no longer has to be born from a project file.

The declaration keeps the storable shapes finite: a struct field may be a list,
but that list holds primitives only, so list->struct->primitive and
struct->list->primitive are the two deepest forms.

check 674, e2e 483, jump-run 24, Tetris 19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:36:15 +02:00

140 lines
6.0 KiB
TypeScript

/**
* What a list variable holds, chosen in the editor rather than only in a project file. Two things
* matter: changing the item kind drags every value along (declared default, own value, overrides),
* and a list of structs declares its item shape through the same field commands a struct does.
*/
import { describe, expect, it } from 'vitest';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import {
AddStructFieldCommand,
AddVariableCommand,
RemoveStructFieldCommand,
RenameStructFieldCommand,
SetListItemsCommand,
} from '../../src/core/commands/variableCommands';
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
const findObject = (project: Project, id: string) =>
objectHolders(project)
.flatMap((holder) => holder.objects)
.find((o) => o.id === id);
const listDef = (project: Project, assetId: string, key: string) => {
const asset = findAsset(project, assetId);
const def = asset?.type === 'behavior' ? asset.variables.find((v) => v.key === key) : undefined;
return def?.kind === 'list' ? def : undefined;
};
/** A behavior with a list variable `stops`, placed once with an override of its own. */
function withList() {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B');
f.stack.execute(new AddVariableCommand(behavior.id, 'stops', 'list'));
const asset = findAsset(f.project, behavior.id)!;
asset.props.stops = [1, 2];
const object = place(f, behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'stops', [3]));
return { f, behaviorId: behavior.id, object };
}
describe('what a list variable holds', () => {
it('starts on numbers, the one kind `+ Variable ▸ List` can pick', () => {
const { f, behaviorId } = withList();
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('number');
});
it('takes every value with it when the kind changes, and gives them back on undo', () => {
const { f, behaviorId, object } = withList();
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'text'));
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('text');
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual(['1', '2']);
expect(findObject(f.project, object)?.overrides.stops).toEqual(['3']);
f.stack.undo();
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('number');
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([1, 2]);
expect(findObject(f.project, object)?.overrides.stops).toEqual([3]);
});
/** Bare numbers have no shape, so they become the empty item rather than disappearing. */
it('turns loose items into the empty shape when it becomes a list of structs', () => {
const { f, behaviorId } = withList();
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'struct'));
expect(listDef(f.project, behaviorId, 'stops')?.fields).toEqual([]);
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{}, {}]);
});
it('refuses a change to the kind it already holds', () => {
const { f, behaviorId } = withList();
expect(() => f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'number'))).toThrow(
/already holds/,
);
});
});
describe('the item shape of a list of structs', () => {
function withStructList() {
const { f, behaviorId, object } = withList();
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'struct'));
f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', { key: 'x', kind: 'number' }));
return { f, behaviorId, object };
}
it('grows a field in the declaration and in every item of every value at once', () => {
const { f, behaviorId, object } = withStructList();
expect(listDef(f.project, behaviorId, 'stops')?.fields).toEqual([{ key: 'x', kind: 'number' }]);
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{ x: 0 }, { x: 0 }]);
expect(findObject(f.project, object)?.overrides.stops).toEqual([{ x: 0 }]);
f.stack.undo();
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{}, {}]);
});
it('keeps the values of the items it does not touch when one is renamed or removed', () => {
const { f, behaviorId } = withStructList();
const asset = findAsset(f.project, behaviorId)!;
asset.props.stops = [{ x: 5 }, { x: 6 }];
f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', { key: 'y', kind: 'number' }));
f.stack.execute(new RenameStructFieldCommand(behaviorId, 'stops', 'x', 'col'));
expect(asset.props.stops).toEqual([
{ col: 5, y: 0 },
{ col: 6, y: 0 },
]);
f.stack.execute(new RemoveStructFieldCommand(behaviorId, 'stops', 'y'));
expect(asset.props.stops).toEqual([{ col: 5 }, { col: 6 }]);
f.stack.undo();
expect(asset.props.stops).toEqual([
{ col: 5, y: 0 },
{ col: 6, y: 0 },
]);
});
/** `list → struct → list → primitive` is one level deeper than anything the model stores. */
it('refuses a list field, which a struct variable does allow', () => {
const { f, behaviorId } = withStructList();
const listField = { key: 'inner', kind: 'list', items: 'number' } as const;
expect(() => f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', listField))).toThrow(
/cannot sit in the items of a list/,
);
f.stack.execute(new AddVariableCommand(behaviorId, 'route', 'struct'));
f.stack.execute(new AddStructFieldCommand(behaviorId, 'route', listField));
expect(findAsset(f.project, behaviorId)?.props.route).toEqual({ inner: [] });
});
it('survives being written out and read back in', () => {
const { f, behaviorId } = withStructList();
findAsset(f.project, behaviorId)!.props.stops = [{ x: 5 }, { x: 6 }];
const back = roundTrip(f.project);
expect(listDef(back, behaviorId, 'stops')).toMatchObject({
items: 'struct',
fields: [{ key: 'x', kind: 'number' }],
});
expect(findAsset(back, behaviorId)?.props.stops).toEqual([{ x: 5 }, { x: 6 }]);
});
});