Files
feuerwerk/tests/editor/editor-run.test.ts
T
root 8f31afac37 Let a behavior run while the editor is open
A behavior object carries a "Run in editor" switch next to Enabled. When
it is on, the behavior ticks outside of Play and works on the saved model
directly - Unity semantics: what it moves is a real change, autosaved and
not undoable.

Input stays with the editor, so On key never fires while typing. The
editor run never bakes placed containers into the model, since that would
silently drop their instanceEdits.

syncToModel reports whether it wrote anything, and the dirty flag is only
raised on change - otherwise a behavior moving something every frame
would trigger ~60 saves a second.
2026-09-09 17:13:07 +02:00

149 lines
5.7 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.
/**
* "Run in editor": a behavior that keeps running while the project is not playing. Unlike play
* mode this one edits the saved model, so these tests watch what lands in the scene itself.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { hasEditorRun, runsInEditor } from '../../src/core/properties/editorRun';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
import { findAsset } from '../../src/core/model/Project';
import { EditorRun } from '../../src/editor/EditorRun';
import { EditorState } from '../../src/editor/state';
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
/** Wires `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts 100 units per second. */
function drift(f: SceneFixture, behavior: BehaviorAsset): void {
const edit = graphEditor(f, behavior);
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const move = edit.addNode('Move', { dx: 1, scale: 100 });
edit.wire([
[update, 'exec', move, 'exec'],
[update, 'dt', move, 'dt'],
[owner, 'object', move, 'object'],
]);
}
/** A box with a drifting behavior below it, in a state the editor run can be pointed at. */
function scene() {
const f = makeSpriteScene('Box');
const behavior = makeBehavior(f, 'Drift');
drift(f, behavior);
const box = place(f, f.spriteId, { x: 0, y: 0 });
const logic = place(f, behavior.id, { x: 0, y: 0 }, box);
const state = new EditorState();
state.loadProject(f.project, 1);
const run = new EditorRun(state);
return {
...f,
behavior,
box,
logic,
state,
run,
/** One frame, after telling the run that the model may have changed (the Editor's wiring). */
frame(dt = 1 / 60) {
run.invalidate();
run.tick(dt);
},
boxX: () => f.object(box).position.x,
};
}
describe('the run-in-editor switch', () => {
it('is a behavior property, off unless a scene object asks for it', () => {
const f = scene();
expect(schemaFor('behavior').map((d) => d.key)).toContain('runInEditor');
const asset = findAsset(f.project, f.behavior.id)!;
expect(runsInEditor(resolveSceneObjectProps(asset, f.object(f.logic)))).toBe(false);
expect(hasEditorRun(f.project, f.sceneOf())).toBe(false);
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
expect(runsInEditor(resolveSceneObjectProps(asset, f.object(f.logic)))).toBe(true);
expect(hasEditorRun(f.project, f.sceneOf())).toBe(true);
});
it('leaves everything alone until it is switched on', () => {
const f = scene();
for (let i = 0; i < 10; i++) f.frame();
expect(f.run.debugInfo()).toEqual({ running: false, time: 0, behaviors: [] });
expect(f.boxX()).toBe(0);
});
it('drives the object in the saved model once it is on, and marks the project for saving', () => {
const f = scene();
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
// The command itself dirtied the project; the question is whether the *run* does too.
f.state.setSaveStatus('saved');
f.frame(0.1);
expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
expect(f.boxX()).toBeCloseTo(10);
// Not a copy, not a preview: the stored scene object moved, and that has to be saved.
expect(f.sceneOf().objects.find((o) => o.id === f.box)!.position.x).toBeCloseTo(10);
expect(f.state.saveStatus).toBe('dirty');
f.frame(0.1);
expect(f.boxX()).toBeCloseTo(20);
});
it('runs only the behaviors that ask for it', () => {
const f = scene();
const second = makeBehavior(f, 'Idle');
drift(f, second);
place(f, second.id, { x: 0, y: 0 }, f.box);
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
f.frame(0.1);
// Both behaviors own the same box: were the second one running too, it would drift twice as far.
expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
expect(f.boxX()).toBeCloseTo(10);
});
it('stops the moment the switch is taken back', () => {
const f = scene();
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
f.frame(0.1);
expect(f.boxX()).toBeCloseTo(10);
f.state.execute(new ClearOverrideCommand(f.sceneId, f.logic, 'runInEditor'));
f.frame(0.1);
f.frame(0.1);
expect(f.run.debugInfo().running).toBe(false);
expect(f.boxX()).toBeCloseTo(10);
});
it('holds still while the project is playing – that world runs every behavior already', () => {
const f = scene();
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
f.state.enterPlay();
f.frame(0.1);
expect(f.run.debugInfo().running).toBe(false);
// The play copy carries the same ids, so the saved model must be the one left untouched.
f.state.exitPlay();
expect(f.boxX()).toBe(0);
});
it('does not run a behavior whose scene is not the loaded one', () => {
const f = scene();
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
f.frame(0.1);
expect(f.run.debugInfo().running).toBe(true);
const other = new CreateAssetCommand('scene', 'Level 2');
f.state.execute(other);
f.project.activeSceneId = other.asset.id;
f.frame(0.1);
f.frame(0.1);
expect(f.run.debugInfo().running).toBe(false);
expect(f.boxX()).toBeCloseTo(10);
});
});