Keep is a choice the author makes per variable. Switching it on for every new variable meant most behaviors carried state across runs that nobody asked for, and the toggle had to be turned off by hand. - AddVariableCommand no longer adds a new variable to keptVariables. Keep is only switched on by hand. - Existing projects are unchanged. Their keptVariables stay as saved. - The comment in Asset.ts describes the new default. - The counter fixture in keep-state.test.ts now keeps count explicitly instead of relying on the old default. Tests: - ./check.sh: 2706 tests, CHECK EXIT 0 - e2e 1, 6, 19: ALL PASSED, 172 checks - 19c-ii expects [] after adding, then ["var1"], [] and ["var1"] after undo, all clicked with the real mouse - Before the fixtures were adjusted, 8 unit tests failed on the new default Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
13 KiB
TypeScript
308 lines
13 KiB
TypeScript
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 left 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, 'count', true));
|
||
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 off for a variable added to a behavior, and on a Variables asset', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', ['count']);
|
||
expect('keptVariables' in behavior).toBe(false);
|
||
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);
|
||
});
|
||
});
|