Files
feuerwerk/tests/core/apply.test.ts
T
root d7c8d3e579 Measure a deviation against the base, not the innermost template
An instance edit was dropped as soon as its value matched the template
object. With nested containers the template is not what the value falls
back to: the outer container holds an edit for the object below it, and
that one wins. If the inner template happened to carry the same value the
user typed, the edit was thrown away and the value jumped to whatever the
container in between says.

Reported against the Jump-and-Run project: a coin narrowed to 8 inside
"level", then set back to the sprite's own 24 in the scene, snapped back
to 8. Reproduced through the inspector before touching anything.

InstanceTarget now offers `base`: the template with the edits of every
container in between woven in. Everything that judges deviation uses it -
setOverride/setName/setPosition, both Revert commands, and the gold marks
in the inspector. Writing still goes into the real template, which is
where "apply to container" belongs.

`base` is built on every read. Apply writes into the template and then
asks whether the edit still deviates, so a base captured beforehand would
answer for the template as it was - the e2e suite caught exactly that.

Also in here, because it corrects the commit before it: placing an asset
by double-clicking its row is gone, as asked for in asset-doppelklick.md.
Dragging is the only way to place now. That took AssetPanel.place, the
PlacementTarget interface, Editor.placementTarget and the two worldCenter()
methods that existed only to feed it. A double-click on a scene asset still
makes it the active scene.
2026-09-09 13:45:27 +02:00

