Files
Crystal/tests/core.test.ts
T
2026-09-23 09:23:29 +02:00

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