По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию. Ключи — явные строковые константы, а не имена классов: релизная сборка минифицирует имена и молча потеряла бы все сохранения. - ISaveSection, SaveManager, IStorage + MemoryStorage - CocosStorage: единственный файл модуля, знающий про движок; при отказе хранилища уходит в память, а не роняет игру - StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия - терпимая загрузка: битый JSON, чужая версия, сломанная секция или отвалившееся хранилище не ломают остальное - проверка чистоты расширена на modules/ со списком адаптеров Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
278 lines
8.6 KiB
TypeScript
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);
|
|
});
|
|
});
|