Files
feuerwerk/tests/editor/struct-field-edit.test.ts
T
root 9f482573e5 Show a placed object's struct the way its asset does
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.
2026-09-10 23:05:02 +02:00

120 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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' });
});
});