/** * Structs and lists nest to any depth (#239): a struct field may be a struct with a declaration of * its own, a list item a list, and nothing deeper is dropped – not on load, not on the way through * a graph. What was stored under the old bound still loads as it was. */ import { describe, expect, it } from 'vitest'; import { AddPortsCommand } from '../../src/core/commands/portCommands'; import { AddStructFieldCommand, AddVariableCommand, RemoveStructFieldCommand, RenameStructFieldCommand, } from '../../src/core/commands/variableCommands'; import { writeTyped } from '../../src/core/graph'; import { liveValue, withoutObjects } from '../../src/core/graph/liveObjects'; import { asField, heldObject, type Value } from '../../src/core/graph/values'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { NO_OBJECT, type PropertyBag, type PropertyValue, sameValue, tagged, } from '../../src/core/model/Properties'; import { variableDef, type StructFieldDef } from '../../src/core/properties/PropertySchema'; import { valueAt, withValueAt } from '../../src/core/properties/valuePath'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { fakeEntity, graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, type SceneFixture, startWorld, } from '../helpers'; /** `enemy` = { hp, loot: { item, drops } }, `loot` declared inside the struct. */ function enemyOn(f: SceneFixture): BehaviorAsset { const behavior = makeBehavior(f, 'Enemy'); const id = behavior.id; const run = (command: Parameters[0]) => f.stack.execute(command); run(new AddVariableCommand(id, 'enemy', 'struct')); run(new AddStructFieldCommand(id, 'enemy', { key: 'hp', kind: 'number' })); run(new AddStructFieldCommand(id, 'enemy', { key: 'loot', kind: 'struct', fields: [] })); run(new AddStructFieldCommand(id, 'enemy', { key: 'item', kind: 'text' }, ['loot'])); run(new AddStructFieldCommand(id, 'enemy', { key: 'drops', kind: 'list' }, ['loot'])); return behavior; } const declared = (behavior: BehaviorAsset) => behavior.variables.find((v) => v.key === 'enemy')!; describe('a struct field that is a struct', () => { it('declares its own fields, and every value takes that shape at once', () => { const f = makeSpriteScene(); const behavior = enemyOn(f); expect(declared(behavior)).toMatchObject({ fields: [ { key: 'hp', kind: 'number' }, { key: 'loot', kind: 'struct', fields: [ { key: 'item', kind: 'text' }, { key: 'drops', kind: 'list' }, ], }, ], }); const empty = { hp: 0, loot: { item: '', drops: [] } }; expect(declared(behavior).default).toEqual(empty); expect(behavior.props.enemy).toEqual(empty); }); it('brings what arrives into the declared shape, however deep', () => { const f = makeSpriteScene(); const fields = (declared(enemyOn(f)) as { fields: readonly StructFieldDef[] }).fields; const def = variableDef('e', 'struct', { hp: '5', loot: { item: 3, extra: 1 }, other: 2 }, fields); expect(def.default).toEqual({ hp: 5, loot: { item: '3', drops: [] } }); }); it('keeps declaration and a deep value through save and load', () => { const f = makeSpriteScene(); const behavior = enemyOn(f); const deep = { hp: 3, loot: { item: 'gem', drops: [{ name: 'a', tags: ['x', [1, tagged('color', 0xff0000)]] }, [[2]]] }, }; behavior.props.enemy = deep; const back = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset; expect(back.props.enemy).toEqual(deep); expect(declared(back)).toEqual(declared(behavior)); }); it('adds, renames and removes a nested field in the declaration and every value, undo included', () => { const f = makeSpriteScene(); const behavior = enemyOn(f); behavior.props.enemy = { hp: 1, loot: { item: 'gem', drops: [7] } }; f.stack.execute(new RenameStructFieldCommand(behavior.id, 'enemy', 'item', 'name', ['loot'])); expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } }); f.stack.execute(new RemoveStructFieldCommand(behavior.id, 'enemy', 'drops', ['loot'])); expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem' } }); expect((declared(behavior) as { fields: readonly StructFieldDef[] }).fields).toEqual([ { key: 'hp', kind: 'number' }, { key: 'loot', kind: 'struct', fields: [{ key: 'name', kind: 'text' }] }, ]); f.stack.undo(); expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } }); f.stack.undo(); expect(behavior.props.enemy).toEqual({ hp: 1, loot: { item: 'gem', drops: [7] } }); }); it('refuses a path that leads to no struct', () => { const f = makeSpriteScene(); const behavior = enemyOn(f); expect(() => f.stack.execute(new AddStructFieldCommand(behavior.id, 'enemy', { key: 'x', kind: 'number' }, ['hp'])), ).toThrow('Field "hp" has no fields'); expect(behavior.props.enemy).toEqual({ hp: 0, loot: { item: '', drops: [] } }); }); }); describe('stored values from before the bound was lifted', () => { it('load exactly as they were', () => { const f = makeSpriteScene(); const behavior = makeBehavior(f, 'Old'); f.stack.execute(new AddVariableCommand(behavior.id, 'rows', 'list')); f.stack.execute(new AddVariableCommand(behavior.id, 'route', 'struct')); f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'name', kind: 'text' })); f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'stops', kind: 'list' })); behavior.props.rows = [{ x: 1, c: tagged('color', 5) }, 2, 'a', tagged('texture', 't')]; behavior.props.route = { name: 'n', stops: [1, tagged('color', 3)] }; const stored = serializeProject(f.project); expect(serializeProject(deserializeProject(stored))).toEqual(stored); }); }); describe('a nested value in a running game', () => { /** Pack(item) into Pack(loot) into `enemy`; on every update Expand twice and log the item. */ function packer(f: SceneFixture): BehaviorAsset { const behavior = enemyOn(f); const edit = graphEditor(f, behavior); const inner = edit.addNode('PackStruct'); f.stack.execute(new AddPortsCommand(behavior.id, inner.id, [{ key: 'item', kind: 'text' }])); inner.params.item = 'gem'; const outer = edit.addNode('PackStruct'); f.stack.execute(new AddPortsCommand(behavior.id, outer.id, [{ key: 'loot', kind: 'struct' }])); const set = edit.variables(['enemy'], { write: true }); const get = edit.variables(['enemy']); const first = edit.addNode('ExpandStruct'); f.stack.execute(new AddPortsCommand(behavior.id, first.id, [{ key: 'loot', kind: 'struct' }])); const second = edit.addNode('ExpandStruct'); f.stack.execute(new AddPortsCommand(behavior.id, second.id, [{ key: 'item', kind: 'text' }])); const log = edit.addNode('Log'); edit.wire([ [edit.addNode('OnStart'), 'exec', set, 'exec'], [inner, 'value', outer, 'loot'], [outer, 'value', set, 'enemy'], [edit.addNode('OnUpdate'), 'exec', log, 'exec'], [get, 'enemy', first, 'value'], [first, 'loot', second, 'value'], [second, 'item', log, 'message'], ]); place(f, behavior.id, { x: 0, y: 0 }); return behavior; } it('is written level by level and taken apart the same way', () => { const f = makeSpriteScene(); const behavior = packer(f); const run = startWorld(f, { behaviors: true }); run.frame(); run.frame(); expect(run.world.log.at(-1)).toBe('gem'); // Merged into the declared shape: the fields nobody wrote keep their values. const instance = run.world.entities.find((e) => e.assetId === behavior.id)!; expect(instance.props.enemy).toEqual({ hp: 0, loot: { item: 'gem', drops: [] } }); }); it('keeps a list in a list and a struct in a struct when written into a variable', () => { const bag: PropertyBag = { grid: [], nest: { a: { b: 0 } } }; writeTyped(bag, 'grid', [ [1, 2], [3, [4]], ]); writeTyped(bag, 'nest', { a: { b: 5, c: 1 } }); expect(bag.grid).toEqual([ [1, 2], [3, [4]], ]); expect(bag.nest).toEqual({ a: { b: 5 } }); }); it('holds an object at any depth, and lets none of them out', () => { const crate = fakeEntity('Crate'); const held = asField([{ who: { at: [crate] } }] as unknown as Value); expect(valueAt(held, [0, 'who', 'at', 0])).toBe(heldObject(crate)); expect(withoutObjects(held)).toEqual([{ who: { at: [NO_OBJECT] } }]); expect(liveValue(held, { isAlive: () => false, referenced: () => null })).toEqual([ { who: { at: [NO_OBJECT] } }, ]); expect(liveValue(held, { isAlive: () => true, referenced: () => null })).toEqual(held); }); }); describe('a path into a nested value', () => { const value: PropertyValue = { a: [1, { b: [2, 3] }] }; it('reads what sits there, and nothing where a step finds nothing', () => { expect(valueAt(value, ['a', 1, 'b', 0])).toBe(2); expect(valueAt(value, ['a', 5])).toBeUndefined(); expect(valueAt(value, ['a', 'b'])).toBeUndefined(); }); it('writes a copy, leaving the value it started from alone', () => { const next = withValueAt(value, ['a', 1, 'b', 1], 9); expect(next).toEqual({ a: [1, { b: [2, 9] }] }); expect(value).toEqual({ a: [1, { b: [2, 3] }] }); expect(withValueAt(value, ['a', 7], 9)).toBe(value); }); it('compares nested values by content', () => { expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [2] }] })).toBe(true); expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [3] }] })).toBe(false); }); });