The value type was already there: StructValue, textOf, sameValue, writeTyped/mergeStruct and the struct port type came with the first part, and Get/Set variable carry any-ports, so a bundle already flowed through the graph unchanged. What was missing was the declaration. PropertyDef gains kind 'struct' with a list of fields, VARIABLE_KINDS a fifth kind, variableDef a fifth parameter. A declared field is a primitive - a list field would need a nested editor and therefore the path in the slot address, the same step "a list of structs" is waiting for. coerceValue now returns PropertyValue rather than FieldValue, the first value type that is not a field; none of its five callers break. In the inspector a struct variable is a block, not a tab. The list tab exists because hundreds of rows fit in no inspector row; a struct is the opposite case - few fields that fit under each other. It is the port editor Pack already has, one level down and with a value column: type, key and default value in one place. A placed object sees the same fields as plain value rows: overriding yes, declaring no. Adding or removing a field touches the declaration, the default, the behavior's value and every instance override in one command, so a stored value can never keep a shape its declaration no longer promises. The row handlers read the bundle at the moment of the change rather than closing over a snapshot - a field writes the whole bundle, so with a snapshot the second edited row revives the first one's old value. defSignature carries the fields, otherwise a new row appears only at the next rebuild. Fixes a fixture fault this uncovered: step 10 pushed its text object into the scene without instanceEdits, which overrideSites walks with Object.entries. AddStructFieldCommand is the first caller to reach it from that state; the step was green alone and failed three checks in the full suite. Moves the dynamic-ports note to done - all three of its parts are built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
5.3 KiB
TypeScript
117 lines
5.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
AddStructFieldCommand,
|
|
AddVariableCommand,
|
|
RemoveStructFieldCommand,
|
|
RenameStructFieldCommand,
|
|
} from '../../src/core/commands/variableCommands';
|
|
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
|
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
|
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
|
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
|
|
|
const findObject = (project: Project, id: string) =>
|
|
objectHolders(project)
|
|
.flatMap((holder) => holder.objects)
|
|
.find((o) => o.id === id);
|
|
|
|
const structDef = (project: Project, assetId: string, key: string) => {
|
|
const asset = findAsset(project, assetId);
|
|
const def = asset?.type === 'behavior' ? asset.variables.find((v) => v.key === key) : undefined;
|
|
return def?.kind === 'struct' ? def : undefined;
|
|
};
|
|
|
|
/** A behavior with a struct variable `piece`, placed once with an override of its own. */
|
|
function withStruct() {
|
|
const f = makeSpriteScene();
|
|
const behavior = makeBehavior(f, 'B');
|
|
f.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
|
|
f.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key: 'x', kind: 'number' }));
|
|
const object = place(f, behavior.id, { x: 0, y: 0 });
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'piece', { x: 7 }));
|
|
return { f, behaviorId: behavior.id, object };
|
|
}
|
|
|
|
describe('a variable of type struct', () => {
|
|
it('starts with no fields at all, so nothing is invented for it', () => {
|
|
const f = makeSpriteScene();
|
|
const behavior = makeBehavior(f, 'B');
|
|
f.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
|
|
expect(structDef(f.project, behavior.id, 'piece')?.fields).toEqual([]);
|
|
expect(findAsset(f.project, behavior.id)?.props.piece).toEqual({});
|
|
});
|
|
|
|
it('grows a new field in the declaration, in its own value and in every override at once', () => {
|
|
const { f, behaviorId, object } = withStruct();
|
|
f.stack.execute(new AddStructFieldCommand(behaviorId, 'piece', { key: 'name', kind: 'text' }));
|
|
|
|
expect(structDef(f.project, behaviorId, 'piece')?.fields).toEqual([
|
|
{ key: 'x', kind: 'number' },
|
|
{ key: 'name', kind: 'text' },
|
|
]);
|
|
// The override deviates in `x` and must keep that while gaining the new field.
|
|
expect(findObject(f.project, object)?.overrides.piece).toEqual({ x: 7, name: '' });
|
|
expect(findAsset(f.project, behaviorId)?.props.piece).toEqual({ x: 0, name: '' });
|
|
});
|
|
|
|
it('takes the field back out on undo, so no value keeps a shape the declaration dropped', () => {
|
|
const { f, behaviorId, object } = withStruct();
|
|
f.stack.execute(new AddStructFieldCommand(behaviorId, 'piece', { key: 'name', kind: 'text' }));
|
|
f.stack.undo();
|
|
|
|
expect(structDef(f.project, behaviorId, 'piece')?.fields).toEqual([{ key: 'x', kind: 'number' }]);
|
|
expect(findAsset(f.project, behaviorId)?.props.piece).toEqual({ x: 0 });
|
|
expect(findObject(f.project, object)?.overrides.piece).toEqual({ x: 7 });
|
|
});
|
|
|
|
it('gives an override its own removed value back, not the default', () => {
|
|
const { f, behaviorId, object } = withStruct();
|
|
f.stack.execute(new RemoveStructFieldCommand(behaviorId, 'piece', 'x'));
|
|
expect(findObject(f.project, object)?.overrides.piece).toEqual({});
|
|
|
|
f.stack.undo();
|
|
// 7 is the override's own value; the behavior's default for the same field is 0.
|
|
expect(findObject(f.project, object)?.overrides.piece).toEqual({ x: 7 });
|
|
expect(findAsset(f.project, behaviorId)?.props.piece).toEqual({ x: 0 });
|
|
});
|
|
|
|
it('carries the value along when a field is renamed, and back again on undo', () => {
|
|
const { f, behaviorId, object } = withStruct();
|
|
f.stack.execute(new RenameStructFieldCommand(behaviorId, 'piece', 'x', 'column'));
|
|
|
|
expect(structDef(f.project, behaviorId, 'piece')?.fields).toEqual([{ key: 'column', kind: 'number' }]);
|
|
expect(findObject(f.project, object)?.overrides.piece).toEqual({ column: 7 });
|
|
|
|
f.stack.undo();
|
|
expect(findObject(f.project, object)?.overrides.piece).toEqual({ x: 7 });
|
|
});
|
|
|
|
it('refuses a second field of the same name', () => {
|
|
const { f, behaviorId } = withStruct();
|
|
expect(() =>
|
|
f.stack.execute(new AddStructFieldCommand(behaviorId, 'piece', { key: 'x', kind: 'text' })),
|
|
).toThrow(/already exists/);
|
|
});
|
|
|
|
it('survives saving and loading with its declaration, not just its value', () => {
|
|
const { f, behaviorId, object } = withStruct();
|
|
f.stack.execute(new AddStructFieldCommand(behaviorId, 'piece', { key: 'name', kind: 'text' }));
|
|
|
|
const loaded = roundTrip(f.project);
|
|
expect(structDef(loaded, behaviorId, 'piece')?.fields).toEqual([
|
|
{ key: 'x', kind: 'number' },
|
|
{ key: 'name', kind: 'text' },
|
|
]);
|
|
expect(findObject(loaded, object)?.overrides.piece).toEqual({ x: 7, name: '' });
|
|
});
|
|
|
|
it('coerces a stored value to the shape its declaration promises', () => {
|
|
const def = variableDef('piece', 'struct', undefined, undefined, [
|
|
{ key: 'x', kind: 'number' },
|
|
{ key: 'on', kind: 'boolean' },
|
|
]);
|
|
// An unknown key is dropped, a missing one starts empty, and '3' is a number here.
|
|
expect(coerceValue(def, { x: '3', gone: 9 })).toEqual({ x: 3, on: false });
|
|
});
|
|
});
|