Files
feuerwerk/tests/core/serialize.test.ts
T
rootandClaude Opus 5 d1fcfe8b81 A shared value belongs to the run, so a declaration carries its scope (#91)
A variable or a state machine declared `global` keeps one value for the whole
run instead of one per behavior instance. The scope sits on the declaration --
`PropertyDef.scope` and `StateMachineAsset.scope`, both optional, absence
meaning `local` -- and `local` is never written, so every project made before
this saves back unchanged.

**The name is the key.** Two behaviors declaring a global `score` mean one
variable; a behavior declaring `score` locally keeps its own. Kinds that
disagree about one global name stop the run before it starts, with the two
behaviors named; starting values that disagree are a warning on the behavior
that declares it, and the first declaration wins (`globalConflicts`).

The runtime needs no redirection layer. A global variable falls out of
`schemaForObject`, so it never reaches `entity.props` -- and then the whole rule
is "whoever declares the name takes their own value" (`graph/sharedState.ts`).
The `Variables` node is untouched and stays transparent: switching a variable to
global breaks no existing graph, because `GraphInterpreter.readAll` builds a
fresh object per read anyway and only asks `port.key in all`.

That same cut answers the inspector. A shared value has no owner, so the placed
object shows no row for it and can hold no override -- `SetVariableScopeCommand`
takes the deviations that were there and undo gives them back.

Both places say it in words rather than leaving a glyph to be guessed: the
declaration explains the mark below the list, and the placed object names the
variable it no longer holds instead of claiming the behavior declares none --
which is what it said before, untruthfully. Screenshots of the e2e run turned
that up; no check had been looking at it.

`SharedState` hangs off `NodeContext`, is built in `BehaviorSystem.start()` from
the *declarations* rather than from any instance (a global variable exists
before its behavior is placed), and is thrown away with the run: nothing is
written back, not even in the editor run. `GraphInterpreter.call` passes it on
through `...ctx` while nested calls keep getting fresh local variables, which is
why `program.defaults` is stripped of global keys.

Two traps this hit. `defSignature`/`assetSignature` have to carry the scope or
the inspector does not rebuild after the switch -- and a state machine has no
rows at all, so its scope is appended to the signature by hand. And the object
inspector does not go through `schemaForObject` for behaviors, it reads
`asset.variables` directly; filtering in one place was not enough, and no unit
test covered that path. The e2e caught it (`global: true` where false belonged),
and the rule now lives in one named function, `objectRows`.

Scopes `object` and `scene` are deliberately not built. The object case already
has a route (`Object by name` -> `Properties`), and a run knows exactly one
scene, so `scene` would be indistinguishable from `global` until #39 exists.

Closes #59: a machine's scope is a word on the machine asset, not a setting per
`Set state` node, so the same machine cannot live in two places at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:22:53 +02:00

125 lines
4.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
SetAssetPropertyCommand,
SetMachineScopeCommand,
} from '../../src/core/commands/assetCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { SetVariableScopeCommand } from '../../src/core/commands/variableCommands';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
describe('serialize', () => {
it('round-trips a project', () => {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 7, y: 8 });
f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'width', 33));
expect(roundTrip(f.project)).toEqual(f.project);
});
/** A behavior's own rows are not the common ones: coercing against those alone dropped the value. */
it('keeps a behavior running in the editor across a save', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B');
f.stack.execute(new SetAssetPropertyCommand(behavior.id, 'runInEditor', true));
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id)!;
expect(loaded.props.runInEditor).toBe(true);
});
it('round-trips the scope of a variable and of a state machine', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B', ['score']);
f.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global'));
const machine = new CreateAssetCommand('statemachine', 'M');
f.stack.execute(machine);
f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global'));
expect(roundTrip(f.project)).toEqual(f.project);
});
/** `local` is the absence of the field, so a project made before scopes existed saves back byte-alike. */
it('writes no scope for a local variable and reads an unknown one as local', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B', ['score']);
expect(JSON.stringify(serializeProject(f.project))).not.toContain('scope');
const loaded = deserializeProject({
assets: [
{
id: behavior.id,
type: 'behavior',
name: 'B',
variables: [{ key: 'score', kind: 'number', scope: 'planet' }],
},
{ id: 'm1', type: 'statemachine', name: 'M', scope: 'planet' },
],
});
const reloaded = loaded.assets[0]!;
const machine = loaded.assets[1]!;
if (reloaded.type !== 'behavior' || machine.type !== 'statemachine') throw new Error('wrong types');
expect(reloaded.variables[0]!.scope).toBeUndefined();
expect(machine.scope).toBeUndefined();
});
it('repairs broken documents', () => {
const project = deserializeProject(
{
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: 'sp1', type: 'sprite', props: { width: 10 } },
// An asset type this build does not know (a newer document, a renamed type).
{ id: 'x', type: 'sound' },
{ type: 'camera' },
],
},
'fallback-id',
);
expect(project.id).toBe('fallback-id');
expect(project.activeSceneId).toBe('s1');
expect(project.assets.map((a) => a.id)).toEqual(['s1', 'sp1']);
const scene = project.assets[0]!;
expect(scene.type === 'scene' && scene.props).toEqual({ backgroundColor: 0 });
const objects = scene.type === 'scene' ? scene.objects : [];
expect(objects).toHaveLength(1);
expect(objects[0]).toEqual({
id: 'o1',
assetId: 'sp1',
name: '',
position: { x: 5, y: 0, z: 0 },
editorOffset: { x: 0, y: 0 },
overrides: { color: 0x00ff00, width: 1 },
parentId: '',
instanceEdits: {},
});
expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 10,
height: 100,
color: 0xffffff,
texture: '',
shader: '',
});
});
});