deadhead-space-freight-tycoon/packages/engine/test/storage.test.ts

126 lines
4.8 KiB
TypeScript

import { IDBFactory } from "fake-indexeddb";
import type { SaveDocument } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { fullSave } from "../../schema/test/save-fixture";
import {
FilesystemBackend,
IndexedDbBackend,
MemoryBackend,
SaveStorage,
type FilesystemPlugin,
type SaveBackend,
} from "../src/index";
/** A Capacitor Filesystem stand-in: files in a map, with failures on demand. */
class FakeFilesystem implements FilesystemPlugin {
files = new Map<string, string>();
/** Fail the next writeFile after writing this many characters of it. */
failWriteAfter: number | null = null;
failRename = false;
async readFile({ path }: { path: string }) {
const data = this.files.get(path);
if (data === undefined) throw new Error("File does not exist");
return { data };
}
async writeFile({ path, data }: { path: string; data: string }) {
if (this.failWriteAfter !== null) {
this.files.set(path, data.slice(0, this.failWriteAfter));
this.failWriteAfter = null;
throw new Error("Disk full");
}
this.files.set(path, data);
}
async rename({ from, to }: { from: string; to: string }) {
if (this.failRename) throw new Error("Interrupted");
const data = this.files.get(from);
if (data === undefined) throw new Error("File does not exist");
this.files.set(to, data);
this.files.delete(from);
}
async deleteFile({ path }: { path: string }) {
if (!this.files.delete(path)) throw new Error("File does not exist");
}
}
const version = (n: number): SaveDocument => ({ ...fullSave(), lastTimeSeen: 1_000 + n });
const backends: [string, () => SaveBackend][] = [
["memory", () => new MemoryBackend()],
["IndexedDB", () => new IndexedDbBackend(new IDBFactory())],
["filesystem", () => new FilesystemBackend(new FakeFilesystem())],
];
describe.each(backends)("save storage on %s (Step 14.3)", (_name, makeBackend) => {
it("is empty before the first save", async () => {
expect(await new SaveStorage(makeBackend()).load()).toEqual({ status: "empty" });
});
it("loads what it saved", async () => {
const storage = new SaveStorage(makeBackend());
await storage.save(version(1));
expect(await storage.load()).toEqual({ status: "loaded", save: version(1) });
});
it("keeps exactly the last three saves as backups, newest first", async () => {
const storage = new SaveStorage(makeBackend());
for (let n = 1; n <= 5; n++) await storage.save(version(n));
expect(await storage.listBackups()).toEqual([
{ index: 1, valid: true, lastTimeSeen: 1_004 },
{ index: 2, valid: true, lastTimeSeen: 1_003 },
{ index: 3, valid: true, lastTimeSeen: 1_002 },
]);
const second = await storage.loadBackup(2);
expect(second.ok && second.save.lastTimeSeen).toBe(1_003);
});
it("falls back to the newest valid backup when the main save is corrupted", async () => {
const backend = makeBackend();
const storage = new SaveStorage(backend);
await storage.save(version(1));
await storage.save(version(2));
await storage.save(version(3));
await backend.write("main", '{"saveVersion": 1, "company": ');
await backend.write("backup-1", JSON.stringify({ ...version(2), company: null }));
const result = await storage.load();
expect(result).toMatchObject({ status: "restored-from-backup", backup: 2, save: version(1) });
});
it("reports an unreadable save when nothing validates", async () => {
const backend = makeBackend();
await backend.write("main", "not a save");
expect(await new SaveStorage(backend).load()).toEqual({
status: "unreadable",
problem: "The save isn't valid JSON.",
});
});
it("clears the save and its backups", async () => {
const storage = new SaveStorage(makeBackend());
await storage.save(version(1));
await storage.save(version(2));
await storage.clear();
expect(await storage.load()).toEqual({ status: "empty" });
expect(await storage.listBackups()).toEqual([]);
});
});
describe("native writes are atomic (Step 14.3)", () => {
it("never leaves a half-written main file when a write is interrupted", async () => {
const fs = new FakeFilesystem();
const storage = new SaveStorage(new FilesystemBackend(fs));
await storage.save(version(1));
fs.failWriteAfter = 20;
await expect(storage.save(version(2))).rejects.toThrow("Disk full");
expect(await storage.load()).toEqual({ status: "loaded", save: version(1) });
fs.failRename = true;
await expect(storage.save(version(3))).rejects.toThrow("Interrupted");
expect(await storage.load()).toEqual({ status: "loaded", save: version(1) });
fs.failRename = false;
await storage.save(version(4));
expect(await storage.load()).toEqual({ status: "loaded", save: version(4) });
});
});