Files
feuerwerk/tests/core/object-variable.test.ts
T
adminandClaude Opus 5.5 0c5ac32b59
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 10m24s
Let structs and lists nest to any depth (#239)
Structs and lists were capped at two fixed levels (list of structs,
struct with a list field). Anything deeper was dropped silently on
load, in node settings and when written to a variable. The cap came
from an early design choice, not from a technical need, so it is
lifted completely instead of adding a third fixed level.

- One kind list NESTED_KINDS replaces ListItemKind and
  StructFieldKind. The flat special cases in listItems.ts are gone,
  every value goes through one recursive path.
- A struct inside a struct is declared, like a top-level struct.
  Values coming in over an edge are brought into that shape. Struct
  items in lists still take their fields from the value.
- New valuePath.ts addresses any nested spot by keys and indices.
  ListSlot.field is now ListSlot.path.
- Field commands (add, rename, remove) take the path to the struct.
  They change declaration, own value and all overrides in one undo
  step, as before on the top level.
- Inspector: "+ Field" offers Struct and List on every level. A
  nested struct is a header row with its own "+ Field", its fields
  are indented one step further.
- List tab: a list item shows "n items" and opens one level deeper.
  The title shows the path, e.g. "Listy · nums · [3]".
- Pack in Pack now writes a struct into a struct field instead of
  text. Objects are kept at any depth and stripped at every exit.
- A list edit in a struct field without an existing object override
  now starts from the shown value instead of an empty struct.
- Old projects load unchanged. Save, load and save again gives the
  same result.

Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii,
  21d and 22f updated)
- 20 new unit tests in nested-values.test.ts and
  nested-list-slot.test.ts, three tests that pinned the old cap
  turned around
- 6 counter-probes, each fails 1 to 8 tests, originals restored
  and checked with cmp
- Not covered: a nested struct inside a struct item in the list
  tab has no e2e click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:33:20 +02:00

