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>
This commit is contained in:
1 parent
483c34bb5c
commit
0c5ac32b59
29 files changed
+1046
-437
No files matched your search
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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<typeof f.stack.execute>[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, () => false)).toEqual([{ who: { at: [NO_OBJECT] } }]);
|
||||
expect(liveValue(held, () => true)).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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user