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>
140 lines
6.0 KiB
TypeScript
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 }]);
|
|
});
|
|
});
|