This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -0,0 +1,163 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user