Files
feuerwerk/tests/core/holders.test.ts
T
adminandClaude Opus 5 4f55f85ad4
game-maker / gate (push) Successful in 2m41s
game-maker / suite (push) Failing after 8m21s
Collision layers and masks: a collider carries a layer and a mask of the layers it meets, and the pair only meets when both masks name the other's layer - the rule is one pure module (src/core/model/collisionLayers.ts) with layerBit, layersMeet and the names a project starts with, so the three places that have to agree read the same function: the editor run through CollisionSystem, the Solids sweep per pair and Rapier, where the mask becomes the collider's interaction groups; the project file gained collisionLayers: ["Default"] (default one layer, no formatVersion bump), the collider schema two new kinds - layer as a dropdown of the project's layers and layerMask as a menu of tickboxes that stays open while it is ticked (Menu.ts learned rows that do not close on a pick) - and the names are edited through "Collision layers..." in the toolbar's Project menu, not a Project tab, which is the decision I made myself because the question ran into the MCP timeout: the dialog (src/editor/CollisionLayersDialog.ts) holds up to 16 names, Add layer appends "Layer <n>" and Apply writes all of them as one undo step (SetCollisionLayersCommand); a mask read from a file goes through a check that refuses anything that is not a finite number, because Number(null) is 0 and a collidesWith: null would silently have meant "meets nothing"; 9 new e2e checks in module 8 cover the menu entry, the dialog, Add layer, Apply and its undo, both inspector rows, the tickbox menu, and the mover walking straight through the crate that 8c blocked on once their layers no longer meet, and 21 new unit tests cover the rule, the schema and the command (#152)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:31:22 +02:00

172 lines
6.7 KiB
TypeScript

/**
* Container assets hold placed objects just like scenes do, so anything that walks "all placed
* objects" has to cover both. Plus: a rejected undo leaves the stacks usable.
*/
import { describe, expect, it } from 'vitest';
import { type Command, CommandError } from '../../src/core/commands/Command';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { findAsset, findObjectHolder, objectHolders } from '../../src/core/model/Project';
import { instanceChildId } from '../../src/core/model/instance';
import { findUsages } from '../../src/core/model/usages';
import { makeBehavior, makeSpriteScene, place } from '../helpers';
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */
function containerWithOverride() {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B', ['speed']);
const objectId = place(f, behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, 'speed', 42));
const container = new CreateContainerCommand(f.sceneId, [objectId]);
f.stack.execute(container);
const template = () => (findAsset(f.project, container.assetId) as ContainerAsset).objects[0]!;
// The object moved into the template; what the scene shows is resolved from it.
const placed = () => f.object(instanceChildId(container.objectId, template().id));
return { ...f, behaviorId: behavior.id, containerId: container.assetId, objectId, template, placed };
}
describe('object holders', () => {
it('counts scenes and container assets alike', () => {
const { project } = containerWithOverride();
expect(
objectHolders(project)
.map((h) => h.type)
.sort(),
).toEqual(['container', 'scene']);
});
it('resolves a scene or a container by id, but no other asset', () => {
const { project, sceneId, containerId, spriteId, behaviorId } = containerWithOverride();
expect(findObjectHolder(project, sceneId)?.type).toBe('scene');
expect(findObjectHolder(project, containerId)?.type).toBe('container');
expect(findObjectHolder(project, spriteId)).toBeUndefined();
expect(findObjectHolder(project, behaviorId)).toBeUndefined();
expect(findObjectHolder(project, 'nope')).toBeUndefined();
});
it('finds usages inside container templates, not just in scenes', () => {
const { project, behaviorId, containerId, template } = containerWithOverride();
// Only the template: what the scene shows is resolved from it, never stored.
const usages = findUsages(project, behaviorId);
expect(usages).toHaveLength(1);
expect(usages[0]!.holder.id).toBe(containerId);
expect(template().overrides.speed).toBe(42);
});
});
describe('RemoveVariableCommand', () => {
it('clears the override on container templates too, and puts it back on undo', () => {
const { stack, behaviorId, template, placed } = containerWithOverride();
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
expect(placed().overrides.speed).toBeUndefined();
expect(template().overrides.speed).toBeUndefined();
stack.undo();
expect(placed().overrides.speed).toBe(42);
expect(template().overrides.speed).toBe(42);
});
});
/**
* A behavior inside a placed container is not stored in the scene, and a value changed on it lives
* in the instance's `instanceEdits`. Anything rewriting overrides project-wide has to reach in
* there, or renaming a variable silently drops the value.
*/
describe('variables overridden on container content', () => {
function withInstanceEdit() {
const f = containerWithOverride();
f.stack.execute(new SetOverrideCommand(f.sceneId, f.placed().id, 'speed', 7));
const edits = () =>
f.sceneOf().objects.find((o) => o.id !== f.objectId)!.instanceEdits[f.template().id]!.overrides;
return { ...f, edits };
}
it('renames the key kept on the instance', () => {
const { stack, behaviorId, edits, placed } = withInstanceEdit();
expect(edits().speed).toBe(7);
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
expect(edits().speed).toBeUndefined();
expect(edits().velocity).toBe(7);
expect(placed().overrides.velocity).toBe(7);
stack.undo();
expect(edits().speed).toBe(7);
});
it('removes the value kept on the instance, and puts it back on undo', () => {
const { stack, behaviorId, edits } = withInstanceEdit();
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
expect(edits().speed).toBeUndefined();
stack.undo();
expect(edits().speed).toBe(7);
});
});
describe('RenameVariableCommand', () => {
it('renames the override on container templates too', () => {
const { stack, behaviorId, template } = containerWithOverride();
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
expect(template().overrides.speed).toBeUndefined();
expect(template().overrides.velocity).toBe(42);
stack.undo();
expect(template().overrides.speed).toBe(42);
expect(template().overrides.velocity).toBeUndefined();
});
});
class RefusingUndo implements Command {
readonly label = 'Refusing';
execute(project: { name: string }): void {
project.name = 'executed';
}
undo(): void {
throw new CommandError('target is gone');
}
}
describe('CommandStack', () => {
it('keeps a command on the undo stack when its undo is rejected', () => {
const { project, stack } = makeSpriteScene();
stack.execute(new RefusingUndo());
expect(stack.canUndo).toBe(true);
expect(() => stack.undo()).toThrow(CommandError);
// Not swallowed by the history: still undoable, and not queued for redo.
expect(stack.canUndo).toBe(true);
expect(stack.canRedo).toBe(false);
expect(project.name).toBe('executed');
});
it('keeps a command on the redo stack when its redo is rejected', () => {
const project = {
formatVersion: 1,
id: 'p',
name: 'P',
assets: [],
activeSceneId: '',
startSceneId: '',
plugins: [],
collisionLayers: ['Default'],
};
const stack = new CommandStack(project);
let failing = false;
stack.execute({
label: 'Toggle',
execute: () => {
if (failing) throw new CommandError('nope');
},
undo: () => {},
});
stack.undo();
failing = true;
expect(() => stack.redo()).toThrow(CommandError);
expect(stack.canRedo).toBe(true);
expect(stack.canUndo).toBe(false);
});
});