import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ISaveSection } from '@modules/save/ISaveSection'; import { IStorage, MemoryStorage } from '@modules/save/IStorage'; import { SAVE_FORMAT_VERSION, SaveManager } from '@modules/save/SaveManager'; import { StatsSection } from '@modules/save/StatsSection'; const SLOT = 'test.save'; /** A minimal section, so manager behaviour can be tested without StatsSection. */ class CounterSection implements ISaveSection { readonly key: string; value = 0; constructor(key = 'counter') { this.key = key; } save(): unknown { return { value: this.value }; } load(data: unknown): void { const record = data as { value?: unknown }; if (typeof record?.value !== 'number') { throw new Error('not a counter'); } this.value = record.value; } reset(): void { this.value = 0; } } function makeManager(storage: IStorage = new MemoryStorage()) { const warnings: string[] = []; const manager = new SaveManager(storage, { slot: SLOT, warn: (m) => warnings.push(m) }); return { manager, storage, warnings }; } describe('SaveManager', () => { it('writes one document keyed by section', () => { const { manager, storage } = makeManager(); const counter = new CounterSection(); manager.register(counter); counter.value = 7; manager.save(); const raw = JSON.parse(storage.getItem(SLOT) as string); expect(raw).toEqual({ version: SAVE_FORMAT_VERSION, sections: { counter: { value: 7 } } }); }); it('round-trips through a fresh manager', () => { const storage = new MemoryStorage(); const first = makeManager(storage); const a = new CounterSection(); first.manager.register(a); a.value = 42; first.manager.save(); const second = makeManager(storage); const b = new CounterSection(); second.manager.register(b); second.manager.load(); expect(b.value).toBe(42); }); it('resets a section that has never been saved', () => { const { manager } = makeManager(); const counter = new CounterSection(); counter.value = 99; manager.register(counter); manager.load(); expect(counter.value).toBe(0); }); it('rejects two sections with the same key', () => { const { manager } = makeManager(); manager.register(new CounterSection('dup')); expect(() => manager.register(new CounterSection('dup'))).toThrow(/already registered/); }); it('clears storage and every section on reset', () => { const { manager, storage } = makeManager(); const counter = new CounterSection(); manager.register(counter); counter.value = 5; manager.save(); manager.reset(); expect(counter.value).toBe(0); expect(storage.getItem(SLOT)).toBeNull(); }); }); describe('SaveManager recovering from bad data', () => { it('starts fresh when the save is not JSON', () => { const storage = new MemoryStorage(); storage.setItem(SLOT, 'not json at all'); const { manager, warnings } = makeManager(storage); const counter = new CounterSection(); counter.value = 3; manager.register(counter); manager.load(); expect(counter.value).toBe(0); expect(warnings.join(' ')).toMatch(/not valid JSON/); }); it('starts fresh when the save has the wrong shape', () => { const storage = new MemoryStorage(); storage.setItem(SLOT, JSON.stringify([1, 2, 3])); const { manager, warnings } = makeManager(storage); manager.register(new CounterSection()); manager.load(); expect(warnings.join(' ')).toMatch(/unrecognisable/); }); it('resets only the section whose payload is broken', () => { const storage = new MemoryStorage(); storage.setItem(SLOT, JSON.stringify({ version: SAVE_FORMAT_VERSION, sections: { good: { value: 11 }, bad: 'rubbish' }, })); const { manager, warnings } = makeManager(storage); const good = new CounterSection('good'); const bad = new CounterSection('bad'); bad.value = 8; manager.register(good); manager.register(bad); manager.load(); expect(good.value).toBe(11); expect(bad.value).toBe(0); expect(warnings.join(' ')).toMatch(/'bad' failed to load/); }); it('reads what it can from a save written by a newer build', () => { const storage = new MemoryStorage(); storage.setItem(SLOT, JSON.stringify({ version: SAVE_FORMAT_VERSION + 5, sections: { counter: { value: 4 }, unknownFutureThing: { x: 1 } }, })); const { manager, warnings } = makeManager(storage); const counter = new CounterSection(); manager.register(counter); manager.load(); expect(counter.value).toBe(4); expect(warnings.join(' ')).toMatch(/newer version/); }); it('survives a storage device that throws', () => { const broken: IStorage = { getItem: () => { throw new Error('quota'); }, setItem: () => { throw new Error('quota'); }, removeItem: () => { throw new Error('quota'); }, }; const { manager, warnings } = makeManager(broken); manager.register(new CounterSection()); expect(() => { manager.load(); manager.save(); manager.reset(); }).not.toThrow(); expect(warnings.length).toBeGreaterThan(0); }); it('survives a section that throws while saving', () => { const { manager, storage, warnings } = makeManager(); manager.register({ key: 'explodes', save() { throw new Error('boom'); }, load() { /* never reached */ }, reset() { /* nothing */ }, }); const counter = new CounterSection(); manager.register(counter); counter.value = 2; manager.save(); const raw = JSON.parse(storage.getItem(SLOT) as string); expect(raw.sections.counter).toEqual({ value: 2 }); expect(warnings.join(' ')).toMatch(/'explodes' failed to save/); }); it('warns through the console when no handler is supplied', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const storage = new MemoryStorage(); storage.setItem('fool.save', '{'); new SaveManager(storage).load(); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); }); describe('StatsSection', () => { let stats: StatsSection; beforeEach(() => { stats = new StatsSection(); }); it('starts empty', () => { expect(stats.played).toBe(0); expect(stats.won).toBe(0); expect(stats.bestStreak).toBe(0); }); it('counts each outcome', () => { stats.recordResult('win'); stats.recordResult('loss'); stats.recordResult('draw'); expect(stats.played).toBe(3); expect(stats.won).toBe(1); expect(stats.lost).toBe(1); expect(stats.drawn).toBe(1); }); it('tracks the current streak and the best one', () => { stats.recordResult('win'); stats.recordResult('win'); stats.recordResult('win'); expect(stats.streak).toBe(3); expect(stats.bestStreak).toBe(3); stats.recordResult('loss'); expect(stats.streak).toBe(0); expect(stats.bestStreak).toBe(3); stats.recordResult('win'); expect(stats.streak).toBe(1); expect(stats.bestStreak).toBe(3); }); it('does not let a draw break a streak', () => { stats.recordResult('win'); stats.recordResult('draw'); expect(stats.streak).toBe(1); }); it('round-trips through a save', () => { stats.recordResult('win'); stats.recordResult('win'); stats.recordResult('loss'); const restored = new StatsSection(); restored.load(JSON.parse(JSON.stringify(stats.save()))); expect(restored.save()).toEqual(stats.save()); }); it('repairs a hand-edited save instead of trusting it', () => { stats.load({ played: 'lots', won: -4, lost: 2.7, streak: 9, bestStreak: 1 }); expect(stats.played).toBe(0); expect(stats.won).toBe(0); expect(stats.lost).toBe(2); expect(stats.streak).toBe(9); expect(stats.bestStreak).toBe(9); }); it('falls back to defaults for a payload of the wrong type', () => { stats.recordResult('win'); stats.load(null); expect(stats.played).toBe(0); }); });