Add ESLint and Prettier, and close the node test gaps
Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f140c2fd77
commit
d54a620ebd
113 files changed
+5049
-666
No files matched your search
@@ -26,7 +26,9 @@ function setup() {
|
||||
const instance = place(f, create.assetId, { x: 1000, y: 0 });
|
||||
const container = () => findAsset(f.project, create.assetId) as ContainerAsset;
|
||||
const templateOfA = () => container().objects.find((t) => t.id === f.object(a).templateId)!;
|
||||
const a2 = f.sceneOf().objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id;
|
||||
const a2 = f
|
||||
.sceneOf()
|
||||
.objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id;
|
||||
return { ...f, a, b, a2, container, templateOfA };
|
||||
}
|
||||
|
||||
@@ -80,7 +82,9 @@ describe('position apply / revert', () => {
|
||||
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.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow(
|
||||
/not part of a placed container/,
|
||||
);
|
||||
expect(f.sceneOf().objects.find((o) => o.id === loose)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -133,7 +137,9 @@ describe('override apply / revert', () => {
|
||||
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/);
|
||||
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)', () => {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The event nodes have no `run` – the BehaviorSystem decides when they fire. These tests drive a
|
||||
* real world and check that timing: key edges vs. held keys, the timer's own clock, and the
|
||||
* difference between a collision that starts and one that continues.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
|
||||
|
||||
/** A scene with a 100×60 sprite and a behavior placed below it, ready to have an event wired up. */
|
||||
function scene(): SceneFixture & { behavior: BehaviorAsset; box: string } {
|
||||
const f = makeSpriteScene('Box');
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||
return { ...f, behavior, box };
|
||||
}
|
||||
|
||||
/** Wires `<event> → Log(message)` so a fired event shows up as one entry in `world.log`. */
|
||||
function logOn(
|
||||
f: SceneFixture & { behavior: BehaviorAsset },
|
||||
event: string,
|
||||
params: Record<string, string | number> = {},
|
||||
message = event,
|
||||
): void {
|
||||
const edit = graphEditor(f, f.behavior);
|
||||
const node = edit.addNode(event, params);
|
||||
const log = edit.addNode('Log', { message });
|
||||
edit.connect(node, 'exec', log, 'exec');
|
||||
}
|
||||
|
||||
describe('key events', () => {
|
||||
it('On key down fires once per press, not while the key stays held', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
frame();
|
||||
expect(world.log).toEqual([]);
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['down']);
|
||||
|
||||
input.press('a'); // auto-repeat while held: no new edge
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual(['down']);
|
||||
|
||||
input.release('a');
|
||||
input.press('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['down', 'down']);
|
||||
});
|
||||
|
||||
it('On key up fires once when the key is released', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual([]);
|
||||
|
||||
input.release('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['up']);
|
||||
|
||||
frame(); // the edge is consumed, not sticky
|
||||
expect(world.log).toEqual(['up']);
|
||||
});
|
||||
|
||||
it('On key held fires every frame the key is down', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual(['held', 'held', 'held']);
|
||||
|
||||
input.release('a');
|
||||
frame();
|
||||
expect(world.log).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('only the node whose key matches fires', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnKeyDown', { key: 'a' }, 'a');
|
||||
logOn(f, 'OnKeyDown', { key: 'b' }, 'b');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('b');
|
||||
frame();
|
||||
expect(world.log).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('losing the window releases every held key, so nothing stays down behind the user’s back', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
|
||||
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['held']);
|
||||
|
||||
input.releaseAll();
|
||||
frame();
|
||||
expect(world.log).toEqual(['held', 'up']);
|
||||
frame();
|
||||
expect(world.log).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('On timer', () => {
|
||||
it('fires on its own clock, independent of the frame rate', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnTimer', { interval: 1 }, 'tick');
|
||||
const { world, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
for (let i = 0; i < 4; i++) frame(0.2); // 0.8 s
|
||||
expect(world.log).toEqual([]);
|
||||
frame(0.2); // 1.0 s
|
||||
expect(world.log).toEqual(['tick']);
|
||||
for (let i = 0; i < 5; i++) frame(0.2); // 2.0 s
|
||||
expect(world.log).toEqual(['tick', 'tick']);
|
||||
});
|
||||
|
||||
it('catches up when one frame is longer than the interval', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
|
||||
const { world, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
frame(2); // a hitch worth four intervals
|
||||
expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']);
|
||||
});
|
||||
|
||||
it('two timers keep separate clocks', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnTimer', { interval: 1 }, 'slow');
|
||||
logOn(f, 'OnTimer', { interval: 0.5 }, 'fast');
|
||||
const { world, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
frame(0.5);
|
||||
expect(world.log).toEqual(['fast']);
|
||||
frame(0.5);
|
||||
expect(world.log).toEqual(['fast', 'slow', 'fast']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('On collision stay', () => {
|
||||
it('fires every frame the overlap lasts, while enter fires only on the first one', () => {
|
||||
const f = makeSpriteScene('Crate');
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||||
const behavior = makeBehavior(f, 'Hit');
|
||||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||||
f.stack.execute(new AddColliderCommand(f.sceneId, a));
|
||||
f.stack.execute(new AddColliderCommand(f.sceneId, b));
|
||||
place(f, behavior.id, { x: 0, y: 0 }, a);
|
||||
const fixture = { ...f, behavior };
|
||||
logOn(fixture, 'OnCollisionEnter', {}, 'enter');
|
||||
logOn(fixture, 'OnCollisionStay', {}, 'stay');
|
||||
logOn(fixture, 'OnCollisionExit', {}, 'exit');
|
||||
|
||||
const { world, frame } = startWorld(f, { collisions: true, behaviors: true });
|
||||
const crateB = world.findByObjectId(b)!;
|
||||
|
||||
frame(0.1);
|
||||
expect(world.log).toEqual([]);
|
||||
|
||||
crateB.x = 50; // now overlapping
|
||||
frame(0.1);
|
||||
expect(world.log).toEqual(['enter']);
|
||||
frame(0.1);
|
||||
frame(0.1);
|
||||
expect(world.log).toEqual(['enter', 'stay', 'stay']);
|
||||
|
||||
crateB.x = 500;
|
||||
frame(0.1);
|
||||
expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']);
|
||||
frame(0.1);
|
||||
expect(world.log).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a switched-off behavior receives no events', () => {
|
||||
it('ignores keys and timers until it is switched back on', () => {
|
||||
const f = scene();
|
||||
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
|
||||
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
const logic = world.entities.find((e) => e.name === 'Logic')!;
|
||||
|
||||
logic.props.enabled = false;
|
||||
input.press('a');
|
||||
frame(1);
|
||||
expect(world.log).toEqual([]);
|
||||
|
||||
logic.props.enabled = true;
|
||||
input.release('a');
|
||||
input.press('a');
|
||||
frame(1);
|
||||
// The timer starts counting when the behavior runs again – no backlog from the off period.
|
||||
expect(world.log).toEqual(['down', 'tick', 'tick']);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,12 @@ import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { axisCorrection, axisSeparation, colliderShape, shapesOverlap } from '../../src/core/properties/collider';
|
||||
import {
|
||||
axisCorrection,
|
||||
axisSeparation,
|
||||
colliderShape,
|
||||
shapesOverlap,
|
||||
} from '../../src/core/properties/collider';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
|
||||
@@ -92,8 +97,15 @@ describe('AddColliderCommand', () => {
|
||||
|
||||
it('refuses a collider on a collider and survives a round trip', () => {
|
||||
const { project, stack, sceneId, colA } = setup();
|
||||
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(/cannot have a collider/);
|
||||
expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 });
|
||||
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(
|
||||
/cannot have a collider/,
|
||||
);
|
||||
expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({
|
||||
shape: 'box',
|
||||
width: 100,
|
||||
height: 60,
|
||||
radius: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +142,15 @@ describe('CollisionSystem', () => {
|
||||
world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap
|
||||
world.tick(0.1);
|
||||
expect(collisions.enters).toHaveLength(2); // circle B against both colliders of A
|
||||
expect(collisions.enters.every((p) => p.a.objectId === colA.objectId || p.b.objectId === colA.objectId || p.a.objectId === second.objectId || p.b.objectId === second.objectId)).toBe(true);
|
||||
expect(
|
||||
collisions.enters.every(
|
||||
(p) =>
|
||||
p.a.objectId === colA.objectId ||
|
||||
p.b.objectId === colA.objectId ||
|
||||
p.a.objectId === second.objectId ||
|
||||
p.b.objectId === second.objectId,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,7 +251,7 @@ describe('move and slide', () => {
|
||||
});
|
||||
|
||||
describe('collision events in behaviors', () => {
|
||||
it('fires On collision enter/exit on the body\'s behavior with the other body; Is touching agrees', () => {
|
||||
it("fires On collision enter/exit on the body's behavior with the other body; Is touching agrees", () => {
|
||||
const s = setup();
|
||||
const { behavior, b, object } = s;
|
||||
// On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
|
||||
|
||||
@@ -22,7 +22,12 @@ describe('asset commands', () => {
|
||||
const { project, sceneId, spriteId } = makeSpriteScene();
|
||||
expect(project.assets).toHaveLength(2);
|
||||
expect(project.activeSceneId).toBe(sceneId);
|
||||
expect(findAsset(project, spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
|
||||
expect(findAsset(project, spriteId)?.props).toEqual({
|
||||
enabled: true,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0xe0553f,
|
||||
});
|
||||
});
|
||||
|
||||
it('renames and sets properties with undo/redo', () => {
|
||||
@@ -100,7 +105,9 @@ describe('scene object commands', () => {
|
||||
expect(object.position).toEqual({ x: 5, y: 6, z: 0 });
|
||||
expect(object.overrides).toEqual({});
|
||||
|
||||
expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/);
|
||||
expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(
|
||||
/cannot be placed/,
|
||||
);
|
||||
expect(sceneOf().objects).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
|
||||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||
import { MoveSceneObjectCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import {
|
||||
MoveSceneObjectCommand,
|
||||
RemoveSceneObjectsCommand,
|
||||
SetParentCommand,
|
||||
} from '../../src/core/commands/sceneObjectCommands';
|
||||
import { worldPositionOf } from '../../src/core/graph';
|
||||
import type { ContainerAsset } from '../../src/core/model/Asset';
|
||||
import { subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
|
||||
|
||||
@@ -6,7 +6,17 @@ import { isHandleEnabled } from '../../src/core/graph';
|
||||
import { findAsset, findScene } from '../../src/core/model/Project';
|
||||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
|
||||
import { fakeHandle, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip, runNode, startWorld } from '../helpers';
|
||||
import {
|
||||
fakeHandle,
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeProject,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
roundTrip,
|
||||
runNode,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
/** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders, in a running world. */
|
||||
function wallAndMover() {
|
||||
@@ -17,7 +27,12 @@ function wallAndMover() {
|
||||
f.stack.execute(new AddColliderCommand(f.sceneId, moverId));
|
||||
f.stack.execute(wallCollider);
|
||||
const { world, collisions } = startWorld(f, { collisions: true });
|
||||
return { world, collisions, mover: world.findByObjectId(moverId)!, wall: world.findByObjectId(wallCollider.objectId)! };
|
||||
return {
|
||||
world,
|
||||
collisions,
|
||||
mover: world.findByObjectId(moverId)!,
|
||||
wall: world.findByObjectId(wallCollider.objectId)!,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the enabled switch', () => {
|
||||
|
||||
+53
-11
@@ -1,12 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { AddNodeCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
||||
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
AddNodeCommand,
|
||||
DisconnectCommand,
|
||||
RemoveNodeCommand,
|
||||
SetNodeParamCommand,
|
||||
} from '../../src/core/commands/graphCommands';
|
||||
import {
|
||||
AddVariableCommand,
|
||||
RemoveVariableCommand,
|
||||
RenameVariableCommand,
|
||||
} from '../../src/core/commands/variableCommands';
|
||||
import { GraphInterpreter, allNodeDefs, findNode, portsCompatible } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { type ProjectFixture, fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
import {
|
||||
type ProjectFixture,
|
||||
fakeContext,
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeProject,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
roundTrip,
|
||||
} from '../helpers';
|
||||
|
||||
/** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */
|
||||
function withMover<F extends ProjectFixture>(fixture: F) {
|
||||
@@ -75,7 +93,10 @@ describe('GraphInterpreter', () => {
|
||||
const start = edit.addNode('OnStart');
|
||||
const self = edit.addNode('Self');
|
||||
const move = edit.addNode('Move', { dx: 7, dy: -3 });
|
||||
edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object']]);
|
||||
edit.wire([
|
||||
[start, 'exec', move, 'exec'],
|
||||
[self, 'object', move, 'object'],
|
||||
]);
|
||||
const ctx = fakeContext();
|
||||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||||
expect(ctx.self.x).toBe(7);
|
||||
@@ -88,7 +109,11 @@ describe('GraphInterpreter', () => {
|
||||
const self = edit.addNode('Self');
|
||||
const clamp = edit.addNode('Clamp', { value: 5 });
|
||||
const move = edit.addNode('Move', { dx: 4 });
|
||||
edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object'], [clamp, 'result', move, 'dy']]);
|
||||
edit.wire([
|
||||
[start, 'exec', move, 'exec'],
|
||||
[self, 'object', move, 'object'],
|
||||
[clamp, 'result', move, 'dy'],
|
||||
]);
|
||||
const ctx = fakeContext();
|
||||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||||
expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0
|
||||
@@ -145,7 +170,10 @@ describe('GraphInterpreter', () => {
|
||||
let previous = start;
|
||||
sources.forEach((source, i) => {
|
||||
const log = edit.addNode('Log');
|
||||
edit.wire([[previous, 'exec', log, 'exec'], [source, ports[i]!, log, 'message']]);
|
||||
edit.wire([
|
||||
[previous, 'exec', log, 'exec'],
|
||||
[source, ports[i]!, log, 'message'],
|
||||
]);
|
||||
previous = log;
|
||||
});
|
||||
const ctx = fakeContext();
|
||||
@@ -198,14 +226,21 @@ describe('GraphInterpreter', () => {
|
||||
const start = edit.addNode('OnStart');
|
||||
const a = edit.addNode('Sequence');
|
||||
const b = edit.addNode('Sequence');
|
||||
edit.wire([[start, 'exec', a, 'exec'], [a, 'a', b, 'exec'], [b, 'a', a, 'exec']]);
|
||||
edit.wire([
|
||||
[start, 'exec', a, 'exec'],
|
||||
[a, 'a', b, 'exec'],
|
||||
[b, 'a', a, 'exec'],
|
||||
]);
|
||||
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graph commands', () => {
|
||||
it('rejects incompatible, self and duplicate connections', () => {
|
||||
const { edit, nodes: { held, mul, move } } = withMover(makeProject());
|
||||
const {
|
||||
edit,
|
||||
nodes: { held, mul, move },
|
||||
} = withMover(makeProject());
|
||||
expect(() => edit.connect(held, 'exec', mul, 'a')).toThrow(/Cannot connect/);
|
||||
expect(() => edit.connect(mul, 'result', mul, 'a')).toThrow(/itself/);
|
||||
expect(() => edit.connect(held, 'exec', move, 'exec')).toThrow(/Already/);
|
||||
@@ -218,7 +253,8 @@ describe('graph commands', () => {
|
||||
const before = behavior.graph.edges.length;
|
||||
edit.connect(literal, 'value', nodes.move, 'dx');
|
||||
expect(behavior.graph.edges.length).toBe(before); // one replaced, one added
|
||||
const sourceOfDx = () => behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node;
|
||||
const sourceOfDx = () =>
|
||||
behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node;
|
||||
expect(sourceOfDx()).toBe(literal.id);
|
||||
stack.undo();
|
||||
expect(sourceOfDx()).toBe(nodes.mul.id);
|
||||
@@ -288,10 +324,16 @@ describe('behavior serialization', () => {
|
||||
expect(loaded.graph).toEqual(behavior.graph);
|
||||
expect(loaded.props).toEqual({ enabled: true, speed: 0 });
|
||||
|
||||
const raw = serializeProject(project) as { assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[] };
|
||||
const raw = serializeProject(project) as {
|
||||
assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[];
|
||||
};
|
||||
const rawGraph = raw.assets.find((a) => a.id === behavior.id)!.graph!;
|
||||
rawGraph.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
|
||||
rawGraph.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } });
|
||||
rawGraph.edges.push({
|
||||
id: 'e-bad',
|
||||
from: { node: 'zzz', port: 'exec' },
|
||||
to: { node: behavior.graph.nodes[0]!.id, port: 'exec' },
|
||||
});
|
||||
const cleaned = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
|
||||
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
|
||||
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { MoveSceneObjectCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import {
|
||||
MoveSceneObjectCommand,
|
||||
RemoveSceneObjectCommand,
|
||||
SetParentCommand,
|
||||
} from '../../src/core/commands/sceneObjectCommands';
|
||||
import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph';
|
||||
import { ancestorIdsOf, descendantsOf, findObject, localPositionFor, treeOrder, visibleTreeOrder } from '../../src/core/model/hierarchy';
|
||||
import {
|
||||
ancestorIdsOf,
|
||||
descendantsOf,
|
||||
findObject,
|
||||
localPositionFor,
|
||||
treeOrder,
|
||||
visibleTreeOrder,
|
||||
} from '../../src/core/model/hierarchy';
|
||||
import { findScene } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
import {
|
||||
fakeContext,
|
||||
fakeEntity,
|
||||
makeBehavior,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
roundTrip,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
/** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */
|
||||
function threeBoxes() {
|
||||
@@ -58,7 +77,11 @@ describe('scene hierarchy', () => {
|
||||
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
|
||||
// Storage order is a, b, c; parent c under a → c is shown right after a, before b.
|
||||
stack.execute(new SetParentCommand(sceneId, c, a));
|
||||
expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([[a, 0], [c, 1], [b, 0]]);
|
||||
expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([
|
||||
[a, 0],
|
||||
[c, 1],
|
||||
[b, 0],
|
||||
]);
|
||||
stack.execute(new SetParentCommand(sceneId, b, c));
|
||||
expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]);
|
||||
});
|
||||
@@ -68,12 +91,20 @@ describe('scene hierarchy', () => {
|
||||
// a > c > b: two levels, so folding a hides both descendants.
|
||||
stack.execute(new SetParentCommand(sceneId, c, a));
|
||||
stack.execute(new SetParentCommand(sceneId, b, c));
|
||||
const rows = (expanded: string[]) => visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]);
|
||||
const rows = (expanded: string[]) =>
|
||||
visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]);
|
||||
|
||||
// Nothing expanded (the default when a scene is opened) – only the top level is listed.
|
||||
expect(rows([])).toEqual([[a, 0, true]]);
|
||||
expect(rows([a])).toEqual([[a, 0, true], [c, 1, true]]);
|
||||
expect(rows([a, c])).toEqual([[a, 0, true], [c, 1, true], [b, 2, false]]);
|
||||
expect(rows([a])).toEqual([
|
||||
[a, 0, true],
|
||||
[c, 1, true],
|
||||
]);
|
||||
expect(rows([a, c])).toEqual([
|
||||
[a, 0, true],
|
||||
[c, 1, true],
|
||||
[b, 2, false],
|
||||
]);
|
||||
// Expanding a nested object without its parent shows nothing extra: every ancestor must be open.
|
||||
expect(rows([c])).toEqual([[a, 0, true]]);
|
||||
});
|
||||
@@ -165,26 +196,29 @@ describe('runtime hierarchy', () => {
|
||||
const parent = { ...fakeEntity('p', 100), y: 20 };
|
||||
const child = { ...fakeEntity('c', 5), y: 5, parent };
|
||||
// On start → Log(world x of self) → Log(local x of self) → Log(world x of self's parent)
|
||||
const graph: Graph = { nodes: [
|
||||
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
|
||||
{ id: 'self', type: 'Self', x: 0, y: 0, params: {} },
|
||||
{ id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} },
|
||||
{ id: 'par', type: 'Parent', x: 0, y: 0, params: {} },
|
||||
{ id: 'ppos', type: 'GetPosition', x: 0, y: 0, params: {} },
|
||||
{ id: 'log1', type: 'Log', x: 0, y: 0, params: {} },
|
||||
{ id: 'log2', type: 'Log', x: 0, y: 0, params: {} },
|
||||
{ id: 'log3', type: 'Log', x: 0, y: 0, params: {} },
|
||||
], edges: [
|
||||
{ id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'log1', port: 'exec' } },
|
||||
{ id: 'e2', from: { node: 'log1', port: 'exec' }, to: { node: 'log2', port: 'exec' } },
|
||||
{ id: 'e3', from: { node: 'log2', port: 'exec' }, to: { node: 'log3', port: 'exec' } },
|
||||
{ id: 'e4', from: { node: 'self', port: 'object' }, to: { node: 'pos', port: 'object' } },
|
||||
{ id: 'e5', from: { node: 'self', port: 'object' }, to: { node: 'par', port: 'object' } },
|
||||
{ id: 'e6', from: { node: 'par', port: 'parent' }, to: { node: 'ppos', port: 'object' } },
|
||||
{ id: 'e7', from: { node: 'pos', port: 'worldX' }, to: { node: 'log1', port: 'message' } },
|
||||
{ id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } },
|
||||
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
|
||||
] };
|
||||
const graph: Graph = {
|
||||
nodes: [
|
||||
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
|
||||
{ id: 'self', type: 'Self', x: 0, y: 0, params: {} },
|
||||
{ id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} },
|
||||
{ id: 'par', type: 'Parent', x: 0, y: 0, params: {} },
|
||||
{ id: 'ppos', type: 'GetPosition', x: 0, y: 0, params: {} },
|
||||
{ id: 'log1', type: 'Log', x: 0, y: 0, params: {} },
|
||||
{ id: 'log2', type: 'Log', x: 0, y: 0, params: {} },
|
||||
{ id: 'log3', type: 'Log', x: 0, y: 0, params: {} },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'log1', port: 'exec' } },
|
||||
{ id: 'e2', from: { node: 'log1', port: 'exec' }, to: { node: 'log2', port: 'exec' } },
|
||||
{ id: 'e3', from: { node: 'log2', port: 'exec' }, to: { node: 'log3', port: 'exec' } },
|
||||
{ id: 'e4', from: { node: 'self', port: 'object' }, to: { node: 'pos', port: 'object' } },
|
||||
{ id: 'e5', from: { node: 'self', port: 'object' }, to: { node: 'par', port: 'object' } },
|
||||
{ id: 'e6', from: { node: 'par', port: 'parent' }, to: { node: 'ppos', port: 'object' } },
|
||||
{ id: 'e7', from: { node: 'pos', port: 'worldX' }, to: { node: 'log1', port: 'message' } },
|
||||
{ id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } },
|
||||
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
|
||||
],
|
||||
};
|
||||
const ctx = fakeContext({ self: child, owner: parent, variables: {} });
|
||||
new GraphInterpreter(graph).fire('OnStart', ctx);
|
||||
expect(ctx.logs).toEqual(['105', '5', '100']);
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* The history tests pin that a rejected undo leaves the stacks usable.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Command } from '../../src/core/commands/Command';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
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';
|
||||
@@ -29,7 +28,11 @@ function containerWithOverride() {
|
||||
describe('object holders', () => {
|
||||
it('counts scenes and container assets alike', () => {
|
||||
const { project } = containerWithOverride();
|
||||
expect(objectHolders(project).map((h) => h.type).sort()).toEqual(['container', 'scene']);
|
||||
expect(
|
||||
objectHolders(project)
|
||||
.map((h) => h.type)
|
||||
.sort(),
|
||||
).toEqual(['container', 'scene']);
|
||||
});
|
||||
|
||||
it('finds usages inside container templates, not just in scenes', () => {
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* One table covering every built-in node that has a `run`, plus a guard that keeps it that way:
|
||||
* adding a node without a case here fails `covers every built-in node`. Nodes without `run`
|
||||
* (the events) are seeded by the interpreter, so they are listed with the test that drives them
|
||||
* and checked in tests/core/behavior-events.test.ts.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER } from '../helpers';
|
||||
|
||||
interface Case {
|
||||
/** What the case demonstrates – becomes the failure message. */
|
||||
name: string;
|
||||
inputs?: Record<string, Value>;
|
||||
params?: PropertyBag;
|
||||
context?: Partial<NodeContext>;
|
||||
/** Expected data outputs, compared exactly. */
|
||||
outputs?: Record<string, Value>;
|
||||
/** Expected exec continuation; `null` asserts the flow ends here. */
|
||||
next?: string | string[] | null;
|
||||
/** Assertions on what the node did to the context or to the handles it was given. */
|
||||
after?(ctx: FakeContext): void;
|
||||
}
|
||||
|
||||
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
|
||||
const ctx = fakeContext(c.context);
|
||||
return { result: requireNodeDef(type).run!(ctx, c.inputs ?? {}, c.params ?? {}), ctx };
|
||||
};
|
||||
|
||||
/** A handle with a fixed position, for the geometry nodes. */
|
||||
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
|
||||
|
||||
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
|
||||
pointer: { ...NO_POINTER, ...overrides },
|
||||
});
|
||||
|
||||
const CASES: Record<string, Case[]> = {
|
||||
// --- flow ------------------------------------------------------------------------------------
|
||||
Branch: [
|
||||
{ name: 'true takes then', inputs: { condition: true }, next: 'then' },
|
||||
{ name: 'false takes else', inputs: { condition: false }, next: 'else' },
|
||||
{ name: 'a non-empty string is true', inputs: { condition: 'yes' }, next: 'then' },
|
||||
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
|
||||
],
|
||||
Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }],
|
||||
|
||||
// --- variables -------------------------------------------------------------------------------
|
||||
GetVariable: [
|
||||
{ name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } },
|
||||
{ name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } },
|
||||
{ name: 'no variable chosen reads as null', outputs: { value: null } },
|
||||
],
|
||||
SetVariable: [
|
||||
{
|
||||
name: 'writes a variable, keeping the declared type',
|
||||
inputs: { value: '250' },
|
||||
params: { variable: 'speed' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.variables.speed).toBe(250),
|
||||
},
|
||||
{
|
||||
name: 'no variable chosen writes nothing',
|
||||
inputs: { value: 5 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
|
||||
},
|
||||
],
|
||||
|
||||
// --- references and properties ----------------------------------------------------------------
|
||||
Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }],
|
||||
Owner: [
|
||||
{ name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } },
|
||||
],
|
||||
Parent: [
|
||||
{ name: 'no object has no parent', inputs: { object: null }, outputs: { parent: null } },
|
||||
{
|
||||
name: 'a top-level object has no parent',
|
||||
inputs: { object: fakeHandle({}) },
|
||||
outputs: { parent: null },
|
||||
},
|
||||
],
|
||||
ObjectByName: [
|
||||
{
|
||||
name: 'asks the context for the name',
|
||||
params: { name: 'Player' },
|
||||
context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) },
|
||||
outputs: { object: fakeEntity('Player') },
|
||||
},
|
||||
{ name: 'an unknown name is null', params: { name: 'Ghost' }, outputs: { object: null } },
|
||||
],
|
||||
NameOf: [
|
||||
{ name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } },
|
||||
{ name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } },
|
||||
],
|
||||
IsAsset: [
|
||||
{
|
||||
name: 'matches the asset id',
|
||||
inputs: { object: fakeEntity('Coin', 0, 'coin') },
|
||||
params: { asset: 'coin' },
|
||||
outputs: { result: true },
|
||||
},
|
||||
{
|
||||
name: 'no asset chosen never matches',
|
||||
inputs: { object: fakeEntity('Coin', 0, 'coin') },
|
||||
outputs: { result: false },
|
||||
},
|
||||
],
|
||||
IsEnabled: [
|
||||
{ name: 'on by default', inputs: { object: fakeHandle({}) }, outputs: { result: true, own: true } },
|
||||
{
|
||||
name: 'own switch off',
|
||||
inputs: { object: fakeHandle({ enabled: false }) },
|
||||
outputs: { result: false, own: false },
|
||||
},
|
||||
{
|
||||
name: 'own switch on but a parent is off – "own" still reports the own switch',
|
||||
inputs: { object: fakeHandle({ enabled: true }, fakeHandle({ enabled: false })) },
|
||||
outputs: { result: false, own: true },
|
||||
},
|
||||
{ name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } },
|
||||
],
|
||||
GetProperty: [
|
||||
{
|
||||
name: 'reads a property',
|
||||
inputs: { object: fakeHandle({ width: 42 }) },
|
||||
params: { property: 'width' },
|
||||
outputs: { value: 42 },
|
||||
},
|
||||
{
|
||||
name: 'an absent property is null',
|
||||
inputs: { object: fakeHandle({}) },
|
||||
params: { property: 'width' },
|
||||
outputs: { value: null },
|
||||
},
|
||||
],
|
||||
GetPosition: [
|
||||
{
|
||||
name: 'local and world position of a top-level object are the same',
|
||||
inputs: { object: at('A', 10, 20) },
|
||||
outputs: { x: 10, y: 20, worldX: 10, worldY: 20 },
|
||||
},
|
||||
{
|
||||
name: 'a child reports its local position and the summed world position',
|
||||
inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } },
|
||||
outputs: { x: 10, y: 20, worldX: 110, worldY: 220 },
|
||||
},
|
||||
{
|
||||
name: 'no object is the origin',
|
||||
inputs: { object: null },
|
||||
outputs: { x: 0, y: 0, worldX: 0, worldY: 0 },
|
||||
},
|
||||
],
|
||||
|
||||
// --- literals and math -------------------------------------------------------------------------
|
||||
Number: [
|
||||
{ name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } },
|
||||
{ name: 'unset is 0', outputs: { value: 0 } },
|
||||
],
|
||||
Boolean: [
|
||||
{ name: 'outputs its setting', params: { value: true }, outputs: { value: true } },
|
||||
{ name: 'unset is false', outputs: { value: false } },
|
||||
],
|
||||
String: [
|
||||
{ name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } },
|
||||
{ name: 'unset is empty', outputs: { value: '' } },
|
||||
],
|
||||
Add: [
|
||||
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
|
||||
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
|
||||
],
|
||||
Subtract: [{ name: 'subtracts', inputs: { a: 2, b: 3 }, outputs: { result: -1 } }],
|
||||
Multiply: [{ name: 'multiplies', inputs: { a: 2, b: 3 }, outputs: { result: 6 } }],
|
||||
Divide: [
|
||||
{ name: 'divides', inputs: { a: 6, b: 4 }, outputs: { result: 1.5 } },
|
||||
{ name: 'division by zero is 0, not Infinity', inputs: { a: 6, b: 0 }, outputs: { result: 0 } },
|
||||
],
|
||||
Min: [{ name: 'the smaller one', inputs: { a: 2, b: -3 }, outputs: { result: -3 } }],
|
||||
Max: [{ name: 'the larger one', inputs: { a: 2, b: -3 }, outputs: { result: 2 } }],
|
||||
Abs: [{ name: 'drops the sign', inputs: { value: -4 }, outputs: { result: 4 } }],
|
||||
Sqrt: [
|
||||
{ name: 'square root', inputs: { value: 9 }, outputs: { result: 3 } },
|
||||
{ name: 'negative input is 0, not NaN', inputs: { value: -9 }, outputs: { result: 0 } },
|
||||
],
|
||||
Clamp: [
|
||||
{
|
||||
name: 'inside the range passes through',
|
||||
inputs: { value: 0.4, min: 0, max: 1 },
|
||||
outputs: { result: 0.4 },
|
||||
},
|
||||
{ name: 'above the range', inputs: { value: 5, min: 0, max: 1 }, outputs: { result: 1 } },
|
||||
{ name: 'below the range', inputs: { value: -5, min: 0, max: 1 }, outputs: { result: 0 } },
|
||||
],
|
||||
Floor: [
|
||||
{ name: 'rounds down', inputs: { value: 2.9 }, outputs: { result: 2 } },
|
||||
{ name: 'rounds down for negatives too', inputs: { value: -2.1 }, outputs: { result: -3 } },
|
||||
],
|
||||
Ceil: [
|
||||
{ name: 'rounds up', inputs: { value: 2.1 }, outputs: { result: 3 } },
|
||||
{ name: 'a whole number stays put', inputs: { value: 3 }, outputs: { result: 3 } },
|
||||
],
|
||||
Round: [
|
||||
{ name: 'rounds to nearest', inputs: { value: 2.4 }, outputs: { result: 2 } },
|
||||
{ name: 'half rounds up', inputs: { value: 2.5 }, outputs: { result: 3 } },
|
||||
{ name: 'negative half rounds towards zero', inputs: { value: -2.5 }, outputs: { result: -2 } },
|
||||
],
|
||||
Modulo: [
|
||||
{ name: 'remainder', inputs: { a: 125, b: 60 }, outputs: { result: 5 } },
|
||||
{ name: 'modulo zero is 0, not NaN', inputs: { a: 125, b: 0 }, outputs: { result: 0 } },
|
||||
],
|
||||
Random: [
|
||||
// The spread is checked in "Random stays inside [min, max)" below; an empty range is the
|
||||
// one case with a single right answer.
|
||||
{ name: 'an empty range always yields min', inputs: { min: 3, max: 3 }, outputs: { result: 3 } },
|
||||
],
|
||||
Time: [
|
||||
{
|
||||
name: 'reports the clock and the frame length from the context',
|
||||
context: { time: 12.5, dt: 0.016 },
|
||||
outputs: { time: 12.5, dt: 0.016 },
|
||||
},
|
||||
],
|
||||
|
||||
// --- text and comparison -----------------------------------------------------------------------
|
||||
ToText: [
|
||||
{ name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } },
|
||||
{ name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } },
|
||||
{ name: 'decimals are clamped to 0…10', inputs: { value: 1, decimals: -5 }, outputs: { text: '1' } },
|
||||
],
|
||||
Join: [
|
||||
{ name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } },
|
||||
{
|
||||
name: 'an object joins as its name',
|
||||
inputs: { a: '', b: fakeEntity('Coin') },
|
||||
outputs: { text: 'Coin' },
|
||||
},
|
||||
],
|
||||
Equals: [
|
||||
{ name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } },
|
||||
{ name: 'a number equals its text form', inputs: { a: 3, b: '3' }, outputs: { result: true } },
|
||||
{ name: 'objects compare by identity, not by name', inputs: eqNamesakes(), outputs: { result: false } },
|
||||
],
|
||||
Compare: [
|
||||
{ name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } },
|
||||
{ name: 'greater or equal', inputs: { a: 2, b: 2 }, params: { op: '>=' }, outputs: { result: true } },
|
||||
{ name: 'not equal', inputs: { a: 2, b: 2 }, params: { op: '!=' }, outputs: { result: false } },
|
||||
{
|
||||
name: 'no operator chosen falls back to the default "<"',
|
||||
inputs: { a: 1, b: 2 },
|
||||
outputs: { result: true },
|
||||
},
|
||||
],
|
||||
And: [
|
||||
{ name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } },
|
||||
{ name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } },
|
||||
{ name: 'an unconnected input reads as false', inputs: { a: true }, outputs: { result: false } },
|
||||
],
|
||||
Or: [
|
||||
{ name: 'one true is enough', inputs: { a: false, b: true }, outputs: { result: true } },
|
||||
{ name: 'both false', inputs: { a: false, b: false }, outputs: { result: false } },
|
||||
],
|
||||
Not: [
|
||||
{ name: 'inverts', inputs: { value: true }, outputs: { result: false } },
|
||||
{ name: 'inverts the other way', inputs: { value: false }, outputs: { result: true } },
|
||||
{ name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } },
|
||||
],
|
||||
|
||||
// --- vectors and input --------------------------------------------------------------------------
|
||||
Length: [
|
||||
{ name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } },
|
||||
{ name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } },
|
||||
],
|
||||
Normalize: [
|
||||
{
|
||||
name: 'scales to length 1 and reports the old length',
|
||||
inputs: { x: 0, y: -4 },
|
||||
outputs: { x: 0, y: -1, length: 4 },
|
||||
},
|
||||
{
|
||||
name: 'the zero vector stays zero instead of becoming NaN',
|
||||
inputs: { x: 0, y: 0 },
|
||||
outputs: { x: 0, y: 0, length: 0 },
|
||||
},
|
||||
],
|
||||
Distance: [
|
||||
{
|
||||
name: 'distance between two objects',
|
||||
inputs: { a: at('a', 0, 0), b: at('b', 3, 4) },
|
||||
outputs: { result: 5 },
|
||||
},
|
||||
{ name: 'a missing object is distance 0', inputs: { a: at('a', 0, 0), b: null }, outputs: { result: 0 } },
|
||||
],
|
||||
AxisInput: [
|
||||
{
|
||||
name: 'one key gives a unit vector',
|
||||
context: { isKeyDown: (key) => key === 'ArrowRight' },
|
||||
outputs: { x: 1, y: 0, active: true },
|
||||
},
|
||||
{
|
||||
name: 'diagonals are normalized, so they are not faster',
|
||||
context: { isKeyDown: (key) => key === 'ArrowRight' || key === 'ArrowDown' },
|
||||
// Not Math.SQRT1_2: the node divides by Math.hypot(1, 1), which lands one ulp lower.
|
||||
outputs: { x: 1 / Math.hypot(1, 1), y: 1 / Math.hypot(1, 1), active: true },
|
||||
},
|
||||
{
|
||||
name: 'opposite keys cancel out',
|
||||
context: { isKeyDown: (key) => key === 'ArrowLeft' || key === 'ArrowRight' },
|
||||
outputs: { x: 0, y: 0, active: false },
|
||||
},
|
||||
{ name: 'nothing held is inactive', outputs: { x: 0, y: 0, active: false } },
|
||||
],
|
||||
KeyIsDown: [
|
||||
{
|
||||
name: 'asks the context for the chosen key',
|
||||
params: { key: 'a' },
|
||||
context: { isKeyDown: (k) => k === 'a' },
|
||||
outputs: { down: true },
|
||||
},
|
||||
{
|
||||
name: 'another key is not down',
|
||||
params: { key: 'b' },
|
||||
context: { isKeyDown: (k) => k === 'a' },
|
||||
outputs: { down: false },
|
||||
},
|
||||
{ name: 'no key chosen is never down', context: { isKeyDown: () => true }, outputs: { down: true } },
|
||||
],
|
||||
MousePosition: [
|
||||
{
|
||||
name: 'reports the pointer in world units',
|
||||
context: pointer({ x: 40, y: -20, inside: true }),
|
||||
outputs: { x: 40, y: -20, inside: true },
|
||||
},
|
||||
{
|
||||
name: 'outside the camera frame the position is still reported, but "in view" is false',
|
||||
context: pointer({ x: 5, y: 5, inside: false }),
|
||||
outputs: { x: 5, y: 5, inside: false },
|
||||
},
|
||||
],
|
||||
MouseButton: [
|
||||
{
|
||||
name: 'the frame the button goes down',
|
||||
context: pointer({ down: true, pressed: true }),
|
||||
outputs: { down: true, pressed: true, released: false },
|
||||
},
|
||||
{
|
||||
name: 'a later frame while it is still held',
|
||||
context: pointer({ down: true }),
|
||||
outputs: { down: true, pressed: false, released: false },
|
||||
},
|
||||
{
|
||||
name: 'the frame it goes up',
|
||||
context: pointer({ released: true }),
|
||||
outputs: { down: false, pressed: false, released: true },
|
||||
},
|
||||
],
|
||||
IsHovered: [
|
||||
{
|
||||
name: 'the pointer is over the object',
|
||||
inputs: { object: fakeEntity('Button') },
|
||||
context: { ...pointer({ inside: true }), hitTest: () => true },
|
||||
outputs: { result: true },
|
||||
},
|
||||
{
|
||||
name: 'a hit outside the camera frame does not count',
|
||||
inputs: { object: fakeEntity('Button') },
|
||||
context: { ...pointer({ inside: false }), hitTest: () => true },
|
||||
outputs: { result: false },
|
||||
},
|
||||
{
|
||||
name: 'no object is never hovered',
|
||||
inputs: { object: null },
|
||||
context: { ...pointer({ inside: true }), hitTest: () => true },
|
||||
outputs: { result: false },
|
||||
},
|
||||
],
|
||||
IsTouching: [
|
||||
{
|
||||
name: 'asks the context',
|
||||
inputs: { a: fakeEntity('a'), b: fakeEntity('b') },
|
||||
context: { isTouching: () => true },
|
||||
outputs: { result: true },
|
||||
},
|
||||
{
|
||||
name: 'a missing object never touches',
|
||||
inputs: { a: fakeEntity('a'), b: null },
|
||||
context: { isTouching: () => true },
|
||||
outputs: { result: false },
|
||||
},
|
||||
],
|
||||
|
||||
// --- actions --------------------------------------------------------------------------------------
|
||||
Move: [
|
||||
{
|
||||
name: 'moves by (dx, dy) × scale × dt',
|
||||
inputs: { object: at('m', 0, 0), dx: 1, dy: 0, scale: 100, dt: 0.5 },
|
||||
next: 'exec',
|
||||
outputs: { blockedX: false, blockedY: false },
|
||||
},
|
||||
{
|
||||
name: 'no object is a no-op that still continues the flow',
|
||||
inputs: { object: null, dx: 1, dy: 1 },
|
||||
next: 'exec',
|
||||
outputs: { blockedX: false, blockedY: false },
|
||||
},
|
||||
],
|
||||
SetPosition: [
|
||||
{
|
||||
name: 'places the object and continues',
|
||||
inputs: { object: at('p', 0, 0), x: 30, y: -40 },
|
||||
next: 'exec',
|
||||
},
|
||||
{ name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' },
|
||||
],
|
||||
SetProperty: [
|
||||
{
|
||||
name: 'writes a property, keeping the existing type',
|
||||
inputs: { object: fakeHandle({ width: 100 }), value: '250' },
|
||||
params: { property: 'width' },
|
||||
next: 'exec',
|
||||
},
|
||||
{
|
||||
name: 'no property name chosen writes nothing',
|
||||
inputs: { object: fakeHandle({ width: 100 }), value: 5 },
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
SetEnabled: [
|
||||
{
|
||||
name: 'switches an object off',
|
||||
inputs: { object: fakeHandle({ enabled: true }), enabled: false },
|
||||
next: 'exec',
|
||||
},
|
||||
{ name: 'no object is a no-op', inputs: { object: null, enabled: false }, next: 'exec' },
|
||||
],
|
||||
SetText: [
|
||||
{
|
||||
name: 'writes the content of a text object',
|
||||
inputs: { object: fakeHandle({ text: 'a' }), text: 12 },
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
Spawn: [
|
||||
{
|
||||
name: 'no asset chosen logs a hint and continues with no object',
|
||||
next: 'exec',
|
||||
outputs: { object: null },
|
||||
after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']),
|
||||
},
|
||||
],
|
||||
Destroy: [
|
||||
{
|
||||
name: 'no object is a no-op that still continues the flow',
|
||||
inputs: { object: null },
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
Log: [
|
||||
{
|
||||
name: 'logs its message and continues',
|
||||
inputs: { message: 'hi' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.logs).toEqual(['hi']),
|
||||
},
|
||||
{
|
||||
name: 'an object logs as its name',
|
||||
inputs: { message: fakeEntity('Coin') },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.logs).toEqual(['Coin']),
|
||||
},
|
||||
],
|
||||
StopGame: [{ name: 'ends play and the flow', next: null, after: (ctx) => expect(ctx.stopped).toBe(true) }],
|
||||
RestartGame: [{ name: 'ends the flow – the world restarts before the next tick', next: null }],
|
||||
};
|
||||
|
||||
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
|
||||
function eqNamesakes(): Record<string, Value> {
|
||||
return { a: fakeEntity('Coin'), b: fakeEntity('Coin') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes without a `run`: the interpreter seeds their outputs when the BehaviorSystem fires them,
|
||||
* so they are only observable through a running world. The value says which test drives it.
|
||||
*/
|
||||
const EVENT_NODES: Record<string, string> = {
|
||||
OnStart: 'graph.test.ts',
|
||||
OnUpdate: 'graph.test.ts',
|
||||
OnKeyDown: 'behavior-events.test.ts',
|
||||
OnKeyUp: 'behavior-events.test.ts',
|
||||
OnKeyHeld: 'behavior-events.test.ts',
|
||||
OnTimer: 'behavior-events.test.ts',
|
||||
OnCollisionEnter: 'collider.test.ts',
|
||||
OnCollisionStay: 'behavior-events.test.ts',
|
||||
OnCollisionExit: 'collider.test.ts',
|
||||
OnClick: 'nodes.test.ts',
|
||||
};
|
||||
|
||||
describe('node catalog', () => {
|
||||
it('covers every built-in node', () => {
|
||||
const uncovered = BUILT_IN_NODES.filter((def) =>
|
||||
def.run ? !CASES[def.type] : !EVENT_NODES[def.type],
|
||||
).map((def) => def.type);
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists exactly the run-less nodes as events', () => {
|
||||
const runless = BUILT_IN_NODES.filter((def) => !def.run).map((def) => def.type);
|
||||
expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort());
|
||||
});
|
||||
|
||||
it('declares well-formed ports and settings', () => {
|
||||
for (const def of BUILT_IN_NODES) {
|
||||
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
|
||||
expect(def.label, `${def.type} has a label`).not.toBe('');
|
||||
expect(def.description, `${def.type} has a description`).not.toBe('');
|
||||
// Inputs and outputs are addressed by key within their own side; a key may repeat across
|
||||
// sides (Normalize reads x/y and writes x/y), never within one.
|
||||
expect(new Set(def.inputs.map((p) => p.key)).size, `${def.type} input keys unique`).toBe(
|
||||
def.inputs.length,
|
||||
);
|
||||
expect(new Set(def.outputs.map((p) => p.key)).size, `${def.type} output keys unique`).toBe(
|
||||
def.outputs.length,
|
||||
);
|
||||
expect(
|
||||
keys.every((k) => k !== ''),
|
||||
`${def.type} has no empty port key`,
|
||||
).toBe(true);
|
||||
for (const param of def.params) {
|
||||
expect(param.default, `${def.type}.${param.key} has a default`).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const [type, cases] of Object.entries(CASES)) {
|
||||
describe(type, () => {
|
||||
for (const c of cases) {
|
||||
it(c.name, () => {
|
||||
const { result, ctx } = run(type, c);
|
||||
if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs);
|
||||
if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next);
|
||||
c.after?.(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cases whose point is the mutation they cause – stated here rather than squeezed into the table.
|
||||
describe('action nodes write through to the object they are given', () => {
|
||||
it('Move applies dx/dy × scale × dt', () => {
|
||||
const target = at('m', 0, 0);
|
||||
requireNodeDef('Move').run!(fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {});
|
||||
expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 });
|
||||
});
|
||||
|
||||
it('SetPosition places the object absolutely', () => {
|
||||
const target = at('p', 10, 10);
|
||||
requireNodeDef('SetPosition').run!(fakeContext(), { object: target, x: 30, y: -40 }, {});
|
||||
expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 });
|
||||
});
|
||||
|
||||
it('SetProperty keeps the type the property already had', () => {
|
||||
const target = fakeHandle({ width: 100 });
|
||||
requireNodeDef('SetProperty').run!(
|
||||
fakeContext(),
|
||||
{ object: target, value: '250' },
|
||||
{ property: 'width' },
|
||||
);
|
||||
expect(target.props.width).toBe(250);
|
||||
});
|
||||
|
||||
it('SetProperty without a property name writes nothing', () => {
|
||||
const target = fakeHandle({ width: 100 });
|
||||
requireNodeDef('SetProperty').run!(fakeContext(), { object: target, value: 5 }, {});
|
||||
expect(target.props).toEqual({ width: 100 });
|
||||
});
|
||||
|
||||
it('SetEnabled switches the object off', () => {
|
||||
const target = fakeHandle({ enabled: true });
|
||||
requireNodeDef('SetEnabled').run!(fakeContext(), { object: target, enabled: false }, {});
|
||||
expect(target.props.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('SetText writes the text property, stringifying numbers', () => {
|
||||
const target = fakeHandle({ text: 'a' });
|
||||
requireNodeDef('SetText').run!(fakeContext(), { object: target, text: 12 }, {});
|
||||
expect(target.props.text).toBe('12');
|
||||
});
|
||||
|
||||
it('Destroy hands the object to the context', () => {
|
||||
const removed: string[] = [];
|
||||
const ctx = fakeContext({ destroy: (target) => removed.push(target.name) });
|
||||
requireNodeDef('Destroy').run!(ctx, { object: fakeEntity('Coin') }, {});
|
||||
expect(removed).toEqual(['Coin']);
|
||||
});
|
||||
|
||||
it('RestartGame asks the context for a restart', () => {
|
||||
let restarted = false;
|
||||
const ctx = fakeContext({
|
||||
restart: () => {
|
||||
restarted = true;
|
||||
},
|
||||
});
|
||||
requireNodeDef('RestartGame').run!(ctx, {}, {});
|
||||
expect(restarted).toBe(true);
|
||||
});
|
||||
|
||||
it('Random stays inside [min, max) over many draws', () => {
|
||||
const ctx = fakeContext();
|
||||
const draws = Array.from(
|
||||
{ length: 200 },
|
||||
() => requireNodeDef('Random').run!(ctx, { min: 5, max: 10 }, {}).outputs!.result as number,
|
||||
);
|
||||
expect(draws.every((n) => n >= 5 && n < 10)).toBe(true);
|
||||
// Not a constant: a generator stuck on one value would pass the range check above.
|
||||
expect(new Set(draws).size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
+15
-33
@@ -1,39 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type Graph, GraphInterpreter } from '../../src/core/graph';
|
||||
import { fakeContext, fakeEntity, fakeHandle, graphEditor, makeBehavior, makeSpriteScene, place, runNode, startWorld } from '../helpers';
|
||||
import {
|
||||
fakeContext,
|
||||
fakeEntity,
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
// Node-by-node behaviour lives in node-catalog.test.ts; what is left here needs a running
|
||||
// world (On click's hit test) or a whole flow (Spawn feeding its object downstream).
|
||||
describe('game-flow nodes', () => {
|
||||
it('formats numbers and joins text', () => {
|
||||
expect(runNode('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' });
|
||||
expect(runNode('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' });
|
||||
expect(runNode('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' });
|
||||
expect(runNode('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 });
|
||||
expect(runNode('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 });
|
||||
expect(runNode('Floor', { value: 2.9 })).toEqual({ result: 2 });
|
||||
});
|
||||
|
||||
it('compares values and identifies assets', () => {
|
||||
const coin = fakeHandle({});
|
||||
coin.assetId = 'coin';
|
||||
expect(runNode('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true });
|
||||
expect(runNode('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false });
|
||||
expect(runNode('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false });
|
||||
expect(runNode('Equals', { a: 'a', b: 'a' })).toEqual({ result: true });
|
||||
expect(runNode('Equals', { a: coin, b: coin })).toEqual({ result: true });
|
||||
expect(runNode('Equals', { a: coin, b: null })).toEqual({ result: false });
|
||||
expect(runNode('NameOf', { object: coin })).toEqual({ name: 'h' });
|
||||
});
|
||||
|
||||
it('switches objects on and off and asks for a restart', () => {
|
||||
const target = fakeHandle({ enabled: true });
|
||||
runNode('SetEnabled', { object: target, enabled: false });
|
||||
expect(target.props.enabled).toBe(false);
|
||||
|
||||
let restarted = false;
|
||||
runNode('RestartGame', {}, {}, fakeContext({ restart: () => { restarted = true; } }));
|
||||
expect(restarted).toBe(true);
|
||||
});
|
||||
|
||||
it('fires On click when the mouse is pressed on the owner', () => {
|
||||
const f = makeSpriteScene('Button');
|
||||
const behavior = makeBehavior(f, 'OnPress', ['clicks']);
|
||||
@@ -101,7 +80,10 @@ describe('Spawn node', () => {
|
||||
|
||||
it('logs a hint and continues when no asset is selected or the asset is unknown', () => {
|
||||
const ctx = fakeContext({ spawn: () => null });
|
||||
const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) };
|
||||
const noAsset: Graph = {
|
||||
...graph,
|
||||
nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)),
|
||||
};
|
||||
new GraphInterpreter(noAsset).fire('OnStart', ctx);
|
||||
expect(ctx.logs).toEqual(['Spawn: no asset selected', '']);
|
||||
const ctx2 = fakeContext({ spawn: () => null });
|
||||
|
||||
@@ -48,7 +48,12 @@ describe('resolve', () => {
|
||||
const sprite = buildAsset('sprite', 'Box');
|
||||
sprite.props.color = 0x00ff00;
|
||||
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
|
||||
expect(resolveSceneObjectProps(sprite, object)).toEqual({ enabled: true, width: 100, height: 100, color: 0x00ff00 });
|
||||
expect(resolveSceneObjectProps(sprite, object)).toEqual({
|
||||
enabled: true,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0x00ff00,
|
||||
});
|
||||
|
||||
object.overrides.color = 0x0000ff;
|
||||
expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff);
|
||||
|
||||
@@ -17,11 +17,22 @@ describe('serialize', () => {
|
||||
name: 'Broken',
|
||||
activeSceneId: 'missing',
|
||||
assets: [
|
||||
{ id: 's1', type: 'scene', name: 'Scene', props: { backgroundColor: '#000000', junk: 1 }, objects: [
|
||||
{ id: 'o1', assetId: 'sp1', position: { x: '5', y: null }, overrides: { color: '#00ff00', width: -3, foo: 1 } },
|
||||
{ id: 'o2', assetId: 'nope', position: {} },
|
||||
{ id: 'o3', assetId: 's1', position: {} },
|
||||
] },
|
||||
{
|
||||
id: 's1',
|
||||
type: 'scene',
|
||||
name: 'Scene',
|
||||
props: { backgroundColor: '#000000', junk: 1 },
|
||||
objects: [
|
||||
{
|
||||
id: 'o1',
|
||||
assetId: 'sp1',
|
||||
position: { x: '5', y: null },
|
||||
overrides: { color: '#00ff00', width: -3, foo: 1 },
|
||||
},
|
||||
{ id: 'o2', assetId: 'nope', position: {} },
|
||||
{ id: 'o3', assetId: 's1', position: {} },
|
||||
],
|
||||
},
|
||||
{ id: 'sp1', type: 'sprite', props: { width: 10 } },
|
||||
{ id: 'x', type: 'texture' },
|
||||
{ type: 'camera' },
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { ClearOverrideCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import {
|
||||
ClearOverrideCommand,
|
||||
RemoveSceneObjectCommand,
|
||||
SetOverrideCommand,
|
||||
SetParentCommand,
|
||||
} from '../../src/core/commands/sceneObjectCommands';
|
||||
import { findScene } from '../../src/core/model/Project';
|
||||
import { World } from '../../src/runtime/World';
|
||||
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
Reference in new issue
Block a user