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)); }); });