The instance inspector drew its own, flatter version of a struct: one row per field, no bundle head. It now draws the same block as the asset side, because there is only one implementation left - both call buildStructBlock. What the instance does not get is everything that touches the shape: renaming a field, '+ Field', and the per-field '×'. The shape belongs to the asset; a placed object overrides values. The revert and override marks sit on the head rather than on a field, since an override covers the whole bundle at once. Underneath this, editing a single field of an unoverridden struct used to blank its siblings: structFieldsOf filled the untouched fields from spec.read, which is object.overrides[key] and therefore undefined until something is overridden. structOf then handed back the empty shape. PreviewedValue carries 'current' now - the resolved value the row actually shows - and the fields come from that. Without it the old path stays, which is right for the asset side, whose prop bag is dense.
120 lines
5.5 KiB
TypeScript
120 lines
5.5 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(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(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()({ 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()({ key: 'x', kind: 'number' }).onChange(9);
|
||
objectHandlers()({ 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()({ 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()({ 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(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({ key: 'y', kind: 'number' }).onChange(8);
|
||
expect(resolveAssetProps(asset()).piece).toEqual({ x: 3, y: 8, tag: 'T' });
|
||
});
|
||
});
|