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>
129 lines
5.4 KiB
TypeScript
129 lines
5.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
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';
|
||
|
||
/** Two boxes: a at the origin and b at (300,0). */
|
||
function setup() {
|
||
const f = makeSpriteScene();
|
||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||
return { ...f, a, b };
|
||
}
|
||
|
||
describe('world ↔ model synchronisation (editing while playing)', () => {
|
||
it('syncFromModel picks up placed, moved, edited, re-parented and removed objects', () => {
|
||
const f = setup();
|
||
const { stack, sceneId, spriteId, a, b, object } = f;
|
||
const { world } = startWorld(f);
|
||
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
|
||
|
||
// Placed while playing → new entity, existing ones keep their identity.
|
||
const entityA = world.findByObjectId(a)!;
|
||
const c = place(f, spriteId, { x: 50, y: 50 });
|
||
world.syncFromModel();
|
||
expect(world.entities).toHaveLength(3);
|
||
expect(world.findByObjectId(a)).toBe(entityA);
|
||
|
||
// Property edits (override, asset value) and parenting are reflected; props bag keeps identity.
|
||
const propsA = entityA.props;
|
||
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 40));
|
||
stack.execute(new SetAssetPropertyCommand(spriteId, 'height', 70));
|
||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||
world.syncFromModel();
|
||
expect(entityA.props).toBe(propsA);
|
||
expect(entityA.props.width).toBe(40);
|
||
expect(entityA.props.height).toBe(70);
|
||
expect(world.findByObjectId(b)!.parent).toBe(entityA);
|
||
expect(world.findByObjectId(b)!.x).toBe(object(b).position.x);
|
||
|
||
// Removed while playing → entity gone.
|
||
stack.execute(new RemoveSceneObjectCommand(sceneId, c));
|
||
world.syncFromModel();
|
||
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
|
||
});
|
||
|
||
it('syncToModel writes positions, changed values as overrides and destroyed entities back', () => {
|
||
const f = setup();
|
||
const { project, sceneId, a, b, object, sceneOf } = f;
|
||
const { world } = startWorld(f);
|
||
const entityA = world.findByObjectId(a)!;
|
||
entityA.x = 123;
|
||
entityA.props.width = 999; // what a Set property node would do
|
||
entityA.props.height = 100; // unchanged → no override
|
||
expect(world.syncToModel()).toBe(false);
|
||
expect(object(a).position).toEqual({ x: 123, y: 0, z: 0 });
|
||
expect(object(a).overrides).toEqual({ width: 999 });
|
||
|
||
// Round trip: the model now resolves to the runtime values, so a sync back changes nothing.
|
||
world.syncFromModel();
|
||
expect(entityA.x).toBe(123);
|
||
expect(entityA.props.width).toBe(999);
|
||
|
||
// Resetting the override in the editor returns the entity to the asset value.
|
||
new ClearOverrideCommand(sceneId, a, 'width').execute(project);
|
||
world.syncFromModel();
|
||
expect(entityA.props.width).toBe(100);
|
||
|
||
// Destroy (node) removes the entity; the sync drops its object – a structural change.
|
||
world.remove(world.findByObjectId(b)!);
|
||
expect(world.syncToModel()).toBe(true);
|
||
expect(sceneOf().objects.map((o) => o.id)).toEqual([a]);
|
||
});
|
||
|
||
it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => {
|
||
const f = setup();
|
||
const { sceneId, spriteId, a, sceneOf } = f;
|
||
const { world } = startWorld(f);
|
||
const entityA = world.findByObjectId(a)!;
|
||
entityA.x = 77; // moved earlier in this tick – a spawn must not pull the model value back
|
||
|
||
const child = world.spawn(spriteId, { x: 10, y: 20, z: 1 }, entityA)!;
|
||
expect(child).not.toBeNull();
|
||
expect(child.parent).toBe(entityA);
|
||
expect(child.type).toBe('sprite');
|
||
expect(child.name).toBe('Box');
|
||
expect(child.props.width).toBe(100);
|
||
expect(entityA.x).toBe(77);
|
||
const object = sceneOf().objects.find((o) => o.id === child.objectId)!;
|
||
expect(object.parentId).toBe(a);
|
||
expect(object.position).toEqual({ x: 10, y: 20, z: 1 });
|
||
|
||
// The spawn is a structural change the panels must see – reported once.
|
||
expect(world.syncToModel()).toBe(true);
|
||
expect(world.syncToModel()).toBe(false);
|
||
// A full sync from the model keeps the spawned entity (identity included).
|
||
world.syncFromModel();
|
||
expect(world.findByObjectId(child.objectId)).toBe(child);
|
||
expect(child.parent).toBe(entityA);
|
||
|
||
// Destroying the spawned entity drops its object again.
|
||
world.remove(child);
|
||
expect(world.syncToModel()).toBe(true);
|
||
expect(sceneOf().objects.some((o) => o.id === child.objectId)).toBe(false);
|
||
|
||
// Unknown or unplaceable assets spawn nothing.
|
||
expect(world.spawn('nope', { x: 0, y: 0, z: 0 }, null)).toBeNull();
|
||
expect(world.spawn(sceneId, { x: 0, y: 0, z: 0 }, null)).toBeNull();
|
||
});
|
||
|
||
it('a play-mode copy leaves the original model untouched', () => {
|
||
const { project, sceneId, a, object, sceneOf } = setup();
|
||
const copy = roundTrip(project);
|
||
const world = World.instantiate(copy, sceneId);
|
||
world.findByObjectId(a)!.x = 500;
|
||
world.remove(world.findByObjectId(a)!);
|
||
world.syncToModel();
|
||
expect(findScene(copy, sceneId)!.objects).toHaveLength(1);
|
||
expect(object(a).position.x).toBe(0);
|
||
expect(sceneOf().objects).toHaveLength(2);
|
||
});
|
||
});
|