Files
feuerwerk/tests/core/keep-state.test.ts
T
adminandClaude Opus 5.5 454d202109
game-maker / gate (push) Successful in 3m13s
game-maker / suite (push) Failing after 8m46s
Let each variable of a behavior choose whether calls keep it (#229)
The first version of #229 put a Keep state switch on the Run behavior
node. The user wanted it where the variable is declared instead. Whether
a value lives on belongs to the variable, not to every call site.

- The node switch keepState is gone again. RunBehavior, CallResult and
  nested.ts are back to what they were before #229.
- BehaviorAsset.keptVariables lists the kept names. It lives on the
  asset and not in PropertyDef, because that is the schema of every
  inspector row.
- A variable added from now on starts with Keep on. Variables in
  existing projects start with Keep off, so old projects run unchanged.
- SetVariableKeptCommand flips the switch as one undo step. Add, remove
  and rename carry the list along. Loading drops names that are not
  declared.
- The interpreter keeps only the listed variables, per Run behavior
  node and per object as before. The others start at their defaults.
- Local state machines in a called behavior start empty on every call
  again, as before #229. Global and scene machines are unchanged.
- The inspector shows a Keep button in each variable row of a behavior.
  Its state is part of the asset signature, so a click redraws the row.
  Without that the button kept its old look until the inspector was
  reopened.

Found: the project Garden of Sacrifices still has keepState = true on
the call Player -> Character Movement. It is ignored now. Direction
there predates the switch, so Keep is off until set by hand.

Tests:
- ./check.sh: 163 files, 2392 tests, CHECK EXIT 0 (before 2388)
- tests/core/keep-state.test.ts rewritten, 15 tests
- e2e 19: ALL PASSED, 35 checks. 19c-ii now clicks the Keep button
  of a new variable: on when added, off by a click, on again, off
  after undo.
- Counter-checks, each fails its own test: keep list ignored (2),
  states kept (1), new variable not kept (8), rename drops the list
  (1), no Keep button (e2e 19c-ii).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:50:59 +02:00

308 lines
13 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.
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { AddPortCommand } from '../../src/core/commands/portCommands';
import {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
SetVariableKeptCommand,
} from '../../src/core/commands/variableCommands';
import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
GraphInterpreter,
MACHINE_PORT,
RUN_BEHAVIOR,
resumedVariables,
STATE,
STATE_NAME_PARAM,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import {
fakeContext,
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
type ProjectFixture,
roundTrip,
startWorld,
} from '../helpers';
/**
* A behavior that counts its calls in `count` and hands the new count out as `out1`. `steps`
* counts the same way but is switched off, so it shows what an unkept variable does meanwhile.
*/
function counter(fixture: ProjectFixture): BehaviorAsset {
const callee = makeBehavior(fixture, 'Counter', ['count', 'steps']);
fixture.stack.execute(new SetVariableKeptCommand(callee.id, 'steps', false));
const edit = graphEditor(fixture, callee);
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' }));
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out2', kind: 'number' }));
const get = edit.variables(['count', 'steps']);
const plus = edit.addNode('Add', { b: 1 });
const plusSteps = edit.addNode('Add', { b: 1 });
const set = edit.variables(['count', 'steps'], { write: true });
edit.wire([
[start, 'exec', set, 'exec'],
[get, 'count', plus, 'a'],
[get, 'steps', plusSteps, 'a'],
[plus, 'result', set, 'count'],
[plusSteps, 'result', set, 'steps'],
[set, 'exec', end, 'exec'],
[plus, 'result', end, 'out1'],
[plusSteps, 'result', end, 'out2'],
]);
return callee;
}
/** A caller that runs `callee` once per `event` through each of `sites` nodes, storing `out1`. */
function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, sites = 1, event = 'OnStart') {
const keys = Array.from({ length: sites }, (_, i) => `r${i}`);
const caller = makeBehavior(fixture, 'Caller', keys);
const edit = graphEditor(fixture, caller);
let previous = edit.addNode(event);
for (const key of keys) {
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const store = edit.variables([key], { write: true });
edit.wire([
[previous, 'exec', call, 'exec'],
[call, 'exec', store, 'exec'],
[call, 'out1', store, key],
]);
previous = store;
}
return caller;
}
/** `out1` and `out2` of three calls from one node. */
function threeCalls(fixture: ProjectFixture, callee: BehaviorAsset): unknown[][] {
const caller = makeBehavior(fixture, 'Caller', ['r0', 'r1']);
const edit = graphEditor(fixture, caller);
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const store = edit.variables(['r0', 'r1'], { write: true });
edit.wire([
[edit.addNode('OnStart'), 'exec', call, 'exec'],
[call, 'exec', store, 'exec'],
[call, 'out1', store, 'r0'],
[call, 'out2', store, 'r1'],
]);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
const seen: unknown[][] = [];
for (let i = 0; i < 3; i++) {
interpreter.fire('OnStart', ctx);
seen.push([ctx.variables.r0, ctx.variables.r1]);
}
return seen;
}
describe('Keep on a behavior variable (#229)', () => {
it('starts on for a variable added to a behavior, and off on a Variables asset', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['count']);
expect(behavior.keptVariables).toEqual(['count']);
const shared = new CreateAssetCommand('variables', 'Shared');
fixture.stack.execute(shared);
fixture.stack.execute(new AddVariableCommand(shared.asset.id, 'score', 'number'));
expect('keptVariables' in shared.asset).toBe(false);
});
it('keeps none in a project saved before the switch existed', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'Old', ['count']);
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
delete raw.assets.find((a) => a.id === behavior.id)!.keptVariables;
const loaded = deserializeProject(raw).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
expect(loaded.keptVariables).toBeUndefined();
});
it('resumes a kept variable from the last call of this node, and restarts an unkept one', () => {
const fixture = makeProject();
// count is kept and counts 1, 2, 3; steps is not and reads 1 every time.
expect(threeCalls(fixture, counter(fixture))).toEqual([
[1, 1],
[2, 1],
[3, 1],
]);
});
it('switches off and on as one undo step each', () => {
const fixture = makeProject();
const callee = counter(fixture);
fixture.stack.execute(new SetVariableKeptCommand(callee.id, 'count', false));
expect(callee.keptVariables).toBeUndefined();
fixture.stack.undo();
expect(callee.keptVariables).toEqual(['count']);
fixture.stack.redo();
expect(callee.keptVariables).toBeUndefined();
expect(threeCalls(fixture, callee).map(([count]) => count)).toEqual([1, 1, 1]);
});
it('keeps one state per node: two nodes calling the same behavior count on their own', () => {
const fixture = makeProject();
const caller = callerOf(fixture, counter(fixture), 2);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
interpreter.fire('OnStart', ctx);
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([1, 1]);
interpreter.fire('OnStart', ctx);
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]);
// The caller's own bag never learns of `count`: the kept values stay the callee's.
expect('count' in ctx.variables).toBe(false);
});
it('keeps one state per object in a running world', () => {
const fixture = makeSpriteScene();
const callee = counter(fixture);
const caller = callerOf(fixture, callee, 1, 'OnUpdate');
const log = graphEditor(fixture, caller).addNode('Log');
const store = caller.graph.nodes.filter((n) => n.type === 'Variables').at(-1)!;
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
graphEditor(fixture, caller).wire([
[store, 'exec', log, 'exec'],
[call, 'out1', log, 'message'],
]);
place(fixture, caller.id, { x: 0, y: 0 });
place(fixture, caller.id, { x: 10, y: 0 });
const started = startWorld(fixture, { behaviors: true });
for (let i = 0; i < 3; i++) started.frame();
// Shared across objects it would read 1…6; per object each counts 1, 2, 3.
expect(started.world.log).toEqual(['1', '1', '2', '2', '3', '3']);
});
it('gives a call inside a call its own state per outer node', () => {
const fixture = makeProject();
const inner = counter(fixture);
// The middle behavior just passes the counter through.
const middle = makeBehavior(fixture, 'Middle');
const edit = graphEditor(fixture, middle);
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(middle.id, end.id, { key: 'out1', kind: 'number' }));
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id });
edit.wire([
[start, 'exec', call, 'exec'],
[call, 'exec', end, 'exec'],
[call, 'out1', end, 'out1'],
]);
const caller = callerOf(fixture, middle, 2);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
interpreter.fire('OnStart', ctx);
interpreter.fire('OnStart', ctx);
// One shared inner counter would have handed out 1, 2, 3, 4.
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]);
});
it('starts a local state machine of the callee over every call, kept variables or not', () => {
const fixture = makeProject();
const create = new CreateAssetCommand('statemachine', 'Doors');
fixture.stack.execute(create);
const open = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' });
fixture.stack.execute(open);
const machine = create.asset.id;
// No state yet → "first" and put it in Open; in Open → "open". The kept variable is only
// there to show that keeping one does not keep the machine along with it.
const callee = makeBehavior(fixture, 'Door', ['count']);
const edit = graphEditor(fixture, callee);
const start = edit.addNode(BEHAVIOR_START);
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine });
const first = edit.addNode('Log', { message: 'first' });
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine });
const again = edit.addNode('Log', { message: 'open' });
edit.wire([
[start, 'exec', branch, 'exec'],
[branch, 'else', first, 'exec'],
[first, 'exec', set, open.node.id],
[branch, open.node.id, again, 'exec'],
]);
const caller = makeBehavior(fixture, 'Caller');
const cEdit = graphEditor(fixture, caller);
cEdit.connect(
cEdit.addNode('OnStart'),
'exec',
cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }),
'exec',
);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext();
interpreter.fire('OnStart', ctx);
interpreter.fire('OnStart', ctx);
expect(ctx.logs).toEqual(['first', 'first']);
// A local machine of the callee is none of the caller's business either.
expect(ctx.states).toEqual({});
});
it('survives saving and loading, and a name that is not declared is dropped', () => {
const fixture = makeProject();
const callee = counter(fixture);
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
raw.assets.find((a) => a.id === callee.id)!.keptVariables = ['count', 'gone', 'count', 7];
const loaded = deserializeProject(raw).assets.find((a) => a.id === callee.id) as BehaviorAsset;
expect(loaded.keptVariables).toEqual(['count']);
const again = roundTrip(fixture.project).assets.find((a) => a.id === callee.id) as BehaviorAsset;
expect(again.keptVariables).toEqual(['count']);
});
it('follows its variable through a rename, and a removal gives it back on undo', () => {
const fixture = makeProject();
const callee = counter(fixture);
fixture.stack.execute(new RenameVariableCommand(callee.id, 'count', 'hits'));
expect(callee.keptVariables).toEqual(['hits']);
fixture.stack.execute(new RemoveVariableCommand(callee.id, 'hits'));
expect(callee.keptVariables).toBeUndefined();
fixture.stack.undo();
expect(callee.keptVariables).toEqual(['hits']);
fixture.stack.undo();
expect(callee.keptVariables).toEqual(['count']);
});
it('is gone from Run behavior, which has no switch of its own any more', () => {
const fixture = makeProject();
const caller = makeBehavior(fixture, 'Caller');
const added = new AddNodeCommand(caller.id, RUN_BEHAVIOR, 0, 0);
fixture.stack.execute(added);
expect(Object.keys(added.node.params)).toEqual([BEHAVIOR_PARAM]);
});
});
describe('resumedVariables', () => {
it('follows today’s declarations: new ones at their default, dropped ones gone', () => {
expect(resumedVariables({ count: 0, fresh: 5 }, { count: 7, gone: 1 }, ['count', 'fresh'])).toEqual({
count: 7,
fresh: 5,
});
});
it('restarts a variable that is not kept, whatever it was left at', () => {
expect(resumedVariables({ count: 0, steps: 0 }, { count: 7, steps: 4 }, ['count'])).toEqual({
count: 7,
steps: 0,
});
});
it('falls back to the default where the kept value is of another sort now', () => {
expect(
resumedVariables({ count: 0, name: 'a' }, { count: 'seven', name: 'b' }, ['count', 'name']),
).toEqual({
count: 0,
name: 'b',
});
});
it('is a fresh copy of the defaults when nothing was kept yet', () => {
const defaults = { count: 0 };
const variables = resumedVariables(defaults, undefined, ['count']);
expect(variables).toEqual(defaults);
expect(variables).not.toBe(defaults);
});
});