397 lines
16 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.
/**
* Objects in variables, struct fields and list items: a spawned object written into one is there on
* the next frame, a destroyed one reads as nothing, and no handle ever leaves the run – not into the
* scene, the file, a save or what the editor is shown.
*/
import { describe, expect, it } from 'vitest';
import { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands';
import { AddPortsCommand } from '../../src/core/commands/portCommands';
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import {
asString,
resumedVariables,
type SaveArea,
type SavedValues,
VARIABLES_PARAM,
writeTyped,
} from '../../src/core/graph';
import { bagWithoutObjects, liveValue } from '../../src/core/graph/liveObjects';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { NO_OBJECT, type PropertyBag } from '../../src/core/model/Properties';
import { findScene } from '../../src/core/model/Project';
import { isKindWith, NESTED_KINDS, VARIABLE_KINDS } from '../../src/core/properties/kinds';
import { makeItem } from '../../src/core/properties/listItems';
import { serializeProject } from '../../src/core/store/serialize';
import {
fakeEntity,
graphEditor,
makeBehavior,
makeSpriteScene,
makeVariables,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
/** Spawns a Crate on start into `spawned`, and logs what `spawned` holds on every update. */
function spawner(f: SceneFixture): BehaviorAsset {
const behavior = makeBehavior(f, 'Spawner');
f.stack.execute(new AddVariableCommand(behavior.id, 'spawned', 'object'));
const edit = graphEditor(f, behavior);
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const set = edit.variables(['spawned'], { write: true });
const get = edit.variables(['spawned']);
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
[spawn, 'exec', set, 'exec'],
[spawn, 'object', set, 'spawned'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'spawned', log, 'message'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return behavior;
}
/** Spawns a Crate on start into the field `target` of the struct `held`; logs that field on every update. */
function structHolder(f: SceneFixture): BehaviorAsset {
const behavior = makeBehavior(f, 'Holder');
f.stack.execute(new AddVariableCommand(behavior.id, 'held', 'struct'));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'target', kind: 'object' }));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'count', kind: 'number' }));
const edit = graphEditor(f, behavior);
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const pack = edit.addNode('PackStruct');
f.stack.execute(new AddPortsCommand(behavior.id, pack.id, [{ key: 'target', kind: 'object' }]));
const set = edit.variables(['held'], { write: true });
const get = edit.variables(['held']);
const expand = edit.addNode('ExpandStruct');
f.stack.execute(new AddPortsCommand(behavior.id, expand.id, [{ key: 'target', kind: 'object' }]));
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
[spawn, 'exec', set, 'exec'],
[spawn, 'object', pack, 'target'],
[pack, 'value', set, 'held'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'held', expand, 'value'],
[expand, 'target', log, 'message'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return behavior;
}
/** Spawns a Crate on start into the list `crates`; logs its first item on every update. */
function listHolder(f: SceneFixture): BehaviorAsset {
const behavior = makeBehavior(f, 'Keeper');
f.stack.execute(new AddVariableCommand(behavior.id, 'crates', 'list'));
const edit = graphEditor(f, behavior);
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const one = edit.addNode('Fill', { count: 1 });
const set = edit.variables(['crates'], { write: true });
const get = edit.variables(['crates']);
const first = edit.addNode('ItemAt', { index: 0 });
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
[spawn, 'exec', set, 'exec'],
[spawn, 'object', one, 'value'],
[one, 'result', set, 'crates'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'crates', first, 'list'],
[first, 'item', log, 'message'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return behavior;
}
const crates = (f: SceneFixture, run: ReturnType<typeof startWorld>) =>
run.world.entities.filter((e) => e.assetId === f.spriteId);
const instanceOf = (run: ReturnType<typeof startWorld>, behavior: BehaviorAsset) =>
run.world.entities.find((e) => e.assetId === behavior.id)!;
const placedOf = (f: SceneFixture, behavior: BehaviorAsset) =>
findScene(f.project, f.sceneId)!.objects.find((o) => o.assetId === behavior.id)!;
describe('the object kind', () => {
it('can be a variable, a struct field and a list item', () => {
expect(VARIABLE_KINDS).toContain('object');
expect(NESTED_KINDS).toContain('object');
expect(isKindWith('tagged', 'object')).toBe(true);
});
it('is stored as the empty object wherever it sits, whatever the file says', () => {
const f = makeSpriteScene('Crate');
const behavior = makeBehavior(f, 'Holder');
f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object'));
f.stack.execute(new AddVariableCommand(behavior.id, 'held', 'struct'));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'who', kind: 'object' }));
f.stack.execute(new AddVariableCommand(behavior.id, 'crowd', 'list'));
behavior.props.crowd = [makeItem('object', 'Crate'), 3];
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
expect(loaded.variables.find((v) => v.key === 'target')).toMatchObject({
kind: 'object',
default: NO_OBJECT,
});
expect(loaded.props).toMatchObject({
target: NO_OBJECT,
held: { who: NO_OBJECT },
crowd: [NO_OBJECT, 3],
});
expect(NO_OBJECT).toEqual({ $object: '' });
});
});
describe('an object held by a running game', () => {
it('stays in a variable on the next frames', () => {
const f = makeSpriteScene('Crate');
spawner(f);
const run = startWorld(f, { behaviors: true });
for (let i = 0; i < 3; i++) run.frame();
expect(crates(f, run)).toHaveLength(1);
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
});
it('stays in a struct field on the next frames, beside the other fields', () => {
const f = makeSpriteScene('Crate');
const behavior = structHolder(f);
const run = startWorld(f, { behaviors: true });
for (let i = 0; i < 3; i++) run.frame();
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
const held = instanceOf(run, behavior).props.held as unknown as Record<string, unknown>;
expect(held.target).toBe(crates(f, run)[0]);
expect(held.count).toBe(0);
});
it('stays in a list on the next frames', () => {
const f = makeSpriteScene('Crate');
const behavior = listHolder(f);
const run = startWorld(f, { behaviors: true });
for (let i = 0; i < 3; i++) run.frame();
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
expect(instanceOf(run, behavior).props.crates).toEqual([crates(f, run)[0]]);
});
it.each([
['a variable', spawner],
['a struct field', structHolder],
['a list item', listHolder],
])('reads as nothing from %s once the object is destroyed', (_where, build) => {
const f = makeSpriteScene('Crate');
build(f);
const run = startWorld(f, { behaviors: true });
run.frame();
run.world.remove(crates(f, run)[0]!);
run.frame();
expect(run.world.log.at(-1)).toBe('');
});
it('tells "gone" by Equals against an empty input', () => {
const f = makeSpriteScene('Crate');
const behavior = spawner(f);
const edit = graphEditor(f, behavior);
const get = edit.variables(['spawned']);
const gone = edit.addNode('Equals');
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'spawned', gone, 'a'],
[gone, 'result', log, 'message'],
]);
const run = startWorld(f, { behaviors: true });
run.frame();
const alive = run.world.log.at(-1);
run.world.remove(crates(f, run)[0]!);
run.frame();
expect([alive, run.world.log.at(-1)]).toEqual(['false', 'true']);
});
it('is read and written on another behavior through Properties', () => {
const f = makeSpriteScene('Crate');
const holder = makeBehavior(f, 'Holder');
f.stack.execute(new AddVariableCommand(holder.id, 'target', 'object'));
const writer = makeBehavior(f, 'Writer');
const edit = graphEditor(f, writer);
const find = edit.addNode('ObjectByName', { name: 'Holder' });
const crate = edit.addNode('ObjectByName', { name: 'Crate' });
const set = edit.properties([{ key: 'target', kind: 'object', write: true }], {
objectType: 'behavior',
objectBehavior: holder.id,
});
const get = edit.properties([{ key: 'target', kind: 'object' }], {
objectType: 'behavior',
objectBehavior: holder.id,
});
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', set, 'exec'],
[find, 'object', set, 'object'],
[crate, 'object', set, 'target'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[find, 'object', get, 'object'],
[get, 'target', log, 'message'],
]);
place(f, holder.id, { x: 0, y: 0 });
place(f, writer.id, { x: 0, y: 0 });
place(f, f.spriteId, { x: 0, y: 0 });
const run = startWorld(f, { behaviors: true });
run.frame();
run.frame();
expect(run.world.log.at(-1)).toBe('Crate');
});
});
describe('no object leaves the run', () => {
it.each([
['a variable', spawner, 'spawned', undefined],
['a struct field', structHolder, 'held', undefined],
// One item more than declared is a real difference – the scene learns the item, not the object.
['a list item', listHolder, 'crates', [NO_OBJECT]],
])('writes nothing of %s into the scene or the file', (_where, build, key, override) => {
const f = makeSpriteScene('Crate');
const behavior = build(f);
const run = startWorld(f, { behaviors: true });
run.frame();
run.frame();
run.world.syncToModel();
expect(asString(instanceOf(run, behavior).props[key] as never)).toContain('Crate');
expect(placedOf(f, behavior).overrides[key]).toEqual(override);
expect(() => JSON.stringify(serializeProject(f.project))).not.toThrow();
});
it('keeps the other fields of a struct flowing into the scene, with the object emptied', () => {
const f = makeSpriteScene('Crate');
const behavior = structHolder(f);
const run = startWorld(f, { behaviors: true });
run.frame();
const live = instanceOf(run, behavior).props;
live.held = { ...(live.held as object), count: 5 };
run.world.syncToModel();
expect(placedOf(f, behavior).overrides.held).toEqual({ target: NO_OBJECT, count: 5 });
});
it('keeps what a run holds when the model is read in again (editor run)', () => {
const f = makeSpriteScene('Crate');
const behavior = structHolder(f);
const run = startWorld(f, { behaviors: true });
run.frame();
run.world.syncToModel();
run.world.syncFromModel();
run.frame();
expect(run.world.log.at(-1)).toBe('Crate');
expect((instanceOf(run, behavior).props.held as unknown as Record<string, unknown>).target).toBe(
crates(f, run)[0],
);
});
it('empties objects however deep they sit, and leaves everything else as it is', () => {
const crate = fakeEntity('Crate');
const bag = {
target: crate,
held: { who: crate, n: 1 },
crowd: [crate, 2, { who: crate }],
plain: [1, 2],
} as unknown as PropertyBag;
const out = bagWithoutObjects(bag);
expect(out).toEqual({
target: NO_OBJECT,
held: { who: NO_OBJECT, n: 1 },
crowd: [NO_OBJECT, 2, { who: NO_OBJECT }],
plain: [1, 2],
});
expect(out.plain).toBe(bag.plain);
expect(JSON.parse(JSON.stringify(out))).toEqual(out);
});
it('reads a destroyed object as nothing alone, as the empty object inside a list or struct', () => {
const crate = fakeEntity('Crate');
const dead = () => false;
expect(liveValue(crate as never, dead)).toBeNull();
expect(liveValue(NO_OBJECT, () => true)).toBeNull();
expect(liveValue([crate, 1] as never, dead)).toEqual([NO_OBJECT, 1]);
expect(liveValue({ who: crate } as never, dead)).toEqual({ who: NO_OBJECT });
expect(liveValue([crate] as never, () => true)).toEqual([crate]);
});
it('names the objects of a list or struct when it reads as text', () => {
const crate = fakeEntity('Crate');
expect(asString({ who: crate, n: 1 } as never)).toBe('who: Crate, n: 1');
expect(asString([{ who: crate }] as never)).toBe('who: Crate');
});
});
describe('an object in a persistent Variables asset', () => {
/** On start: log what `target` holds, then spawn a Crate into it and into `crowd`, and count `score` up. */
function persistent() {
const f = makeSpriteScene('Crate');
const shared = makeVariables(f, 'Progress', ['score']);
f.stack.execute(new AddVariableCommand(shared.id, 'target', 'object'));
f.stack.execute(new AddVariableCommand(shared.id, 'crowd', 'list'));
f.stack.execute(new SetVariablesPersistentCommand(shared.id, true));
const behavior = makeBehavior(f, 'Spawner');
const edit = graphEditor(f, behavior);
const params = { [VARIABLES_PARAM]: shared.id };
const read = edit.variables(['score', 'target'], { params });
const log = edit.addNode('Log');
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const add = edit.addNode('Add', { b: 1 });
const one = edit.addNode('Fill', { count: 1 });
const write = edit.variables(['score', 'target', 'crowd'], { write: true, params });
edit.wire([
[edit.addNode('OnStart'), 'exec', log, 'exec'],
[read, 'target', log, 'message'],
[log, 'exec', spawn, 'exec'],
[spawn, 'exec', write, 'exec'],
[spawn, 'object', write, 'target'],
[spawn, 'object', one, 'value'],
[one, 'result', write, 'crowd'],
[read, 'score', add, 'a'],
[add, 'result', write, 'score'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return { f, shared };
}
it('is saved as nothing and starts the next run at nothing, beside the kept values', () => {
const { f, shared } = persistent();
let saved: SavedValues = {};
const area: SaveArea = { read: () => structuredClone(saved), write: (s) => (saved = structuredClone(s)) };
const first = startWorld(f, { behaviors: true, saves: area });
first.frame();
expect(first.world.log).toEqual(['']);
const live = first.behaviors!.shared.variables[shared.id]!;
expect(asString(live.target as never)).toBe('Crate');
expect(asString(live.crowd as never)).toBe('Crate');
expect(saved).toEqual({ [shared.id]: { score: 1, target: NO_OBJECT, crowd: [NO_OBJECT] } });
const second = startWorld(f, { behaviors: true, saves: area });
second.frame();
expect(second.world.log).toEqual(['']);
expect(second.behaviors!.shared.variables[shared.id]!.score).toBe(2);
});
});
describe('a kept call (#229)', () => {
it('hands a kept object on to the next call, and only a kept one', () => {
const crate = fakeEntity('Crate');
const left: PropertyBag = { target: NO_OBJECT, other: NO_OBJECT, held: { who: NO_OBJECT } };
writeTyped(left, 'target', crate);
writeTyped(left, 'other', crate);
writeTyped(left, 'held', { who: crate } as never);
const defaults = { target: NO_OBJECT, other: NO_OBJECT, held: { who: NO_OBJECT } };
const next = resumedVariables(defaults, left, ['target', 'held']);
expect(next.target).toBe(crate);
expect(next.other).toBe(NO_OBJECT);
expect((next.held as unknown as Record<string, unknown>).who).toBe(crate);
});
it('does not resume an object where the declaration now says text, or the other way round', () => {
const crate = fakeEntity('Crate');
const left: PropertyBag = { a: NO_OBJECT, b: '', c: { x: 1 } };
writeTyped(left, 'a', crate);
const next = resumedVariables({ a: '', b: NO_OBJECT, c: NO_OBJECT }, left, ['a', 'b', 'c']);
expect(next).toEqual({ a: '', b: NO_OBJECT, c: NO_OBJECT });
});
});