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>
140 lines
6.1 KiB
TypeScript
140 lines
6.1 KiB
TypeScript
/**
|
||
* A list declares nothing about what it holds: every item carries its own kind, read back off the
|
||
* value – and a color or a texture says so, or it would be a plain number or text.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { type PropertyValue, tagged, untag } from '../../src/core/model/Properties';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { itemKindOf, makeItem, structItemFields } from '../../src/core/properties/listItems';
|
||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||
|
||
/** A behavior with a list variable `stops`, placed once with an override of its own. */
|
||
function withList(value: unknown = [1, 2]) {
|
||
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 = value as never;
|
||
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('the kind of one list item', () => {
|
||
it('is read off the item itself, a color through its tag', () => {
|
||
expect([7, 'a', true, tagged('color', 0xff0000), { x: 1 }].map(itemKindOf)).toEqual([
|
||
'number',
|
||
'text',
|
||
'boolean',
|
||
'color',
|
||
'struct',
|
||
]);
|
||
});
|
||
|
||
it('lets one list hold all of them at once, through a write and a read back', () => {
|
||
const mixed = [7, 'tee', true, tagged('color', 0xff0000), { x: 1, y: 'two' }];
|
||
const { f, behaviorId } = withList(mixed);
|
||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(mixed);
|
||
});
|
||
|
||
/** The one kind JSON cannot tell apart from a number, which is why it is tagged at all. */
|
||
it('keeps a color apart from the number beside it', () => {
|
||
const { f, behaviorId } = withList([tagged('color', 0xff0000), 0xff0000]);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back.map(itemKindOf)).toEqual(['color', 'number']);
|
||
expect(back.map(untag)).toEqual([0xff0000, 0xff0000]);
|
||
});
|
||
|
||
/**
|
||
* The second kind JSON cannot tell apart on its own: an asset id is a text like any other. What a
|
||
* sprite wears is one id whether it names a texture or one of its frames.
|
||
*/
|
||
it('keeps a texture apart from the text beside it, and reads back as its id', () => {
|
||
const { f, behaviorId } = withList([tagged('texture', 'tex-walk'), 'tex-walk']);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back.map(itemKindOf)).toEqual(['texture', 'text']);
|
||
expect(back.map(untag)).toEqual(['tex-walk', 'tex-walk']);
|
||
});
|
||
|
||
it('turns a text into a texture item and back without losing the id', () => {
|
||
expect(makeItem('texture', 'frame-26')).toEqual(tagged('texture', 'frame-26'));
|
||
expect(makeItem('text', tagged('texture', 'frame-26'))).toBe('frame-26');
|
||
});
|
||
|
||
/** A struct item's field is undeclared too, so a texture there keeps its tag, like a color does. */
|
||
it('stays a texture inside a struct item', () => {
|
||
const { f, behaviorId } = withList([{ pic: tagged('texture', 'frame-26'), n: 1 }]);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back).toEqual([{ pic: tagged('texture', 'frame-26'), n: 1 }]);
|
||
expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([
|
||
'pic:texture',
|
||
'n:number',
|
||
]);
|
||
});
|
||
|
||
it('changes for one item without touching its neighbours', () => {
|
||
const { f, behaviorId } = withList([1, 2]);
|
||
const asset = findAsset(f.project, behaviorId)!;
|
||
const list = asset.props.stops as PropertyValue[];
|
||
asset.props.stops = [makeItem('text', list[0]), list[1]] as never;
|
||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(['1', 2]);
|
||
});
|
||
|
||
it('gives a struct item a shape of its own, each field with its own kind', () => {
|
||
const { f, behaviorId } = withList([{ x: 1, label: 'a' }, { x: 2 }]);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back.map((item) => structItemFields(item).map((field) => `${field.key}:${field.kind}`))).toEqual([
|
||
['x:number', 'label:text'],
|
||
['x:number'],
|
||
]);
|
||
});
|
||
|
||
/** Once dropped as one level too deep; since #239 nothing bounds the depth. */
|
||
it('keeps a list inside a struct item, and a list in a list', () => {
|
||
const { f, behaviorId } = withList([{ x: 1, deep: [1, [2, tagged('color', 0xff0000)]] }, [[3]]]);
|
||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([
|
||
{ x: 1, deep: [1, [2, tagged('color', 0xff0000)]] },
|
||
[[3]],
|
||
]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A node parameter meets no schema on the way in – `asStoredValue` is the only rule it passes. It is
|
||
* therefore the one place where the store itself has to know the tags.
|
||
*/
|
||
describe('a list in a node parameter, where nothing is declared', () => {
|
||
const loadParam = (value: unknown) =>
|
||
deserializeProject({
|
||
id: 'p',
|
||
assets: [
|
||
{
|
||
id: 'b',
|
||
type: 'behavior',
|
||
name: 'B',
|
||
graph: { nodes: [{ id: 'n', type: 'List', x: 0, y: 0, params: { value } }], edges: [] },
|
||
},
|
||
],
|
||
});
|
||
const paramOf = (value: unknown) => {
|
||
const asset = findAsset(loadParam(value), 'b');
|
||
return asset?.type === 'behavior' ? asset.graph.nodes[0]?.params.value : undefined;
|
||
};
|
||
|
||
it('keeps a tagged texture an item of its own', () => {
|
||
expect(paramOf([tagged('texture', 'frame-26'), 'frame-26'])).toEqual([
|
||
tagged('texture', 'frame-26'),
|
||
'frame-26',
|
||
]);
|
||
});
|
||
|
||
it('and one inside a struct item as well', () => {
|
||
expect(paramOf([{ pic: tagged('texture', 'frame-26'), n: 1 }])).toEqual([
|
||
{ pic: tagged('texture', 'frame-26'), n: 1 },
|
||
]);
|
||
});
|
||
});
|