/** * A persistent Variables asset keeps its values past the end of the run (#166): the next run starts * where the last one left off, from whatever area it is handed – played and editor runs get their own. */ import { describe, expect, it } from 'vitest'; import { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands'; import { AddVariableCommand, RemoveVariableCommand } from '../../src/core/commands/variableCommands'; import { restoreSaved, type SaveArea, type SavedValues, VARIABLES_PARAM } from '../../src/core/graph'; import type { VariablesAsset } from '../../src/core/model/Asset'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { graphEditor, makeBehavior, makeSpriteScene, makeVariables, place, type SceneFixture, startWorld, } from '../helpers'; /** An area in memory that counts its writes. */ function memoryArea(): SaveArea & { saved: SavedValues; writes: number } { const area = { saved: {} as SavedValues, writes: 0, read: () => structuredClone(area.saved), write: (saved: SavedValues) => { area.writes++; area.saved = structuredClone(saved); }, }; return area; } /** One object whose behavior adds one to `score` of the asset on start. */ function counting(persistent = true) { const f = makeSpriteScene(); const shared = makeVariables(f, 'Progress', ['score']); if (persistent) f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); const behavior = makeBehavior(f, 'Counter'); const editor = graphEditor(f, behavior); const params = { [VARIABLES_PARAM]: shared.id }; const start = editor.addNode('OnStart'); const read = editor.variables(['score'], { params }); const add = editor.addNode('Add', { b: 1 }); const write = editor.variables(['score'], { write: true, params }); editor.wire([ [start, 'exec', write, 'exec'], [read, 'score', add, 'a'], [add, 'result', write, 'score'], ]); place(f, behavior.id, { x: 0, y: 0 }); return { f, shared }; } function run(f: SceneFixture, saves: SaveArea) { const started = startWorld(f, { behaviors: true, saves }); started.frame(); return started; } const scoreOf = (started: ReturnType, id: string) => started.behaviors!.shared.variables[id]!.score; describe('a persistent Variables asset', () => { it('starts the next run where the last one ended', () => { const { f, shared } = counting(); const area = memoryArea(); expect(scoreOf(run(f, area), shared.id)).toBe(1); expect(scoreOf(run(f, area), shared.id)).toBe(2); expect(area.saved).toEqual({ [shared.id]: { score: 2 } }); }); it('keeps played and editor runs apart', () => { const { f, shared } = counting(); const played = memoryArea(); const editor = memoryArea(); run(f, played); run(f, played); expect(scoreOf(run(f, editor), shared.id)).toBe(1); expect(played.saved[shared.id]!.score).toBe(2); }); it('writes only in a frame that changed something', () => { const { f } = counting(); const area = memoryArea(); const started = run(f, area); const after = area.writes; started.frame(); started.frame(); expect(after).toBe(1); expect(area.writes).toBe(1); }); it('starts from the declared value again once the save is gone', () => { const { f, shared } = counting(); const area = memoryArea(); run(f, area); area.saved = {}; expect(scoreOf(run(f, area), shared.id)).toBe(1); }); }); describe('a Variables asset that is not persistent', () => { it('neither reads nor writes a save', () => { const { f, shared } = counting(false); const area = memoryArea(); area.saved = { [shared.id]: { score: 40 } }; expect(scoreOf(run(f, area), shared.id)).toBe(1); expect(area.writes).toBe(0); }); }); describe('restoring a save', () => { function project() { const f = makeSpriteScene(); const shared = makeVariables(f, 'Progress', ['score', 'lives']); f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); return { f, shared }; } it('follows the declaration as it is now, not as it was saved', () => { const { f, shared } = project(); f.stack.execute(new RemoveVariableCommand(shared.id, 'lives')); f.stack.execute(new AddVariableCommand(shared.id, 'name', 'text')); const variables = { [shared.id]: { score: 0, name: '' } }; restoreSaved(f.project, variables, { [shared.id]: { score: '12', lives: 3, name: 7 } }); // A key no longer declared stays out, and each value takes the kind declared today. expect(variables[shared.id]).toEqual({ score: 12, name: '7' }); }); it('leaves a key the save does not name at its declared value', () => { const { f, shared } = project(); const variables = { [shared.id]: { score: 0, lives: 3 } }; restoreSaved(f.project, variables, { [shared.id]: { score: 5 } }); expect(variables[shared.id]).toEqual({ score: 5, lives: 3 }); }); }); describe('the persistent switch', () => { it('is one undo step', () => { const f = makeSpriteScene(); const shared = makeVariables(f, 'Progress', ['score']); f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); expect(shared.persistent).toBe(true); f.stack.undo(); expect(shared.persistent).toBeUndefined(); }); it('survives saving and loading', () => { const f = makeSpriteScene(); const shared = makeVariables(f, 'Progress', ['score']); f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); const loaded = deserializeProject(JSON.parse(JSON.stringify(serializeProject(f.project)))); const asset = loaded.assets.find( (a): a is VariablesAsset => a.id === shared.id && a.type === 'variables', ); expect(asset?.persistent).toBe(true); }); });