Checks / web (push) Canceled after 0s
A new game now runs NewGame's InitGender and OakSpeech natively before the bedroom: Boy or Girl, InitClock's hour and minute steppers with their YES/NO confirmations, Oak's speech with Wooper, and NamePlayer's preset names or naming field (a blank name takes CHRIS or KRIS). Texts, menus and time strings come from a new INTRO profile table verified by check:reference; Oak's and the player's pictures decode from TrainerPicPointers, ChrisPic/KrisPic and TrainerPalettes. The chosen time is kept as a minute offset from the device clock, which time of day, the weekday, NPC hours and the daily reset now read. The intro runs only when nothing is saved; a save made during it starts it again. Saves gain intro and clock.minuteOffset, migrated by absence. The shell's name and character fields are gone. Intentional change: the steppers start at the device time rather than 10:00. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
9.9 KiB
TypeScript
266 lines
9.9 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { syntheticRom } from "./synthetic-rom";
|
|
|
|
test("ROM gate, help and responsive layouts", async ({ page }) => {
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
await page.goto("/");
|
|
await expect(
|
|
page.getByRole("tab", { name: "Game", exact: true }),
|
|
).toHaveAttribute("aria-selected", "true");
|
|
await expect(page.locator("#play")).toBeDisabled();
|
|
await page.locator("#rom-file").setInputFiles({
|
|
name: "wrong.gbc",
|
|
mimeType: "application/octet-stream",
|
|
buffer: Buffer.from("wrong"),
|
|
});
|
|
await expect(page.locator("#status")).toContainText("2 MiB");
|
|
await page.locator("#help").click();
|
|
await expect(page.locator("#help-dialog")).toBeVisible();
|
|
await page.locator("#close-help").click();
|
|
for (const [width, height] of [
|
|
[1440, 1000],
|
|
[1024, 900],
|
|
[768, 1024],
|
|
[390, 844],
|
|
[320, 740],
|
|
[320, 568],
|
|
[844, 390],
|
|
]) {
|
|
await page.setViewportSize({ width, height });
|
|
expect(
|
|
await page.evaluate(
|
|
() => document.documentElement.scrollWidth <= innerWidth,
|
|
),
|
|
).toBe(true);
|
|
const buttons = await page
|
|
.locator("[data-game-button]")
|
|
.evaluateAll((elements) =>
|
|
elements.map((el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
return { width: rect.width, height: rect.height };
|
|
}),
|
|
);
|
|
for (const button of buttons) {
|
|
expect(button.width).toBeGreaterThanOrEqual(44);
|
|
expect(button.height).toBeGreaterThanOrEqual(44);
|
|
}
|
|
await page.locator("#rom-label").scrollIntoViewIfNeeded();
|
|
await expect(page.locator("#rom-label")).toBeInViewport({ ratio: 0.9 });
|
|
}
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("game menu tabs expose real tools and honest planned states with keyboard navigation", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
const game = page.getByRole("tab", { name: "Game", exact: true });
|
|
await game.focus();
|
|
await page.keyboard.press("ArrowRight");
|
|
await expect(page.locator("#tab-saves")).toBeFocused();
|
|
await expect(page.locator("#panel-saves")).toBeVisible();
|
|
await expect(page.locator("#save")).toBeDisabled();
|
|
await expect(page.locator("#backup")).toBeDisabled();
|
|
await page.keyboard.press("ArrowRight");
|
|
await expect(page.locator("#panel-map")).toBeVisible();
|
|
await expect(page.locator("#map-empty")).toBeVisible();
|
|
await page.keyboard.press("End");
|
|
await expect(page.locator("#tab-friends")).toBeFocused();
|
|
await expect(page.locator("#panel-friends")).toContainText(/planned/i);
|
|
await page.keyboard.press("Home");
|
|
await expect(game).toBeFocused();
|
|
await expect(page.locator("#panel-game")).toBeVisible();
|
|
for (const id of ["pokedex", "link"]) {
|
|
await page.locator(`#tab-${id}`).click();
|
|
await expect(page.locator(`#panel-${id}`)).toContainText(/planned/i);
|
|
}
|
|
});
|
|
|
|
test("actual WASM executes an original cartridge, reads input and round-trips state", async ({
|
|
page,
|
|
}) => {
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
await page.goto("/");
|
|
const result = await page.evaluate(async (bytes) => {
|
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
|
const canvas = document.createElement("canvas");
|
|
document.body.append(canvas);
|
|
const emulator = new BinjgbEmulator((error) => {
|
|
throw error;
|
|
});
|
|
const wait = () => new Promise((resolve) => setTimeout(resolve, 180));
|
|
await emulator.load(new Uint8Array(bytes), canvas);
|
|
emulator.play();
|
|
await wait();
|
|
emulator.pause();
|
|
const before = emulator.exportBattery();
|
|
const state = emulator.captureState();
|
|
const pixels = canvas.getContext("2d")!.getImageData(0, 0, 160, 144).data;
|
|
const colors = new Set<string>();
|
|
for (let i = 0; i < pixels.length; i += 4)
|
|
colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`);
|
|
emulator.setButton("a", true);
|
|
emulator.play();
|
|
await wait();
|
|
emulator.pause();
|
|
const pressed = emulator.exportBattery()[0];
|
|
emulator.restoreState(state);
|
|
const restored = emulator.exportBattery()[0];
|
|
const badSizeRejected = (() => {
|
|
try {
|
|
emulator.restoreState(new Uint8Array([1]));
|
|
return false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
})();
|
|
emulator.dispose();
|
|
await emulator.load(new Uint8Array(bytes), canvas);
|
|
emulator.restoreState(state);
|
|
const reopened = emulator.exportBattery()[0];
|
|
emulator.dispose();
|
|
canvas.remove();
|
|
return {
|
|
unpressed: before[0],
|
|
pressed,
|
|
restored,
|
|
reopened,
|
|
colors: colors.size,
|
|
stateSize: state.length,
|
|
ramSize: before.length,
|
|
badSizeRejected,
|
|
};
|
|
}, Array.from(syntheticRom()));
|
|
expect(result.ramSize).toBe(32768);
|
|
expect(result.stateSize).toBeGreaterThan(32768);
|
|
expect(result.colors).toBeGreaterThan(1);
|
|
expect(result.unpressed & 1).toBe(1);
|
|
expect(result.pressed & 1).toBe(0);
|
|
expect(result.restored).toBe(result.unpressed);
|
|
expect(result.reopened).toBe(result.unpressed);
|
|
expect(result.badSizeRejected).toBe(true);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("full player lifecycle with test-only validator replacement", async ({
|
|
page,
|
|
context,
|
|
browserName,
|
|
}) => {
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
// The shipped validator is never relaxed. Only this browser's module response
|
|
// is replaced to exercise the real UI with our original synthetic cartridge.
|
|
await page.route("**/src/rom/validate.ts", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/javascript",
|
|
body: 'export async function validateRom(file) { return { bytes: new Uint8Array(await file.arrayBuffer()), sha1: "synthetic-browser-test", name: "Original test cartridge" }; }',
|
|
}),
|
|
);
|
|
await page.goto("/reference.html");
|
|
const cartridge = {
|
|
name: "synthetic.gbc",
|
|
mimeType: "application/octet-stream",
|
|
buffer: Buffer.from(syntheticRom()),
|
|
};
|
|
await page.locator("#rom-file").setInputFiles(cartridge);
|
|
await expect(page.locator("#status")).toContainText("verified");
|
|
await page.locator("#play").click();
|
|
await expect(page.locator("#power-label")).toHaveText("PLAYING");
|
|
const action = page.locator('[data-game-button="a"]');
|
|
await action.scrollIntoViewIfNeeded();
|
|
const bounds = (await action.boundingBox())!;
|
|
await page.mouse.move(
|
|
bounds.x + bounds.width / 2,
|
|
bounds.y + bounds.height / 2,
|
|
);
|
|
await page.mouse.down();
|
|
await expect(action).toHaveAttribute("aria-pressed", "true");
|
|
await page.mouse.up();
|
|
await expect(action).toHaveAttribute("aria-pressed", "false");
|
|
await page.locator("#game-screen").focus();
|
|
await page.keyboard.down("KeyX");
|
|
await expect(page.locator('[data-game-button="a"]')).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await page.keyboard.up("KeyX");
|
|
await action.focus();
|
|
await page.keyboard.down("Space");
|
|
await expect(action).toHaveAttribute("aria-pressed", "true");
|
|
await page.keyboard.up("Space");
|
|
await expect(action).toHaveAttribute("aria-pressed", "false");
|
|
// Loaded layouts must keep screen and gameplay controls visible together.
|
|
for (const [width, height] of [
|
|
[390, 844],
|
|
[320, 740],
|
|
[844, 390],
|
|
]) {
|
|
await page.setViewportSize({ width, height });
|
|
await page.evaluate(() => scrollTo(0, 0));
|
|
const geometry = await page.locator(".console").evaluate((el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
return {
|
|
top: rect.top,
|
|
bottom: rect.bottom,
|
|
viewport: innerHeight,
|
|
fits: document.documentElement.scrollWidth <= innerWidth,
|
|
};
|
|
});
|
|
expect(geometry.fits).toBe(true);
|
|
expect(geometry.top).toBeGreaterThanOrEqual(0);
|
|
expect(geometry.bottom).toBeLessThanOrEqual(geometry.viewport);
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
if (browserName === "chromium") {
|
|
const cdp = await context.newCDPSession(page);
|
|
const up = page.locator('[data-game-button="up"]');
|
|
const points = await Promise.all(
|
|
[up, action].map(async (button, id) => {
|
|
const rect = (await button.boundingBox())!;
|
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, id };
|
|
}),
|
|
);
|
|
await cdp.send("Input.dispatchTouchEvent", {
|
|
type: "touchStart",
|
|
touchPoints: points,
|
|
});
|
|
await expect(up).toHaveAttribute("aria-pressed", "true");
|
|
await expect(action).toHaveAttribute("aria-pressed", "true");
|
|
await cdp.send("Input.dispatchTouchEvent", {
|
|
type: "touchCancel",
|
|
touchPoints: [],
|
|
});
|
|
await expect(up).toHaveAttribute("aria-pressed", "false");
|
|
await expect(action).toHaveAttribute("aria-pressed", "false");
|
|
await cdp.detach();
|
|
}
|
|
await page.locator("#play").click();
|
|
await expect(page.locator("#power-label")).toHaveText("PAUSED");
|
|
await expect(page.locator("#save-status")).toContainText("Session saved");
|
|
const downloadEvent = page.waitForEvent("download");
|
|
await page.locator("#backup").click();
|
|
const download = await downloadEvent;
|
|
expect(download.suggestedFilename()).toContain(".crystal-save.json");
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await page.locator("#save-file").setInputFiles((await download.path())!);
|
|
await expect(page.locator("#status")).toContainText("Backup restored");
|
|
await page.reload();
|
|
await page.locator("#rom-file").setInputFiles(cartridge);
|
|
await expect(page.locator("#status")).toContainText("saved session is ready");
|
|
const second = await context.newPage();
|
|
await second.route("**/src/rom/validate.ts", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/javascript",
|
|
body: 'export async function validateRom(file) { return { bytes: new Uint8Array(await file.arrayBuffer()), sha1: "synthetic-browser-test", name: "Test" }; }',
|
|
}),
|
|
);
|
|
await second.goto("/reference.html");
|
|
await second.locator("#rom-file").setInputFiles(cartridge);
|
|
await expect(second.locator("#status")).toContainText("another tab");
|
|
await second.close();
|
|
expect(errors).toEqual([]);
|
|
});
|