M3: модуль сохранения
По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию. Ключи — явные строковые константы, а не имена классов: релизная сборка минифицирует имена и молча потеряла бы все сохранения. - ISaveSection, SaveManager, IStorage + MemoryStorage - CocosStorage: единственный файл модуля, знающий про движок; при отказе хранилища уходит в память, а не роняет игру - StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия - терпимая загрузка: битый JSON, чужая версия, сломанная секция или отвалившееся хранилище не ломают остальное - проверка чистоты расширена на modules/ со списком адаптеров Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user