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>
142 lines
5.8 KiB
TypeScript
142 lines
5.8 KiB
TypeScript
/**
|
||
* The kind table is the one place a kind is described. Everything a kind needs – its lists, its
|
||
* name and mark, its empty value, its tag, its port – has to follow from the row alone, so each
|
||
* check here walks the table rather than naming a kind.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { declaredPortType, portDefOf } from '../../src/core/graph';
|
||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||
import { NO_OBJECT, tagOf, untag } from '../../src/core/model/Properties';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import {
|
||
coerceField,
|
||
DECLARED_PORT_KINDS,
|
||
emptyOf,
|
||
FIELD_KINDS,
|
||
isKindWith,
|
||
isTaggedKind,
|
||
KIND,
|
||
KINDS,
|
||
kindLabel,
|
||
kindMark,
|
||
NESTED_KINDS,
|
||
plainKindOf,
|
||
VARIABLE_KINDS,
|
||
} from '../../src/core/properties/kinds';
|
||
import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/listItems';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { makeBehavior, makeProject, roundTrip } from '../helpers';
|
||
|
||
/** The tagged kinds that hold a value; an object's place is always empty at rest (see below). */
|
||
const TAGGED = FIELD_KINDS.filter((kind) => isTaggedKind(kind) && kind !== 'object');
|
||
/** A value of the type a tagged kind travels as, distinct from the kind's empty value. */
|
||
const sampleOf = (kind: (typeof TAGGED)[number]) => (KIND[kind].travels === 'number' ? 0xff0000 : 'walk-up');
|
||
|
||
describe('the kind table', () => {
|
||
// The marks are held with the pictures that stand in for most of them (tests/ui/svg-icons.test.ts).
|
||
it('has a word for every kind a user can pick', () => {
|
||
const picked = new Set([...VARIABLE_KINDS, ...FIELD_KINDS, ...DECLARED_PORT_KINDS]);
|
||
for (const kind of picked) expect(kindLabel(kind), kind).not.toBe(kind);
|
||
expect(kindMark('no such kind')).toBe('?');
|
||
});
|
||
|
||
it('derives the kind lists from the roles, the depth rules on top', () => {
|
||
for (const row of KINDS) {
|
||
expect(VARIABLE_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('variable', row.kind));
|
||
expect(FIELD_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('field', row.kind));
|
||
expect(DECLARED_PORT_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('port', row.kind));
|
||
}
|
||
expect(NESTED_KINDS).toEqual([...FIELD_KINDS, 'list', 'struct']);
|
||
});
|
||
|
||
it('reads nothing as the empty value of a kind, and the empty value as itself', () => {
|
||
for (const kind of FIELD_KINDS) {
|
||
expect(coerceField(undefined, kind), kind).toBe(emptyOf(kind));
|
||
expect(coerceField(emptyOf(kind), kind), kind).toBe(emptyOf(kind));
|
||
}
|
||
});
|
||
|
||
it('reads a bare value as the untagged kind travelling its way', () => {
|
||
for (const kind of FIELD_KINDS) {
|
||
if (isTaggedKind(kind)) continue;
|
||
expect(plainKindOf(KIND[kind].travels), kind).toBe(kind);
|
||
expect(itemKindOf(emptyOf(kind)), kind).toBe(kind);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('a tagged kind', () => {
|
||
it('wraps what it reads, and reads back as itself', () => {
|
||
for (const kind of TAGGED) {
|
||
const item = makeItem(kind, sampleOf(kind));
|
||
expect(tagOf(item), kind).toBe(kind);
|
||
expect(itemKindOf(item), kind).toBe(kind);
|
||
expect(untag(item), kind).toBe(sampleOf(kind));
|
||
expect(listItemOf(item), kind).toEqual(item);
|
||
// The bare value beside it is the plain kind – which is why the tag is needed at all.
|
||
expect(itemKindOf(sampleOf(kind)), kind).toBe(plainKindOf(KIND[kind].travels));
|
||
}
|
||
});
|
||
|
||
it('holds nothing but its place when it is an object, whatever it is handed', () => {
|
||
for (const value of ['Crate', 3, undefined]) expect(makeItem('object', value)).toEqual(NO_OBJECT);
|
||
expect(itemKindOf(NO_OBJECT)).toBe('object');
|
||
expect(listItemOf(NO_OBJECT)).toEqual(NO_OBJECT);
|
||
});
|
||
|
||
it('survives a save and load in a list variable and in a node parameter', () => {
|
||
for (const kind of TAGGED) {
|
||
const item = makeItem(kind, sampleOf(kind));
|
||
const f = makeProject();
|
||
const behavior = makeBehavior(f, 'B');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'items', 'list'));
|
||
findAsset(f.project, behavior.id)!.props.items = [item, sampleOf(kind)];
|
||
expect(findAsset(roundTrip(f.project), behavior.id)?.props.items, kind).toEqual([item, sampleOf(kind)]);
|
||
|
||
const loaded = deserializeProject({
|
||
id: 'p',
|
||
assets: [
|
||
{
|
||
id: 'b',
|
||
type: 'behavior',
|
||
name: 'B',
|
||
graph: { nodes: [{ id: 'n', type: 'List', x: 0, y: 0, params: { value: [item] } }], edges: [] },
|
||
},
|
||
],
|
||
});
|
||
const asset = findAsset(loaded, 'b');
|
||
expect(asset?.type === 'behavior' && asset.graph.nodes[0]?.params.value, kind).toEqual([item]);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('a declared port', () => {
|
||
it('travels as the table says, and carries the field that edits it only when tagged', () => {
|
||
for (const kind of DECLARED_PORT_KINDS) {
|
||
expect(declaredPortType(kind), kind).toBe(KIND[kind].travels);
|
||
// An object has nothing to type in, so its port has no field.
|
||
const edited = isTaggedKind(kind) && kind !== 'object';
|
||
expect(portDefOf({ key: 'p', kind }).field?.kind, kind).toBe(edited ? kind : undefined);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('the literal nodes', () => {
|
||
it('are one per kind: named, typed and edited as the kind they hand out', () => {
|
||
for (const [type, kind] of [
|
||
['Number', 'number'],
|
||
['Boolean', 'boolean'],
|
||
['String', 'text'],
|
||
['Texture', 'texture'],
|
||
['StateMachine', 'statemachine'],
|
||
['List', 'list'],
|
||
] as const) {
|
||
const def = BUILT_IN_NODES.find((d) => d.type === type)!;
|
||
expect(def.label, type).toBe(kindLabel(kind));
|
||
expect(def.outputs[0]?.type, type).toBe(KIND[kind].travels);
|
||
expect(def.params[0]?.kind, type).toBe(kind);
|
||
}
|
||
});
|
||
});
|