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 { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, GraphInterpreter, KEEP_STATE_PARAM, 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 { fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, type ProjectFixture, roundTrip, startWorld, } from '../helpers'; /** A behavior that counts its calls in its own `count` and hands the new count out as `out1`. */ function counter(fixture: ProjectFixture): BehaviorAsset { const callee = makeBehavior(fixture, 'Counter', ['count']); 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' })); const get = edit.variables(['count']); const plus = edit.addNode('Add', { b: 1 }); const set = edit.variables(['count'], { write: true }); edit.wire([ [start, 'exec', set, 'exec'], [get, 'count', plus, 'a'], [plus, 'result', set, 'count'], [set, 'exec', end, 'exec'], [plus, 'result', end, 'out1'], ]); return callee; } /** A caller that runs `callee` once per `event` through each of `sites` nodes, storing the results. */ function callerOf( fixture: ProjectFixture, callee: BehaviorAsset, keep: boolean, 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, [KEEP_STATE_PARAM]: keep }); 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; } describe('Run behavior with Keep state (#229)', () => { it('is off by default, so a call still starts from the defaults every time', () => { const fixture = makeProject(); const caller = callerOf(fixture, counter(fixture), false); const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!; const added = new AddNodeCommand(caller.id, RUN_BEHAVIOR, 0, 0); fixture.stack.execute(added); expect(added.node.params[KEEP_STATE_PARAM]).toBe(false); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables: { r0: 0 } }); for (let i = 0; i < 3; i++) interpreter.fire('OnStart', ctx); expect(ctx.variables.r0).toBe(1); expect(call.params[KEEP_STATE_PARAM]).toBe(false); }); it('resumes the callee’s variables where the last call from this node left them', () => { const fixture = makeProject(); const caller = callerOf(fixture, counter(fixture), true); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables: { r0: 0 } }); const seen: unknown[] = []; for (let i = 0; i < 3; i++) { interpreter.fire('OnStart', ctx); seen.push(ctx.variables.r0); } expect(seen).toEqual([1, 2, 3]); // 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 node: two nodes calling the same behavior count on their own', () => { const fixture = makeProject(); const caller = callerOf(fixture, counter(fixture), true, 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]); }); it('keeps one state per object in a running world', () => { const fixture = makeSpriteScene(); const callee = counter(fixture); const caller = callerOf(fixture, callee, true, 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 kept call inside a kept call its own state per outer node', () => { const fixture = makeProject(); const inner = counter(fixture); // The middle behavior just passes the counter through, keeping its call to it. 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, [KEEP_STATE_PARAM]: true }); edit.wire([ [start, 'exec', call, 'exec'], [call, 'exec', end, 'exec'], [call, 'out1', end, 'out1'], ]); const caller = callerOf(fixture, middle, true, 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('keeps a local state machine of the callee along with its variables', () => { 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". const callee = makeBehavior(fixture, 'Door'); 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'], ]); for (const keep of [false, true]) { const caller = makeBehavior(fixture, `Caller ${keep}`); const cEdit = graphEditor(fixture, caller); cEdit.connect( cEdit.addNode('OnStart'), 'exec', cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, [KEEP_STATE_PARAM]: keep }), '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(keep ? ['first', 'open'] : ['first', 'first']); // A local machine of the callee is none of the caller's business either way. expect(ctx.states).toEqual({}); } }); it('survives saving and loading', () => { const fixture = makeProject(); const caller = callerOf(fixture, counter(fixture), true); const loaded = roundTrip(fixture.project).assets.find((a) => a.id === caller.id) as BehaviorAsset; expect(loaded.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!.params[KEEP_STATE_PARAM]).toBe(true); }); }); 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 })).toEqual({ count: 7, fresh: 5 }); }); 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' })).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); expect(variables).toEqual(defaults); expect(variables).not.toBe(defaults); }); });