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); }); });