feat: add gameplay assists and variant generator
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { VariantConstraint } from "../../src/domain";
|
||||
import {
|
||||
removeKillerCagesAtCells,
|
||||
replaceOverlappingKillerCages,
|
||||
selectionTouchesKillerCage,
|
||||
} from "../../src/components/constraintEditing";
|
||||
|
||||
const constraints: VariantConstraint[] = [
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{ type: "killer-cage", cells: [2, 3], sum: 7 },
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
];
|
||||
|
||||
describe("setter cage editing", () => {
|
||||
it("replaces every overlapping cage while preserving other rules", () => {
|
||||
expect(
|
||||
replaceOverlappingKillerCages(constraints, {
|
||||
type: "killer-cage",
|
||||
cells: [1, 2],
|
||||
sum: 5,
|
||||
}),
|
||||
).toEqual([
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
{ type: "killer-cage", cells: [1, 2], sum: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes cages by any selected member cell", () => {
|
||||
expect(removeKillerCagesAtCells(constraints, [1])).toEqual([
|
||||
{ type: "killer-cage", cells: [2, 3], sum: 7 },
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
]);
|
||||
expect(selectionTouchesKillerCage(constraints, [1])).toBe(true);
|
||||
expect(selectionTouchesKillerCage(constraints, [4])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DigitCompletionBar } from "../../src/components/DigitCompletionBar";
|
||||
import { digitCompletions } from "../../src/state/gameplayHelpers";
|
||||
|
||||
describe("digit completion bar", () => {
|
||||
it("renders complete digits muted and excess digits as overdone", () => {
|
||||
const values = new Array<number>(81).fill(0);
|
||||
values.fill(1, 0, 9);
|
||||
values.fill(2, 9, 19);
|
||||
|
||||
render(
|
||||
<DigitCompletionBar
|
||||
size={9}
|
||||
completions={digitCompletions(9, values)}
|
||||
highlightedDigit={null}
|
||||
highlightingEnabled
|
||||
onHighlight={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 1: complete/u }),
|
||||
).toHaveClass("is-done");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 2: overdone by 1/u }),
|
||||
).toHaveClass("is-overdone");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 3: 9 remaining/u }),
|
||||
).toHaveClass("is-undone");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
|
||||
|
||||
describe("Sudoku generator workspace", () => {
|
||||
it("offers only reliable sizes and submits an explicit local recipe", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGenerate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
onGenerate={onGenerate}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "anti-king");
|
||||
const grid = screen.getByLabelText("Grid");
|
||||
expect(
|
||||
within(grid)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent),
|
||||
).toEqual(["6 × 6", "9 × 9"]);
|
||||
expect(
|
||||
screen.queryByLabelText("Requested markings"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "thermo");
|
||||
expect(screen.getByLabelText("Requested markings")).toBeInTheDocument();
|
||||
await user.selectOptions(screen.getByLabelText("Grid"), "4");
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Requested profile"),
|
||||
"easy",
|
||||
);
|
||||
await user.clear(screen.getByLabelText("Seed"));
|
||||
await user.type(screen.getByLabelText("Seed"), "repeatable-demo");
|
||||
await user.click(screen.getByRole("button", { name: "Generate Thermo" }));
|
||||
|
||||
expect(onGenerate).toHaveBeenCalledWith({
|
||||
variant: "thermo",
|
||||
size: 4,
|
||||
targetDifficulty: "easy",
|
||||
symmetry: "rotational",
|
||||
constraintCount: 8,
|
||||
seed: "repeatable-demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes independent rating for the current puzzle", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace busy={false} onGenerate={vi.fn()} onRate={onRate} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Rate current puzzle" }),
|
||||
);
|
||||
expect(onRate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizePuzzle } from "../../src/domain";
|
||||
import { SudokuBoard } from "../../src/components/SudokuBoard";
|
||||
|
||||
describe("Sudoku board constraint visuals", () => {
|
||||
it("distinguishes XV sums from directional inequalities", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
constraints: [
|
||||
{ type: "xv", a: 0, b: 1, total: 5 },
|
||||
{ type: "xv", a: 4, b: 5, total: 10 },
|
||||
{ type: "inequality", lesser: 8, greater: 9 },
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set([0])}
|
||||
activeCell={0}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent(
|
||||
"5",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(".constraint-xv--10 text"),
|
||||
).toHaveTextContent("10");
|
||||
expect(
|
||||
container.querySelector(".constraint-inequality path"),
|
||||
).toHaveAttribute("d", "M0.12 -0.17L-0.12 0L0.12 0.17");
|
||||
expect(
|
||||
container.querySelector(".constraint-inequality-tip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,102 @@ describe("Sudoku workbench", () => {
|
||||
expect(undo).toBeDisabled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("switches between setting, solving, playing and helper workspaces", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
@@ -75,6 +171,14 @@ describe("Sudoku workbench", () => {
|
||||
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" }),
|
||||
|
||||
Reference in New Issue
Block a user