233 lines
11 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.
import { describe, expect, it } from 'vitest';
import {
ApplyOverrideToAssetCommand,
ApplyOverrideToContainerCommand,
ApplyPositionToContainerCommand,
RevertOverrideToTemplateCommand,
RevertPositionToTemplateCommand,
} from '../../src/core/commands/applyCommands';
import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands';
import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { instanceChildId, instanceTargetOf } from '../../src/core/model/instance';
import { findAsset } from '../../src/core/model/Project';
import { makeSpriteScene, place } from '../helpers';
/**
* A container made from two boxes (a at (100,100), b at (10,0)) and a second placed instance of it.
* `a` is the content of the first instance, `a2` the same template object in the second – neither
* is stored in the scene, both are resolved from the template.
*/
function setup() {
const f = makeSpriteScene();
const create = new CreateContainerCommand(f.sceneId, [
place(f, f.spriteId, { x: 100, y: 100 }),
place(f, f.spriteId, { x: 10, y: 0 }),
]);
f.stack.execute(create);
const instance = place(f, create.assetId, { x: 1000, y: 0 });
const container = () => findAsset(f.project, create.assetId) as ContainerAsset;
const templateA = container().objects[0]!.id;
return {
...f,
instanceId: create.objectId,
instance,
container,
templateA,
a: instanceChildId(create.objectId, templateA),
a2: instanceChildId(instance, templateA),
templateOfA: () => container().objects.find((t) => t.id === templateA)!,
};
}
describe('template link', () => {
it('resolves the container and template for resolved content only', () => {
const { project, a, a2, sceneOf, instanceId, container, templateOfA } = setup();
const scene = sceneOf();
expect(instanceTargetOf(project, scene, a2)?.asset.id).toBe(container().id);
expect(instanceTargetOf(project, scene, a2)?.template).toBe(templateOfA());
expect(instanceTargetOf(project, scene, a)?.template).toBe(templateOfA());
// The container object itself is stored, so it has no template behind it.
expect(instanceTargetOf(project, scene, instanceId)).toBeUndefined();
});
it('drops the link once the instance is unpacked, even though the ids stay', () => {
const { project, stack, sceneId, sceneOf, instanceId, a } = setup();
stack.execute(new UnpackContainerCommand(sceneId, instanceId));
expect(sceneOf().objects.some((o) => o.id === a)).toBe(true);
expect(instanceTargetOf(project, sceneOf(), a)).toBeUndefined();
stack.undo();
expect(instanceTargetOf(project, sceneOf(), a)?.asset.type).toBe('container');
});
});
describe('position apply / revert', () => {
it('applies one axis to the template; instances still at the template value follow', () => {
const { stack, sceneId, a, a2, object, templateOfA } = setup();
expect(templateOfA().position).toEqual({ x: 45, y: 50, z: 0 });
// a2 was moved by hand on x → it keeps that; on y it still matches the template.
stack.execute(new MoveSceneObjectCommand(sceneId, a2, { x: 0, y: 50, z: 0 }));
stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 60, y: 70, z: 0 }));
stack.execute(new ApplyPositionToContainerCommand(sceneId, a, 'x'));
expect(templateOfA().position.x).toBe(60);
expect(object(a2).position.x).toBe(0); // modified on its own → untouched
stack.execute(new ApplyPositionToContainerCommand(sceneId, a, 'y'));
expect(templateOfA().position.y).toBe(70);
expect(object(a2).position.y).toBe(70); // was at the template value → follows
stack.undo();
expect(templateOfA().position.y).toBe(50);
expect(object(a2).position.y).toBe(50);
stack.undo();
expect(templateOfA().position.x).toBe(45);
expect(object(a).position.x).toBe(60); // the source object itself is not changed by apply
});
it('reverts an axis (or the whole position) to the template', () => {
const { stack, sceneId, a, object } = setup();
stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 1, y: 2, z: 3 }));
stack.execute(new RevertPositionToTemplateCommand(sceneId, a, 'z'));
expect(object(a).position).toEqual({ x: 1, y: 2, z: 0 });
stack.execute(new RevertPositionToTemplateCommand(sceneId, a));
expect(object(a).position).toEqual({ x: 45, y: 50, z: 0 });
stack.undo();
expect(object(a).position).toEqual({ x: 1, y: 2, z: 0 });
});
it('rejects objects that are not part of a container', () => {
const f = setup();
const loose = place(f, f.spriteId, { x: 0, y: 0 });
expect(() => f.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow(
/not part of a placed container/,
);
expect(f.sceneOf().objects.find((o) => o.id === loose)).toBeDefined();
});
});
describe('override apply / revert', () => {
it('applies an override to the container template and to instances that had no own value', () => {
const { stack, sceneId, a, a2, object, templateOfA, sceneOf, instanceId } = setup();
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
expect(templateOfA().overrides.width).toBe(77);
expect(object(a2).overrides.width).toBe(77);
expect(object(a).overrides.width).toBe(77);
// The template carries it now, so nothing is left to deviate – the edit has to be gone, not
// merely equal. It is dropped by comparing against a base read *after* the write.
expect(sceneOf().objects.find((o) => o.id === instanceId)!.instanceEdits).toEqual({});
stack.undo();
expect(templateOfA().overrides.width).toBeUndefined();
expect(object(a2).overrides.width).toBeUndefined();
expect(object(a).overrides.width).toBe(77);
});
it('leaves instances alone that already had their own override', () => {
const { stack, sceneId, a, a2, object, templateOfA } = setup();
stack.execute(new SetOverrideCommand(sceneId, a2, 'width', 5));
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
expect(templateOfA().overrides.width).toBe(77);
expect(object(a2).overrides.width).toBe(5);
});
it('applies an override to the asset, dropping the override on object and template', () => {
const { project, stack, sceneId, spriteId, a, a2, object, templateOfA } = setup();
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
stack.execute(new ApplyOverrideToAssetCommand(sceneId, a, 'width'));
const sprite = () => findAsset(project, spriteId)!;
expect(sprite().props.width).toBe(77);
expect(object(a).overrides.width).toBeUndefined();
expect(templateOfA().overrides.width).toBeUndefined();
expect(object(a2).overrides.width).toBeUndefined(); // shared the template value → follows the asset now
stack.undo();
expect(sprite().props.width).toBe(100);
expect(object(a).overrides.width).toBe(77);
expect(templateOfA().overrides.width).toBe(77);
expect(object(a2).overrides.width).toBe(77);
});
it('applies to the asset for a plain object too', () => {
const f = setup();
const { project, stack, sceneId, spriteId, object } = f;
const loose = place(f, spriteId, { x: 0, y: 0 });
stack.execute(new SetOverrideCommand(sceneId, loose, 'height', 33));
stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'height'));
expect(findAsset(project, spriteId)!.props.height).toBe(33);
expect(object(loose).overrides.height).toBeUndefined();
expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'nope'))).toThrow(
/not defined/,
);
});
it('reverts an override to the template value (or removes it when the template has none)', () => {
const { stack, sceneId, a, a2, object, templateOfA } = setup();
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
stack.execute(new SetOverrideCommand(sceneId, a2, 'width', 5));
stack.execute(new RevertOverrideToTemplateCommand(sceneId, a2, 'width'));
expect(object(a2).overrides.width).toBe(77);
expect(templateOfA().overrides.width).toBe(77);
stack.execute(new SetOverrideCommand(sceneId, a2, 'height', 9));
stack.execute(new RevertOverrideToTemplateCommand(sceneId, a2, 'height'));
expect(object(a2).overrides.height).toBeUndefined();
stack.undo();
expect(object(a2).overrides.height).toBe(9);
});
});
/**
* A container inside a container: the value a scene edit falls back to is the template *plus* the
* edits of every container in between – not the innermost template alone.
*/
function nested() {
const f = makeSpriteScene();
const inner = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]);
f.stack.execute(inner);
const innerAsset = () => findAsset(f.project, inner.assetId) as ContainerAsset;
const leaf = innerAsset().objects[0]!.id;
// The sprite is 100 wide anyway: an override carrying the asset's own value, the way putting a
// value back by hand in a container editor leaves one behind.
f.stack.execute(new SetOverrideCommand(inner.assetId, leaf, 'width', 100));
const outer = new CreateContainerCommand(f.sceneId, [inner.objectId]);
f.stack.execute(outer);
const outerAsset = () => findAsset(f.project, outer.assetId) as ContainerAsset;
const middle = outerAsset().objects[0]!.id;
// Inside the outer container, that one sprite is narrowed to 8.
f.stack.execute(new SetOverrideCommand(outer.assetId, instanceChildId(middle, leaf), 'width', 8));
return { ...f, leaf, deep: instanceChildId(outer.objectId, `${middle}/${leaf}`) };
}
describe('a base two levels up', () => {
it('keeps a scene value that equals the inner template but not the container in between', () => {
const { stack, sceneId, deep, object } = nested();
expect(object(deep).overrides.width).toBe(8);
stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 10));
expect(object(deep).overrides.width).toBe(10);
// 100 is what the innermost template says – but the container in between says 8, so this is
// a deviation and has to be kept. Comparing against the template alone dropped it.
stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 100));
expect(object(deep).overrides.width).toBe(100);
stack.undo();
expect(object(deep).overrides.width).toBe(10);
});
it('resets to the value the container in between gives, not to the inner template', () => {
const { stack, sceneId, deep, object } = nested();
stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 100));
stack.execute(new RevertOverrideToTemplateCommand(sceneId, deep, 'width'));
expect(object(deep).overrides.width).toBe(8);
});
it('drops an edit that matches the base, so the value follows again', () => {
const { stack, sceneId, deep, object, sceneOf } = nested();
stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 8));
const instance = sceneOf().objects.find((o) => o.id === deep.split('~')[0])!;
expect(instance.instanceEdits).toEqual({});
expect(object(deep).overrides.width).toBe(8);
});
});