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>
124 lines
5.6 KiB
TypeScript
124 lines
5.6 KiB
TypeScript
/**
|
||
* Editing one field of a struct rebuilds the whole bundle – an override is one value per property
|
||
* key, not per field. The question here is what the untouched fields are filled from: an override
|
||
* is sparse and starts out unset, so only the *resolved* value carries the asset's own numbers.
|
||
*/
|
||
import { beforeEach, describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import type { PropertyValue } from '../../src/core/model/Properties';
|
||
import type { SceneObject } from '../../src/core/model/SceneObject';
|
||
import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project';
|
||
import { requirePropDef } from '../../src/core/commands/require';
|
||
import { resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve';
|
||
import { structFieldsOf } from '../../src/editor/inspector/previewHandlers';
|
||
import { EditorState } from '../../src/editor/state';
|
||
|
||
const FIELDS = [
|
||
{ key: 'x', kind: 'number' },
|
||
{ key: 'y', kind: 'number' },
|
||
{ key: 'tag', kind: 'text' },
|
||
] as const;
|
||
|
||
function setup() {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
const scene = new CreateAssetCommand('scene', 'Level');
|
||
const behavior = new CreateAssetCommand('behavior', 'B');
|
||
// A rejected command only returns false, so an unasserted setup would let the tests below pass
|
||
// for the wrong reason.
|
||
expect(state.execute(scene)).toBe(true);
|
||
expect(state.execute(behavior)).toBe(true);
|
||
expect(state.execute(new AddVariableCommand(behavior.asset.id, 'piece', 'struct'))).toBe(true);
|
||
for (const field of FIELDS)
|
||
expect(state.execute(new AddStructFieldCommand(behavior.asset.id, 'piece', { ...field }))).toBe(true);
|
||
// The asset's own values: what the inspector shows for an object that overrides nothing.
|
||
expect(
|
||
state.execute(new SetAssetPropertyCommand(behavior.asset.id, 'piece', { x: 3, y: 4, tag: 'T' })),
|
||
).toBe(true);
|
||
const place = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 });
|
||
expect(state.execute(place)).toBe(true);
|
||
return { state, sceneId: scene.asset.id, behaviorId: behavior.asset.id, objectId: place.objectId };
|
||
}
|
||
|
||
describe('editing one field of a struct', () => {
|
||
let f: ReturnType<typeof setup>;
|
||
beforeEach(() => {
|
||
f = setup();
|
||
});
|
||
|
||
const asset = () => findAsset(f.state.project, f.behaviorId)!;
|
||
const object = (): SceneObject =>
|
||
findScene(f.state.project, f.sceneId)!.objects.find((o) => o.id === f.objectId)!;
|
||
const structDef = () => {
|
||
const def = requirePropDef(f.state.project, asset(), 'piece');
|
||
if (def.kind !== 'struct') throw new Error('piece is not a struct');
|
||
return def;
|
||
};
|
||
|
||
/** The spec `objectSection` builds for an override, resolved value included. */
|
||
const objectHandlers = () =>
|
||
structFieldsOf<SceneObject>({ state: f.state }, structDef(), {
|
||
target: object,
|
||
read: (o) => o.overrides.piece,
|
||
current: (o) => resolveSceneObjectProps(f.state.project, asset(), o).piece,
|
||
write: (o, value) => (o.overrides.piece = value),
|
||
remove: (o) => delete o.overrides.piece,
|
||
commit: (value) => new SetOverrideCommand(f.sceneId, f.objectId, 'piece', value),
|
||
});
|
||
|
||
it('fills the untouched fields of a first override from the asset, not from an empty bundle', () => {
|
||
objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9);
|
||
expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'T' });
|
||
});
|
||
|
||
it('leaves the others alone on a second edit, and undo goes back one field at a time', () => {
|
||
objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9);
|
||
objectHandlers()(['tag'], { key: 'tag', kind: 'text' }).onChange('hi');
|
||
expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'hi' });
|
||
|
||
f.state.commands.undo();
|
||
expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'T' });
|
||
f.state.commands.undo();
|
||
expect(object().overrides.piece).toBeUndefined();
|
||
});
|
||
|
||
it('builds on the preview while typing, and still undoes to before it', () => {
|
||
const handlers = objectHandlers()(['x'], { key: 'x', kind: 'number' });
|
||
handlers.onInput(1);
|
||
handlers.onInput(12);
|
||
handlers.onChange(12);
|
||
expect(object().overrides.piece).toEqual({ x: 12, y: 4, tag: 'T' });
|
||
|
||
f.state.commands.undo();
|
||
expect(object().overrides.piece).toBeUndefined();
|
||
});
|
||
|
||
it('copies those values in rather than keeping them tied to the asset', () => {
|
||
objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9);
|
||
f.state.execute(new SetAssetPropertyCommand(f.behaviorId, 'piece', { x: 3, y: 99, tag: 'T' }));
|
||
// The whole bundle is overridden now, so the asset moving on no longer reaches y.
|
||
expect(resolveSceneObjectProps(f.state.project, asset(), object()).piece).toEqual({
|
||
x: 9,
|
||
y: 4,
|
||
tag: 'T',
|
||
});
|
||
});
|
||
|
||
it('takes the stored value where that already is the resolved one – the dense asset bag', () => {
|
||
const handlers = structFieldsOf<{ props: Record<string, PropertyValue> }>(
|
||
{ state: f.state },
|
||
structDef(),
|
||
{
|
||
target: () => asset(),
|
||
read: (a) => a.props.piece,
|
||
write: (a, value) => (a.props.piece = value),
|
||
commit: (value) => new SetAssetPropertyCommand(f.behaviorId, 'piece', value),
|
||
},
|
||
);
|
||
handlers(['y'], { key: 'y', kind: 'number' }).onChange(8);
|
||
expect(resolveAssetProps(f.state.project, asset()).piece).toEqual({ x: 3, y: 8, tag: 'T' });
|
||
});
|
||
});
|