126 lines
4.8 KiB
TypeScript
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) });
|
|
});
|
|
});
|