Files
fool-cardgame/tests/modules/save/SaveManager.spec.ts
T
ftcandClaude Opus 5 51117a698c M3: модуль сохранения
По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию.
Ключи — явные строковые константы, а не имена классов: релизная сборка
минифицирует имена и молча потеряла бы все сохранения.

- ISaveSection, SaveManager, IStorage + MemoryStorage
- CocosStorage: единственный файл модуля, знающий про движок;
  при отказе хранилища уходит в память, а не роняет игру
- StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия
- терпимая загрузка: битый JSON, чужая версия, сломанная секция
  или отвалившееся хранилище не ломают остальное
- проверка чистоты расширена на modules/ со списком адаптеров

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:45:52 +03:00

278 lines
8.6 KiB
TypeScript

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);
});
});