/** * 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 }]); }); });