164 lines
5.5 KiB
TypeScript
164 lines
5.5 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import "fake-indexeddb/auto";
|
|
import { InputController } from "../src/input/controller";
|
|
import { CRYSTAL_ROM, validateRom } from "../src/rom/validate";
|
|
import {
|
|
BrowserSaveRepository,
|
|
decodeSave,
|
|
encodeSave,
|
|
type SaveRecord,
|
|
} from "../src/storage/saves";
|
|
import { GameSession } from "../src/app/session";
|
|
import type { Emulator } from "../src/emulator/types";
|
|
|
|
const record: SaveRecord = {
|
|
version: 1,
|
|
romHash: "test-rom",
|
|
coreId: "test-core",
|
|
savedAt: "2026-09-22T12:00:00.000Z",
|
|
state: new Uint8Array([0, 128, 255]),
|
|
};
|
|
|
|
describe("ROM validation", () => {
|
|
it("rejects incorrect sizes without reading the file", async () => {
|
|
const file = new File(["not a game"], "crystal.gbc");
|
|
await expect(validateRom(file)).rejects.toThrow("2 MiB");
|
|
});
|
|
it("rejects a correctly sized file with a forged title and revision", async () => {
|
|
const bytes = new Uint8Array(CRYSTAL_ROM.size);
|
|
bytes.set(new TextEncoder().encode("PM_CRYSTAL"), 0x134);
|
|
bytes[0x14c] = 1;
|
|
await expect(
|
|
validateRom(new File([bytes], "Pokemon Crystal EU 1.1.gbc")),
|
|
).rejects.toThrow("not the supported");
|
|
});
|
|
});
|
|
|
|
describe("input aggregation", () => {
|
|
it("keeps a button pressed until all sources release it", () => {
|
|
const send = vi.fn();
|
|
const input = new InputController(send);
|
|
input.press("finger:1", "a");
|
|
input.press("keyboard:x", "a");
|
|
input.release("finger:1");
|
|
expect(send.mock.calls).toEqual([["a", true]]);
|
|
input.release("keyboard:x");
|
|
expect(send.mock.calls).toEqual([
|
|
["a", true],
|
|
["a", false],
|
|
]);
|
|
});
|
|
it("slides a pointer to a different direction and releases everything on interruption", () => {
|
|
const send = vi.fn();
|
|
const input = new InputController(send);
|
|
input.press("finger:1", "up");
|
|
input.press("finger:1", "left");
|
|
expect(send.mock.calls).toEqual([
|
|
["up", true],
|
|
["up", false],
|
|
["left", true],
|
|
]);
|
|
input.releaseAll();
|
|
expect(input.isPressed("left")).toBe(false);
|
|
expect(send).toHaveBeenCalledWith("left", false);
|
|
});
|
|
});
|
|
|
|
describe("saves", () => {
|
|
it("round trips binary state and rejects incompatible imports", () => {
|
|
const text = encodeSave(record);
|
|
expect(decodeSave(text, record.romHash, record.coreId)).toEqual(record);
|
|
expect(() => decodeSave(text, "another-rom", record.coreId)).toThrow(
|
|
"not compatible",
|
|
);
|
|
expect(() => decodeSave(text, record.romHash, "another-core")).toThrow(
|
|
"not compatible",
|
|
);
|
|
expect(() =>
|
|
decodeSave(
|
|
JSON.stringify({ ...record, state: "???=" }),
|
|
record.romHash,
|
|
record.coreId,
|
|
),
|
|
).toThrow("damaged");
|
|
});
|
|
it("persists isolated ROM slots through a fresh repository", async () => {
|
|
const first = new BrowserSaveRepository();
|
|
await first.write(record);
|
|
const second = new BrowserSaveRepository();
|
|
expect(await second.read(record.romHash)).toEqual(record);
|
|
expect(await second.read("another-rom")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
function setup(saved?: SaveRecord) {
|
|
let bytes = new Uint8Array([3]);
|
|
const emulator: Emulator = {
|
|
id: record.coreId,
|
|
load: vi.fn(async () => {}),
|
|
play: vi.fn(),
|
|
pause: vi.fn(),
|
|
setButton: vi.fn(),
|
|
setMuted: vi.fn(),
|
|
unlockAudio: vi.fn(),
|
|
captureState: () => bytes.slice(),
|
|
restoreState: vi.fn((state) => {
|
|
if (state[0] === 255) throw new Error("Bad state");
|
|
bytes = new Uint8Array(state);
|
|
}),
|
|
exportBattery: () => bytes.slice(),
|
|
dispose: vi.fn(),
|
|
};
|
|
const saves = {
|
|
read: vi.fn(async () => saved),
|
|
write: vi.fn(async (_value: SaveRecord) => {}),
|
|
};
|
|
const session = new GameSession(emulator, saves);
|
|
const rom = {
|
|
bytes: new Uint8Array([1]),
|
|
sha1: record.romHash,
|
|
name: "Synthetic",
|
|
};
|
|
return { emulator, saves, session, rom };
|
|
}
|
|
|
|
describe("session lifecycle", () => {
|
|
it("loads a saved session paused and saves while playing without pausing", async () => {
|
|
const { emulator, saves, session, rom } = setup(record);
|
|
expect(await session.load(rom, {} as HTMLCanvasElement)).toBe(true);
|
|
expect(session.state).toBe("paused");
|
|
expect(emulator.restoreState).toHaveBeenCalledWith(record.state);
|
|
session.play();
|
|
await session.save();
|
|
expect(session.state).toBe("playing");
|
|
expect(saves.write).toHaveBeenCalledOnce();
|
|
session.pause();
|
|
expect(session.state).toBe("paused");
|
|
});
|
|
it("preserves saves from another core and refuses to overwrite them", async () => {
|
|
const { session, rom, saves } = setup({ ...record, coreId: "old" });
|
|
await expect(session.load(rom, {} as HTMLCanvasElement)).rejects.toThrow(
|
|
"migration",
|
|
);
|
|
expect(saves.write).not.toHaveBeenCalled();
|
|
expect(() => session.snapshot()).toThrow("Load a ROM");
|
|
});
|
|
it("rolls back a failed backup import", async () => {
|
|
const { session, rom } = setup();
|
|
await session.load(rom, {} as HTMLCanvasElement);
|
|
await expect(
|
|
session.importSave(
|
|
encodeSave({ ...record, state: new Uint8Array([255]) }),
|
|
),
|
|
).rejects.toThrow("Bad state");
|
|
expect(session.snapshot().state).toEqual(new Uint8Array([3]));
|
|
});
|
|
it("reports write failures and allows a later save to succeed", async () => {
|
|
const { session, rom, saves } = setup();
|
|
await session.load(rom, {} as HTMLCanvasElement);
|
|
saves.write.mockRejectedValueOnce(new Error("Quota exceeded"));
|
|
await expect(session.save()).rejects.toThrow("Quota exceeded");
|
|
await expect(session.save()).resolves.toEqual(expect.any(String));
|
|
});
|
|
});
|