import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { Workbench } from "../../src/components/Workbench";
class WorkerStub {
addEventListener() {}
postMessage() {}
terminate() {}
}
beforeAll(() => {
vi.stubGlobal("Worker", WorkerStub);
});
describe("Sudoku workbench", () => {
it("opens with the local classic example rendered as an accessible grid", () => {
render();
expect(
screen.getByRole("heading", { name: "A first classic" }),
).toBeInTheDocument();
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
expect(within(grid).getAllByRole("gridcell")).toHaveLength(81);
expect(
within(grid).getByRole("gridcell", {
name: "Row 1, column 1, 4",
}),
).toHaveClass("is-given");
expect(
within(grid).getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
).toBeInTheDocument();
});
it("enters a digit in an empty cell and restores it with undo", async () => {
const user = userEvent.setup();
render();
const emptyCell = screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
});
fireEvent.pointerDown(emptyCell, { buttons: 1 });
await user.click(
within(screen.getByRole("group", { name: "Digits" })).getByRole(
"button",
{ name: "2" },
),
);
expect(
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeInTheDocument();
const undo = screen.getByRole("button", { name: "Undo" });
expect(undo).toBeEnabled();
await user.click(undo);
expect(
screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
).toBeInTheDocument();
expect(undo).toBeDisabled();
});
it("supports non-wrapping WAI-ARIA grid navigation", () => {
render();
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
const cell = (row: number, column: number) =>
within(grid).getByRole("gridcell", {
name: new RegExp(`^Row ${String(row)}, column ${String(column)},`, "u"),
});
cell(1, 1).focus();
fireEvent.keyDown(cell(1, 1), { key: "ArrowLeft" });
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(cell(1, 1), { key: "End" });
expect(cell(1, 9)).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(cell(1, 9), { key: "ArrowRight" });
expect(cell(1, 9)).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(cell(1, 9), { key: "ArrowDown" });
expect(cell(2, 9)).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(cell(2, 9), { key: "Home" });
expect(cell(2, 1)).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(cell(2, 1), { key: "PageDown" });
expect(cell(9, 1)).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(cell(9, 1), { key: "Home", ctrlKey: true });
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
});
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
const user = userEvent.setup();
render();
const completion = screen.getByRole("group", {
name: "Highlight matching digits",
});
expect(within(completion).getAllByRole("button")).toHaveLength(9);
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
const four = within(grid).getByRole("gridcell", {
name: "Row 1, column 1, 4",
});
fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true });
const highlighted = grid.querySelectorAll(".is-digit-highlighted");
expect(highlighted.length).toBeGreaterThan(1);
expect(four).toHaveAttribute("aria-selected", "true");
fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true });
expect(grid.querySelectorAll(".is-digit-highlighted")).toHaveLength(0);
await user.click(screen.getByLabelText("Show digit completion bar"));
expect(
screen.queryByRole("group", { name: "Highlight matching digits" }),
).not.toBeInTheDocument();
});
it("replaces and removes setter cages through the selected cells", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button", { name: "New blank" }));
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
fireEvent.pointerDown(
within(grid).getByRole("gridcell", {
name: "Row 1, column 2, empty",
}),
{ buttons: 1, shiftKey: true },
);
const cageSum = screen.getByRole("spinbutton", { name: "Cage sum" });
await user.clear(cageSum);
await user.type(cageSum, "3");
await user.click(
screen.getByRole("button", { name: "Add / replace cage" }),
);
expect(screen.getByText("3 cage · 2 cells")).toBeInTheDocument();
await user.clear(cageSum);
await user.type(cageSum, "4");
await user.click(
screen.getByRole("button", { name: "Add / replace cage" }),
);
expect(screen.queryByText("3 cage · 2 cells")).not.toBeInTheDocument();
expect(screen.getByText("4 cage · 2 cells")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Remove selected cage" }),
);
expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Cage digits may repeat"));
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.clear(cageSum);
await user.type(cageSum, "2");
await user.click(
screen.getByRole("button", { name: "Add / replace cage" }),
);
expect(
screen.getByText("false · 2 cage · 2 cells · repeats allowed"),
).toBeInTheDocument();
await user.click(screen.getByLabelText("Cage digits may repeat"));
await user.clear(cageSum);
await user.type(cageSum, "3");
await user.click(
screen.getByRole("button", { name: "Add / replace cage" }),
);
expect(
screen.queryByText("false · 2 cage · 2 cells · repeats allowed"),
).not.toBeInTheDocument();
expect(screen.getByText("false · 3 cage · 2 cells")).toBeInTheDocument();
});
it("opens the expanded built-in variant examples", async () => {
const user = userEvent.setup();
const { container } = render();
await user.selectOptions(
screen.getByLabelText("Open built-in puzzle"),
"thermo",
);
expect(
screen.getByRole("heading", { name: "Warm fronts" }),
).toBeInTheDocument();
expect(
screen.getByRole("grid", { name: "6 by 6 Sudoku grid" }),
).toBeInTheDocument();
expect(
container.querySelectorAll(".constraint-thermo").length,
).toBeGreaterThan(0);
await user.selectOptions(
screen.getByLabelText("Open built-in puzzle"),
"xv",
);
expect(
screen.getByRole("heading", { name: "Five or ten" }),
).toBeInTheDocument();
expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent(
"5",
);
expect(
container.querySelector(".constraint-xv--10 text"),
).toHaveTextContent("10");
await user.selectOptions(
screen.getByLabelText("Open built-in puzzle"),
"truth-and-lies",
);
expect(
screen.getByRole("heading", { name: "Truth and lies" }),
).toBeInTheDocument();
expect(container.querySelectorAll(".constraint-outside")).toHaveLength(4);
expect(container.querySelectorAll(".constraint-quadruple")).toHaveLength(2);
expect(container.querySelectorAll(".constraint-maximum")).toHaveLength(2);
});
it("sets and batch-toggles false local and outside clues", async () => {
const user = userEvent.setup();
const { container } = render();
await user.click(screen.getByRole("button", { name: "New blank" }));
expect(
screen.getByRole("button", { name: "Add quadruple" }),
).toBeDisabled();
await user.clear(screen.getByLabelText("Quadruple digits"));
await user.type(screen.getByLabelText("Quadruple digits"), "1");
expect(
screen.getByRole("button", { name: "Add quadruple" }),
).toBeDisabled();
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(screen.getByRole("button", { name: "Maximum cell" }));
await user.click(
screen.getByRole("button", { name: "Add / replace outside clue" }),
);
expect(screen.getByText("false · maximum · r1c1")).toBeInTheDocument();
expect(screen.getByText("false · Σ3 · top 1")).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Require true for false · maximum · r1c1",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Remove false · Σ3 · top 1",
}),
).toBeInTheDocument();
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
await user.clear(screen.getByLabelText("Clue"));
await user.type(screen.getByLabelText("Clue"), "6562");
expect(
screen.getByRole("button", { name: "Add / replace outside clue" }),
).toBeDisabled();
await user.click(
screen.getByRole("button", { name: "Make all clues true" }),
);
expect(screen.getByText("maximum · r1c1")).toBeInTheDocument();
expect(screen.getByText("Σ3 · top 1")).toBeInTheDocument();
expect(container.querySelectorAll(".is-negated")).toHaveLength(0);
});
it("switches between setting, solving, playing and helper workspaces", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button", { name: "Set" }));
expect(
screen.getByRole("heading", { name: "Set a puzzle" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Enter givens" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Generate" }));
expect(
screen.getByRole("heading", { name: "Sudoku generator" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Rate current puzzle" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Solve" }));
expect(
screen.getByRole("heading", { name: "Solve and verify" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Helpers" }));
expect(
screen.getByRole("heading", { name: "Sudoku helpers" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Killer combinations" }),
).toHaveAttribute("aria-selected", "true");
expect(document.querySelector(".helper-result")).toHaveTextContent(
"combinations",
);
await user.click(screen.getByRole("tab", { name: "45-rule residual" }));
expect(
screen.getByLabelText("Accounted values or cage sums"),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Play" }));
expect(
screen.getByRole("heading", { name: "Enter your solve" }),
).toBeInTheDocument();
});
it("projects candidate filters and links onto the board only while inspecting", async () => {
const user = userEvent.setup();
const { container } = render();
await user.click(screen.getByRole("button", { name: "Helpers" }));
await user.click(screen.getByRole("tab", { name: "Candidate links" }));
expect(
screen.getByRole("heading", { name: "Links and houses" }),
).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
expect(
container.querySelector(".candidate-link-layer"),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Filter candidate 1" }),
);
expect(
container.querySelectorAll(".sudoku-cell.is-candidate-highlighted")
.length,
).toBeGreaterThan(0);
await user.click(screen.getByRole("button", { name: "Play" }));
expect(
container.querySelector(".candidate-link-layer"),
).not.toBeInTheDocument();
expect(
container.querySelectorAll(".sudoku-cell.is-candidate-highlighted"),
).toHaveLength(0);
});
it("makes the import boundary and self-contained local sharing explicit", async () => {
const user = userEvent.setup();
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
render();
await user.click(screen.getByRole("button", { name: "Import / export" }));
expect(
screen.getByRole("heading", { name: "Import and export" }),
).toBeInTheDocument();
expect(
screen.getByText(/Server-only short IDs are never fetched\./u),
).toBeInTheDocument();
expect(
screen.getByText(/Share links are self-contained\./u),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Copy local share URL" }),
);
expect(
await screen.findByText("Local share URL copied."),
).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
});