377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
test("loads standalone and keeps the core play workflow local", async ({
|
|
page,
|
|
}) => {
|
|
const runtimeErrors: string[] = [];
|
|
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") runtimeErrors.push(message.text());
|
|
});
|
|
|
|
await page.goto("/deep/nested/sudoku/");
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "A first classic" }),
|
|
).toBeVisible();
|
|
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
|
await expect(grid).toBeVisible();
|
|
await expect(grid.getByRole("gridcell")).toHaveCount(81);
|
|
|
|
await grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }).click();
|
|
await page
|
|
.getByRole("group", { name: "Digits" })
|
|
.getByRole("button", { name: "2", exact: true })
|
|
.click();
|
|
await expect(
|
|
grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
|
).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Undo" }).click();
|
|
await expect(
|
|
grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }),
|
|
).toBeVisible();
|
|
|
|
const placedFour = grid.getByRole("gridcell", {
|
|
name: "Row 1, column 1, 4",
|
|
});
|
|
await placedFour.click({ modifiers: ["Control"] });
|
|
expect(await grid.locator(".is-digit-highlighted").count()).toBeGreaterThan(
|
|
1,
|
|
);
|
|
await placedFour.click({ modifiers: ["Control"] });
|
|
await expect(grid.locator(".is-digit-highlighted")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Helpers" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Sudoku helpers" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("tab", { name: "Killer combinations" }),
|
|
).toHaveAttribute("aria-selected", "true");
|
|
await expect(page.locator(".helper-result")).toContainText("4 combinations");
|
|
|
|
await page.getByRole("button", { name: "Import / export" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Import and export" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByText(/Server-only short IDs are never fetched\./u),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByText(/Share links are self-contained\./u),
|
|
).toBeVisible();
|
|
|
|
expect(runtimeErrors).toEqual([]);
|
|
});
|
|
|
|
test("replaces setter cages and generates a rated variant", async ({
|
|
page,
|
|
}) => {
|
|
const runtimeErrors: string[] = [];
|
|
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") runtimeErrors.push(message.text());
|
|
});
|
|
|
|
await page.goto("/deep/nested/sudoku/");
|
|
await page.getByRole("button", { name: "New blank" }).click();
|
|
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
|
await grid.getByRole("gridcell", { name: "Row 1, column 1, empty" }).click();
|
|
await grid
|
|
.getByRole("gridcell", { name: "Row 1, column 2, empty" })
|
|
.click({ modifiers: ["Shift"] });
|
|
|
|
await page.getByRole("spinbutton", { name: "Cage sum" }).fill("3");
|
|
await page.getByRole("button", { name: "Add / replace cage" }).click();
|
|
await expect(page.getByText("3 cage · 2 cells")).toBeVisible();
|
|
await page.getByRole("spinbutton", { name: "Cage sum" }).fill("4");
|
|
await page.getByRole("button", { name: "Add / replace cage" }).click();
|
|
await expect(page.getByText("3 cage · 2 cells")).toHaveCount(0);
|
|
await expect(page.getByText("4 cage · 2 cells")).toBeVisible();
|
|
await page.getByRole("button", { name: "Remove selected cage" }).click();
|
|
await expect(page.getByText("4 cage · 2 cells")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Generate", exact: true }).click();
|
|
await page.getByLabel("Variant").selectOption("thermo");
|
|
await page
|
|
.getByRole("combobox", { name: "Grid", exact: true })
|
|
.selectOption("4");
|
|
await page.getByLabel("Requested profile").selectOption("beginner");
|
|
await page.getByLabel("Seed").fill("browser-thermo");
|
|
await page.getByRole("button", { name: "Generate Thermo" }).click();
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "Thermo · browser-thermo" }),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
await expect(page.getByText("Difficulty assessment")).toBeVisible();
|
|
await expect(page.locator(".difficulty-card .status-pill")).toHaveText(
|
|
"unique",
|
|
);
|
|
await expect(page.locator(".constraint-thermo")).not.toHaveCount(0);
|
|
|
|
expect(runtimeErrors).toEqual([]);
|
|
});
|
|
|
|
test("uses distinct numbered sum badges and directional inequalities", async ({
|
|
page,
|
|
}) => {
|
|
const runtimeErrors: string[] = [];
|
|
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") runtimeErrors.push(message.text());
|
|
});
|
|
|
|
await page.goto("/deep/nested/sudoku/");
|
|
const examples = page.getByLabel("Open built-in puzzle");
|
|
|
|
await examples.selectOption("xv");
|
|
await expect(
|
|
page.getByRole("heading", { name: "Five or ten" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".constraint-xv--5 text").first()).toHaveText("5");
|
|
await expect(page.locator(".constraint-xv--10 text").first()).toHaveText(
|
|
"10",
|
|
);
|
|
|
|
await examples.selectOption("inequality");
|
|
await expect(
|
|
page.getByRole("heading", { name: "Lesser and greater" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".constraint-inequality")).not.toHaveCount(0);
|
|
await expect(
|
|
page.locator(".constraint-inequality-tip").first(),
|
|
).toBeVisible();
|
|
|
|
await examples.selectOption("truth-and-lies");
|
|
await expect(
|
|
page.getByRole("heading", { name: "Truth and lies" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".constraint-outside")).toHaveCount(4);
|
|
await expect(page.locator(".constraint-quadruple")).toHaveCount(2);
|
|
await expect(page.locator(".constraint-maximum")).toHaveCount(2);
|
|
await expect(page.locator(".constraint-outside.is-negated")).toHaveCount(2);
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
const outsideBox = await page
|
|
.locator(".constraint-outside")
|
|
.first()
|
|
.boundingBox();
|
|
const maximumBox = await page
|
|
.locator(".constraint-maximum")
|
|
.first()
|
|
.boundingBox();
|
|
expect(outsideBox?.width ?? 0).toBeGreaterThan(18);
|
|
expect(outsideBox?.height ?? 0).toBeGreaterThan(12);
|
|
expect(maximumBox?.width ?? 0).toBeGreaterThan(12);
|
|
expect(maximumBox?.height ?? 0).toBeGreaterThan(12);
|
|
await expect(
|
|
page.locator(".constraint-thermo .constraint-false-mark").first(),
|
|
).toBeVisible();
|
|
|
|
expect(runtimeErrors).toEqual([]);
|
|
});
|
|
|
|
test("keeps navigation, scratch work, branches and analysis tools local", async ({
|
|
page,
|
|
}) => {
|
|
const runtimeErrors: string[] = [];
|
|
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") runtimeErrors.push(message.text());
|
|
});
|
|
|
|
await page.goto("/deep/nested/sudoku/");
|
|
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
|
const first = grid.getByRole("gridcell", {
|
|
name: "Row 1, column 1, 4",
|
|
});
|
|
await first.focus();
|
|
await first.press("End");
|
|
await expect(
|
|
grid.getByRole("gridcell", { name: /Row 1, column 9,/u }),
|
|
).toBeFocused();
|
|
await page.keyboard.press("PageDown");
|
|
await expect(
|
|
grid.getByRole("gridcell", { name: /Row 9, column 9,/u }),
|
|
).toBeFocused();
|
|
await page.keyboard.press("Control+Home");
|
|
await expect(first).toBeFocused();
|
|
|
|
await page.getByLabel("Show aid-mémoire scratch cells").check();
|
|
const scratch = page.getByRole("grid", {
|
|
name: "9 aid-mémoire scratch cells",
|
|
});
|
|
const scratchCell = scratch.getByRole("gridcell").first();
|
|
await scratchCell.click();
|
|
await page
|
|
.getByRole("group", { name: "Digits" })
|
|
.getByRole("button", { name: "2", exact: true })
|
|
.click();
|
|
await expect(scratch.getByRole("gridcell").first()).toHaveAccessibleName(
|
|
/value 2/u,
|
|
);
|
|
|
|
await page.getByRole("button", { name: "History & branches" }).click();
|
|
await page.getByLabel("Savepoint name").fill("Before test branch");
|
|
await page.getByRole("button", { name: "Save current grid" }).click();
|
|
await expect(
|
|
page.getByText("Before test branch", { exact: true }),
|
|
).toBeVisible();
|
|
await page.getByLabel("Hypothesis name").fill("Try a two");
|
|
await page.getByRole("button", { name: "Start hypothesis" }).click();
|
|
|
|
const empty = grid.getByRole("gridcell", {
|
|
name: "Row 1, column 3, empty",
|
|
});
|
|
await empty.click();
|
|
await page
|
|
.getByRole("group", { name: "Digits" })
|
|
.getByRole("button", { name: "2", exact: true })
|
|
.click();
|
|
await expect(
|
|
grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
|
).toBeVisible();
|
|
await page.getByRole("button", { name: "Hypothesis: Try a two" }).click();
|
|
await page.getByRole("button", { name: "Discard and return" }).click();
|
|
await expect(empty).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Helpers" }).click();
|
|
await page.getByRole("tab", { name: "Sum Lab" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Generalized Sum Lab" }),
|
|
).toBeVisible();
|
|
await page.getByRole("tab", { name: "Candidate links" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Links and houses" }),
|
|
).toBeVisible();
|
|
|
|
expect(runtimeErrors).toEqual([]);
|
|
});
|
|
|
|
test("migrates the local database and recovers autosaved history", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/deep/nested/sudoku/favicon.svg");
|
|
await page.evaluate(async () => {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const request = indexedDB.deleteDatabase("sudoku-tools");
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
const request = indexedDB.open("sudoku-tools", 1);
|
|
request.onupgradeneeded = () => {
|
|
const store = request.result.createObjectStore("projects", {
|
|
keyPath: "id",
|
|
});
|
|
store.createIndex("updatedAt", "updatedAt");
|
|
};
|
|
request.onsuccess = () => {
|
|
request.result.close();
|
|
resolve();
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
});
|
|
|
|
await page.goto("/deep/nested/sudoku/");
|
|
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
|
await grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }).click();
|
|
await page
|
|
.getByRole("group", { name: "Digits" })
|
|
.getByRole("button", { name: "2", exact: true })
|
|
.click();
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
await page.evaluate(
|
|
async () =>
|
|
await new Promise<number | undefined>((resolve, reject) => {
|
|
const open = indexedDB.open("sudoku-tools");
|
|
open.onerror = () => reject(open.error);
|
|
open.onsuccess = () => {
|
|
const database = open.result;
|
|
const request = database
|
|
.transaction("autosaves", "readonly")
|
|
.objectStore("autosaves")
|
|
.get("current");
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
const value = request.result as
|
|
| { record?: { progress?: { values?: number[] } } }
|
|
| undefined;
|
|
database.close();
|
|
resolve(value?.record?.progress?.values?.[2]);
|
|
};
|
|
};
|
|
}),
|
|
),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toBe(2);
|
|
|
|
await page.reload();
|
|
await expect(
|
|
page.getByRole("heading", { name: "A first classic" }),
|
|
).toBeVisible();
|
|
await expect(page.getByText("Recover unsaved local work?")).toBeVisible();
|
|
await page.getByRole("button", { name: "Restore" }).click();
|
|
await expect(
|
|
page
|
|
.getByRole("grid", { name: "9 by 9 Sudoku grid" })
|
|
.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
|
).toBeVisible();
|
|
await page.getByRole("button", { name: "History & branches" }).click();
|
|
await expect(
|
|
page
|
|
.getByRole("list", { name: "Solve history" })
|
|
.getByRole("button", { name: /Set r1c3 to 2/u }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("installs a subpath-safe application shell that reopens offline", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
await page.goto("/deep/nested/sudoku/");
|
|
await page.waitForFunction(async () => {
|
|
await navigator.serviceWorker.ready;
|
|
return navigator.serviceWorker.controller !== null;
|
|
});
|
|
const cachedUrls = await page.evaluate(async () => {
|
|
const names = await caches.keys();
|
|
return (
|
|
await Promise.all(
|
|
names
|
|
.filter((name) => name.startsWith("sudoku-tools-shell-"))
|
|
.map(async (name) =>
|
|
(await caches.open(name))
|
|
.keys()
|
|
.then((requests) => requests.map((request) => request.url)),
|
|
),
|
|
)
|
|
).flat();
|
|
});
|
|
expect(
|
|
cachedUrls.some((url) => /\/solver\.worker-[^/]+\.js$/u.test(url)),
|
|
).toBe(true);
|
|
await page.reload();
|
|
await expect(
|
|
page.getByRole("heading", { name: "A first classic" }),
|
|
).toBeVisible();
|
|
|
|
await context.setOffline(true);
|
|
try {
|
|
await page.reload();
|
|
await expect(
|
|
page.getByRole("heading", { name: "A first classic" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("grid", { name: "9 by 9 Sudoku grid" }),
|
|
).toBeVisible();
|
|
} finally {
|
|
await context.setOffline(false);
|
|
}
|
|
});
|