565 lines
19 KiB
TypeScript
565 lines
19 KiB
TypeScript
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";
|
|
import { encodePuzzleHash, fromDomainPuzzle } from "../../src/formats";
|
|
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
|
|
|
|
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(<Workbench />);
|
|
|
|
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(<Workbench />);
|
|
|
|
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("fills a tracked candidate grid as one undoable maintenance action", async () => {
|
|
const user = userEvent.setup();
|
|
render(<Workbench />);
|
|
|
|
expect(
|
|
screen.getByText("Candidate tracking is currently inactive."),
|
|
).toBeInTheDocument();
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Fill legal candidates" }),
|
|
);
|
|
|
|
expect(
|
|
screen.getByText("The guided candidate grid is active."),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("gridcell", {
|
|
name: "Row 1, column 3, empty",
|
|
}),
|
|
).toHaveAccessibleDescription(expect.stringContaining("centre notes"));
|
|
|
|
await user.click(screen.getByRole("button", { name: "Undo" }));
|
|
expect(
|
|
screen.getByText("Candidate tracking is currently inactive."),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("gridcell", {
|
|
name: "Row 1, column 3, empty",
|
|
}),
|
|
).not.toHaveAccessibleDescription(expect.stringContaining("centre notes"));
|
|
});
|
|
|
|
it("supports non-wrapping WAI-ARIA grid navigation", () => {
|
|
render(<Workbench />);
|
|
|
|
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("offers tap-by-tap multi-selection without drag selection", async () => {
|
|
const user = userEvent.setup();
|
|
render(<Workbench />);
|
|
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
|
const cell = (column: number) =>
|
|
within(grid).getByRole("gridcell", {
|
|
name: new RegExp(`^Row 1, column ${String(column)},`, "u"),
|
|
});
|
|
|
|
fireEvent.pointerDown(cell(3), { buttons: 1 });
|
|
const toggleMode = screen.getByRole("button", { name: "Tap multi-select" });
|
|
expect(toggleMode).toHaveAttribute("aria-pressed", "false");
|
|
await user.click(toggleMode);
|
|
expect(toggleMode).toHaveAttribute("aria-pressed", "true");
|
|
|
|
fireEvent.pointerDown(cell(4), { buttons: 1 });
|
|
expect(cell(3)).toHaveAttribute("aria-selected", "true");
|
|
expect(cell(4)).toHaveAttribute("aria-selected", "true");
|
|
fireEvent.pointerEnter(cell(5), { buttons: 1 });
|
|
expect(cell(5)).toHaveAttribute("aria-selected", "false");
|
|
|
|
fireEvent.pointerDown(cell(3), { buttons: 1 });
|
|
expect(cell(3)).toHaveAttribute("aria-selected", "false");
|
|
expect(cell(4)).toHaveAttribute("aria-selected", "true");
|
|
});
|
|
|
|
it("renders one sticky entry pad in a narrow viewport", () => {
|
|
const original = Object.getOwnPropertyDescriptor(window, "matchMedia");
|
|
Object.defineProperty(window, "matchMedia", {
|
|
configurable: true,
|
|
value: (query: string) => ({
|
|
matches: query === "(max-width: 48rem)",
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}),
|
|
});
|
|
|
|
const { container, unmount } = render(<Workbench />);
|
|
expect(container.querySelectorAll(".mobile-number-pad")).toHaveLength(1);
|
|
expect(screen.getAllByRole("group", { name: "Entry mode" })).toHaveLength(
|
|
1,
|
|
);
|
|
unmount();
|
|
if (original === undefined) Reflect.deleteProperty(window, "matchMedia");
|
|
else Object.defineProperty(window, "matchMedia", original);
|
|
});
|
|
|
|
it("keeps fogged selections out of toolbar and helper output", async () => {
|
|
const user = userEvent.setup();
|
|
const previousHash = window.location.hash;
|
|
window.location.hash = encodePuzzleHash(
|
|
fromDomainPuzzle({
|
|
version: 1,
|
|
size: 4,
|
|
givens: new Array<number>(16).fill(0),
|
|
solution: [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1],
|
|
constraints: [{ type: "fog", lights: [15], revealRadius: 0 }],
|
|
}),
|
|
);
|
|
const { unmount } = render(<Workbench />);
|
|
|
|
expect(
|
|
screen.getByRole("gridcell", {
|
|
name: "Row 1, column 1, obscured by fog",
|
|
}),
|
|
).toBeDisabled();
|
|
expect(screen.getByText("r4c4")).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Helpers" }));
|
|
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
|
await user.clear(screen.getByLabelText("Target sum"));
|
|
await user.type(screen.getByLabelText("Target sum"), "4");
|
|
await user.click(
|
|
screen.getByRole("checkbox", {
|
|
name: "Use 1 board cell and candidates",
|
|
}),
|
|
);
|
|
expect(screen.getByText(/^r4c4:/u)).toBeInTheDocument();
|
|
|
|
unmount();
|
|
window.location.hash = previousHash;
|
|
});
|
|
|
|
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
|
|
const user = userEvent.setup();
|
|
render(<Workbench />);
|
|
|
|
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(<Workbench />);
|
|
|
|
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(<Workbench />);
|
|
|
|
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(<Workbench />);
|
|
|
|
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("Sum"));
|
|
await user.type(screen.getByLabelText("Sum"), "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(<Workbench />);
|
|
|
|
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(<Workbench />);
|
|
|
|
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(<Workbench />);
|
|
|
|
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();
|
|
});
|
|
|
|
it("keeps imported visuals, provenance and metadata after a board edit", async () => {
|
|
const user = userEvent.setup();
|
|
let downloaded: Blob | undefined;
|
|
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
|
downloaded = value as Blob;
|
|
return "blob:preserved-workbench-export";
|
|
});
|
|
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
|
render(<Workbench />);
|
|
|
|
const imported = {
|
|
schema: SUDOKU_DOCUMENT_SCHEMA,
|
|
version: 1,
|
|
size: 4,
|
|
givens: Array<number>(16).fill(0),
|
|
constraints: [],
|
|
title: "Preservation regression",
|
|
visuals: [
|
|
{
|
|
type: "text",
|
|
layer: "overlay",
|
|
position: { kind: "cell", cell: 0 },
|
|
text: "source label",
|
|
style: { fill: "#123456" },
|
|
},
|
|
],
|
|
source: { format: "sudokupad", id: "original-source" },
|
|
metadata: { edition: "kept" },
|
|
};
|
|
await user.click(screen.getByRole("button", { name: "Import / export" }));
|
|
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
|
target: { value: JSON.stringify(imported) },
|
|
});
|
|
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
|
expect(document.querySelector(".source-visual--text")).toHaveTextContent(
|
|
"source label",
|
|
);
|
|
|
|
fireEvent.pointerDown(
|
|
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
|
|
{ buttons: 1 },
|
|
);
|
|
await user.click(
|
|
within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
|
"button",
|
|
{ name: "1" },
|
|
),
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Import / export" }));
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Download project JSON" }),
|
|
);
|
|
|
|
const exported = JSON.parse((await downloaded?.text()) ?? "{}") as {
|
|
values?: number[];
|
|
visuals?: unknown[];
|
|
source?: unknown;
|
|
metadata?: unknown;
|
|
};
|
|
expect(exported.values?.[0]).toBe(1);
|
|
expect(exported.visuals).toEqual(imported.visuals);
|
|
expect(exported.source).toEqual(imported.source);
|
|
expect(exported.metadata).toEqual(imported.metadata);
|
|
});
|
|
});
|