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>
155 lines
6.8 KiB
TypeScript
155 lines
6.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { Fill } from '../../src/core/graph/nodes/data/Fill';
|
||
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
|
||
import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt';
|
||
import { SetItem } from '../../src/core/graph/nodes/data/SetItem';
|
||
import { sameValue, tagged, textOf } from '../../src/core/model/Properties';
|
||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||
import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers';
|
||
|
||
const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y }));
|
||
|
||
/** A `list` variable holding struct items, the way a seeder writes it. */
|
||
const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never);
|
||
|
||
/** The node interface only needs `run`; nothing here reads the context. */
|
||
const run = (def: { run?: unknown }, inputs: Record<string, unknown>) =>
|
||
(def.run as (ctx: unknown, i: Record<string, unknown>) => { outputs: Record<string, unknown> })({}, inputs)
|
||
.outputs;
|
||
|
||
describe('a list whose items are structs', () => {
|
||
/** Each item keeps the fields it carries: there is no declaration left to fill or drop any. */
|
||
it('keeps every item the shape it arrived with', () => {
|
||
const def = shapesDef([{ x: 1, y: 2 }, { x: 3 }, { x: 4, y: 5, z: 9 }]);
|
||
expect(def.default).toEqual([{ x: 1, y: 2 }, { x: 3 }, { x: 4, y: 5, z: 9 }]);
|
||
});
|
||
|
||
it('keeps a field value as the kind it is, text included', () => {
|
||
expect(coerceValue(shapesDef([]), [{ x: '3', y: 4 }])).toEqual([{ x: '3', y: 4 }]);
|
||
});
|
||
|
||
it('leaves a number beside them a number, since an item answers for itself', () => {
|
||
expect(coerceValue(shapesDef([]), [7, { x: 1, y: 2 }])).toEqual([7, { x: 1, y: 2 }]);
|
||
});
|
||
|
||
it('survives a save and load with its items intact', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = makeBehavior(f, 'B');
|
||
const asset = f.project.assets.find((a) => a.id === behavior.id);
|
||
if (asset?.type !== 'behavior') throw new Error('behavior expected');
|
||
asset.variables = [shapesDef(cells([0, 1], [1, 1]))];
|
||
asset.props.shapes = cells([2, 3], [4, 5]);
|
||
|
||
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id);
|
||
if (loaded?.type !== 'behavior') throw new Error('behavior expected');
|
||
const def = loaded.variables.find((v) => v.key === 'shapes');
|
||
expect(def?.kind).toBe('list');
|
||
expect(def?.default).toEqual(cells([0, 1], [1, 1]));
|
||
expect(loaded.props.shapes).toEqual(cells([2, 3], [4, 5]));
|
||
});
|
||
|
||
it('compares by content, so an unchanged list is not reported as an override', () => {
|
||
expect(sameValue(cells([1, 2]), cells([1, 2]))).toBe(true);
|
||
expect(sameValue(cells([1, 2]), cells([1, 3]))).toBe(false);
|
||
expect(sameValue(cells([1, 2]), cells([1, 2], [3, 4]))).toBe(false);
|
||
});
|
||
|
||
it('reads as its items rather than as "[object Object]"', () => {
|
||
expect(textOf(cells([1, 2], [3, 4]))).toBe('x: 1, y: 2, x: 3, y: 4');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Node params have no schema to coerce against, so the loader alone decides how deep a value may
|
||
* be. The bound has to be the model's own: a list of structs of primitives, and no further.
|
||
*/
|
||
describe('an untyped slot on load', () => {
|
||
const paramsAfterLoad = (params: Record<string, unknown>) => {
|
||
const f = makeSpriteScene();
|
||
const behavior = makeBehavior(f, 'B');
|
||
const asset = f.project.assets.find((a) => a.id === behavior.id);
|
||
if (asset?.type !== 'behavior') throw new Error('behavior expected');
|
||
asset.graph.nodes.push({ id: 'n1', type: 'List', x: 0, y: 0, params: params as never });
|
||
|
||
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id);
|
||
if (loaded?.type !== 'behavior') throw new Error('behavior expected');
|
||
return loaded.graph.nodes.find((n) => n.id === 'n1')?.params ?? {};
|
||
};
|
||
|
||
it('keeps a list of structs', () => {
|
||
expect(paramsAfterLoad({ items: cells([1, 2], [3, 4]) }).items).toEqual(cells([1, 2], [3, 4]));
|
||
});
|
||
|
||
it('still keeps a struct holding a flat list, which it always did', () => {
|
||
expect(paramsAfterLoad({ v: { tags: ['a', 'b'], n: 1 } }).v).toEqual({ tags: ['a', 'b'], n: 1 });
|
||
});
|
||
|
||
/** Both were dropped while the depth was bounded; since #239 they come back as stored. */
|
||
it('keeps a struct inside a struct', () => {
|
||
expect(paramsAfterLoad({ v: { inner: { x: 1, deeper: { y: 2 } }, n: 1 } }).v).toEqual({
|
||
inner: { x: 1, deeper: { y: 2 } },
|
||
n: 1,
|
||
});
|
||
});
|
||
|
||
it('keeps a list inside a list item, and drops only what is no value at all', () => {
|
||
expect(paramsAfterLoad({ items: [{ deep: [1, [2, null]], x: 1 }, [null, 3]] }).items).toEqual([
|
||
{ deep: [1, [2]], x: 1 },
|
||
[3],
|
||
]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A tagged item is an editor thing: the graph has no colours and no pictures, only numbers and
|
||
* texts. Reading one out therefore hands over what it wraps – the id a sprite can be made to wear.
|
||
*/
|
||
describe('the list nodes with tagged items', () => {
|
||
it('hands a texture item out as the plain id it holds', () => {
|
||
expect(
|
||
run(ItemAt, { list: [tagged('texture', 'walk-up'), tagged('texture', 'fr-0-1')], index: 1 }),
|
||
).toEqual({
|
||
item: 'fr-0-1',
|
||
});
|
||
});
|
||
|
||
it('and a colour item as its number', () => {
|
||
expect(run(ItemAt, { list: [tagged('color', 0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
|
||
});
|
||
|
||
/** Or searching a list of pictures with the id a `Properties` node just read would never hit. */
|
||
it('finds a texture item by the id beside it', () => {
|
||
expect(run(IndexOf, { list: [tagged('texture', 'a'), tagged('texture', 'b')], value: 'b' })).toEqual({
|
||
index: 1,
|
||
});
|
||
});
|
||
|
||
/** The slot keeps its kind, so a list of pictures stays one after the graph has written to it. */
|
||
it('keeps a texture slot a texture when the graph writes a plain id into it', () => {
|
||
expect(run(SetItem, { list: [tagged('texture', 'a')], index: 0, value: 'b' })).toEqual({
|
||
result: [tagged('texture', 'b')],
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('the list nodes with struct items', () => {
|
||
it('hands a whole item out of the list', () => {
|
||
expect(run(ItemAt, { list: cells([1, 2], [3, 4]), index: 1 })).toEqual({ item: { x: 3, y: 4 } });
|
||
});
|
||
|
||
it('puts a struct back into a list instead of turning it into text', () => {
|
||
expect(run(SetItem, { list: cells([1, 2], [3, 4]), index: 0, value: { x: 9, y: 9 } })).toEqual({
|
||
result: cells([9, 9], [3, 4]),
|
||
});
|
||
});
|
||
|
||
it('finds a struct by its content, since two equal structs are never the same object', () => {
|
||
expect(run(IndexOf, { list: cells([1, 2], [3, 4]), value: { x: 3, y: 4 } })).toEqual({ index: 1 });
|
||
expect(run(IndexOf, { list: cells([1, 2]), value: { x: 9, y: 9 } })).toEqual({ index: -1 });
|
||
});
|
||
|
||
it('fills a list with a struct', () => {
|
||
expect(run(Fill, { value: { x: 1, y: 2 }, count: 2 })).toEqual({ result: cells([1, 2], [1, 2]) });
|
||
});
|
||
});
|