feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BoardViewport } from "../../src/components/BoardViewport";
|
||||
import { BOARD_SCALE_STORAGE_KEY } from "../../src/state/uiPreferences";
|
||||
|
||||
describe("BoardViewport", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("zooms, fits and restores the persisted scale", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(
|
||||
<BoardViewport>
|
||||
<div>Board</div>
|
||||
</BoardViewport>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
|
||||
await user.click(screen.getByRole("button", { name: "Zoom board in" }));
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
|
||||
expect(localStorage.getItem(BOARD_SCALE_STORAGE_KEY)).toBe("1.25");
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<BoardViewport>
|
||||
<div>Board</div>
|
||||
</BoardViewport>,
|
||||
);
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
|
||||
await user.click(screen.getByRole("button", { name: "Fit board" }));
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
|
||||
});
|
||||
|
||||
it("offers explicit pan mode and board-scoped zoom shortcuts", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCellKeyDown = vi.fn();
|
||||
render(
|
||||
<BoardViewport>
|
||||
<button type="button" onKeyDown={onCellKeyDown}>
|
||||
Cell
|
||||
</button>
|
||||
</BoardViewport>,
|
||||
);
|
||||
|
||||
const pan = screen.getByRole("button", { name: "Pan board" });
|
||||
expect(pan).toHaveAttribute("aria-pressed", "false");
|
||||
await user.click(pan);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Stop panning" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(
|
||||
screen.getByRole("status", {
|
||||
name: "",
|
||||
}),
|
||||
).toHaveTextContent(
|
||||
"Pan mode: drag the board to move it. Cell taps are paused.",
|
||||
);
|
||||
expect(screen.getByLabelText(/pan mode is on/iu)).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Cell" }), {
|
||||
key: "+",
|
||||
ctrlKey: true,
|
||||
});
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Cell" }), {
|
||||
key: "0",
|
||||
ctrlKey: true,
|
||||
});
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
|
||||
expect(onCellKeyDown).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConstraintEditor } from "../../src/components/ConstraintEditor";
|
||||
import { createEmptyPuzzle, type PuzzleDefinition } from "../../src/domain";
|
||||
|
||||
function EditorHarness({
|
||||
selection,
|
||||
initial = createEmptyPuzzle(4),
|
||||
}: {
|
||||
readonly selection: readonly number[];
|
||||
readonly initial?: PuzzleDefinition;
|
||||
}) {
|
||||
const [puzzle, setPuzzle] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<ConstraintEditor
|
||||
puzzle={puzzle}
|
||||
selection={selection}
|
||||
onChange={setPuzzle}
|
||||
onNewGrid={vi.fn()}
|
||||
onCheck={vi.fn()}
|
||||
onGenerate={vi.fn()}
|
||||
busy={false}
|
||||
/>
|
||||
<output data-testid="puzzle-state">{JSON.stringify(puzzle)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function currentPuzzle(): PuzzleDefinition {
|
||||
return JSON.parse(
|
||||
screen.getByTestId("puzzle-state").textContent ?? "{}",
|
||||
) as PuzzleDefinition;
|
||||
}
|
||||
|
||||
describe("ConstraintEditor expansion controls", () => {
|
||||
it("validates, replaces, polarizes and removes selected-cell markers", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EditorHarness selection={[5]} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Minimum cell" }));
|
||||
expect(screen.getByText("false · minimum · r2c2")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Odd cell (circle)" }));
|
||||
expect(screen.getByText("false · odd circle · r2c2")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Odd cell (circle)" }));
|
||||
expect(screen.getByText("odd circle · r2c2")).toBeInTheDocument();
|
||||
expect(
|
||||
currentPuzzle().constraints?.filter(({ type }) => type === "odd"),
|
||||
).toHaveLength(1);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Even cell (square)" }),
|
||||
);
|
||||
expect(screen.getByText("even square · r2c2")).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove selected cell markers" }),
|
||||
);
|
||||
expect(currentPuzzle().constraints).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires exactly one selected cell for cell-marker creation", () => {
|
||||
render(<EditorHarness selection={[0, 1]} />);
|
||||
|
||||
for (const name of [
|
||||
"Maximum cell",
|
||||
"Minimum cell",
|
||||
"Odd cell (circle)",
|
||||
"Even cell (square)",
|
||||
]) {
|
||||
expect(screen.getByRole("button", { name })).toBeDisabled();
|
||||
}
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove selected cell markers" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("toggles disjoint groups and validates/replaces new outside clues", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EditorHarness selection={[]} />);
|
||||
|
||||
const disjoint = screen.getByRole("button", { name: "Disjoint groups" });
|
||||
expect(disjoint).toHaveAttribute("aria-pressed", "false");
|
||||
await user.click(disjoint);
|
||||
expect(disjoint).toHaveAttribute("aria-pressed", "true");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"disjoint groups · matching box positions do not repeat",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
await user.click(disjoint);
|
||||
expect(disjoint).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Type"), "little-killer");
|
||||
await user.selectOptions(screen.getByLabelText("Direction"), "down-left");
|
||||
const add = screen.getByRole("button", {
|
||||
name: "Add / replace outside clue",
|
||||
});
|
||||
// Top line 1 travelling down-left leaves the grid after one cell.
|
||||
expect(add).toBeDisabled();
|
||||
|
||||
const line = screen.getByRole("spinbutton", { name: "Row / column" });
|
||||
await user.clear(line);
|
||||
await user.type(line, "2");
|
||||
expect(add).toBeEnabled();
|
||||
await user.click(add);
|
||||
expect(
|
||||
screen.getByText("little killer 3 · top 2 · down left"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const sum = screen.getByRole("spinbutton", { name: "Sum" });
|
||||
await user.clear(sum);
|
||||
await user.type(sum, "4");
|
||||
await user.click(add);
|
||||
expect(
|
||||
screen.queryByText("little killer 3 · top 2 · down left"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("little killer 4 · top 2 · down left"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
currentPuzzle().constraints?.filter(
|
||||
({ type }) => type === "little-killer",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Type"), "sandwich");
|
||||
await user.clear(sum);
|
||||
await user.type(sum, "1");
|
||||
expect(add).toBeDisabled();
|
||||
await user.clear(sum);
|
||||
await user.type(sum, "3");
|
||||
expect(add).toBeEnabled();
|
||||
await user.click(add);
|
||||
expect(screen.getByText("sandwich 3 · top 2")).toBeInTheDocument();
|
||||
|
||||
const constraintList = screen.getByRole("list");
|
||||
await user.click(
|
||||
within(constraintList).getByRole("button", {
|
||||
name: "Remove little killer 4 · top 2 · down left",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
currentPuzzle().constraints?.some(({ type }) => type === "little-killer"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("creates ordered Pack 2 lines with bounded whisper settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EditorHarness selection={[0, 1, 2]} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Between line" }));
|
||||
expect(
|
||||
screen.getByText("false · between line · 3 ordered cells"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
|
||||
const difference = screen.getByRole("spinbutton", {
|
||||
name: "Whisper minimum difference",
|
||||
});
|
||||
await user.clear(difference);
|
||||
await user.type(difference, "0");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "German whisper" }),
|
||||
).toBeDisabled();
|
||||
await user.clear(difference);
|
||||
await user.type(difference, "3");
|
||||
await user.click(screen.getByRole("button", { name: "German whisper" }));
|
||||
expect(
|
||||
screen.getByText("German whisper ≥ 3 · 3 ordered cells"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.clear(difference);
|
||||
await user.type(difference, "2");
|
||||
await user.click(screen.getByRole("button", { name: "German whisper" }));
|
||||
expect(
|
||||
screen.queryByText("German whisper ≥ 3 · 3 ordered cells"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("German whisper ≥ 2 · 3 ordered cells"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
currentPuzzle().constraints?.filter(
|
||||
({ type }) => type === "german-whisper",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Region-sum line" }));
|
||||
expect(
|
||||
screen.getByText("region-sum line · 3 ordered cells"),
|
||||
).toBeInTheDocument();
|
||||
expect(currentPuzzle().constraints).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "between-line",
|
||||
cells: [0, 1, 2],
|
||||
negated: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "german-whisper",
|
||||
cells: [0, 1, 2],
|
||||
minimumDifference: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "region-sum-line",
|
||||
cells: [0, 1, 2],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove region-sum line · 3 ordered cells",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
currentPuzzle().constraints?.some(
|
||||
({ type }) => type === "region-sum-line",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("splits an ordered selection into clone pairs and builds an extra house", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EditorHarness selection={[0, 1, 4, 5]} />);
|
||||
|
||||
const cloneButton = screen.getByRole("button", {
|
||||
name: "Clone selection halves",
|
||||
});
|
||||
const extraButton = screen.getByRole("button", {
|
||||
name: "Extra region (4 cells)",
|
||||
});
|
||||
expect(cloneButton).toBeEnabled();
|
||||
expect(extraButton).toBeEnabled();
|
||||
|
||||
await user.click(cloneButton);
|
||||
await user.click(cloneButton);
|
||||
expect(
|
||||
screen.getByText("clone regions · 2 + 2 paired cells"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
currentPuzzle().constraints?.filter(({ type }) => type === "clone"),
|
||||
).toHaveLength(1);
|
||||
expect(currentPuzzle().constraints).toContainEqual({
|
||||
type: "clone",
|
||||
cells: [0, 1],
|
||||
cloneCells: [4, 5],
|
||||
});
|
||||
|
||||
await user.click(extraButton);
|
||||
await user.click(extraButton);
|
||||
const extraDescription = screen.getByText("extra region · 4 cells");
|
||||
expect(
|
||||
currentPuzzle().constraints?.filter(
|
||||
({ type }) => type === "extra-region",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
const extraItem = extraDescription.closest("li");
|
||||
expect(extraItem).not.toBeNull();
|
||||
expect(
|
||||
within(extraItem as HTMLElement).queryByRole("button", {
|
||||
name: /require false/iu,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(
|
||||
screen
|
||||
.getByText("clone regions · 2 + 2 paired cells")
|
||||
.closest("li") as HTMLElement,
|
||||
).getByRole("button", { name: /require false/iu }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove clone regions · 2 + 2 paired cells",
|
||||
}),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove extra region · 4 cells" }),
|
||||
);
|
||||
expect(currentPuzzle().constraints).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects incomplete Pack 2 selections", () => {
|
||||
render(<EditorHarness selection={[0, 1, 2]} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clone selection halves" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Extra region (4 cells)" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("creates, replaces, polarizes and removes ordered Pack 3 lines", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EditorHarness selection={[0, 1, 2]} initial={createEmptyPuzzle(6)} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
for (const name of [
|
||||
"Modular line",
|
||||
"Entropic line",
|
||||
"Zipper line",
|
||||
"Double arrow",
|
||||
]) {
|
||||
const button = screen.getByRole("button", { name });
|
||||
expect(button).toBeEnabled();
|
||||
await user.click(button);
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.getByText("false · modular line · 3 ordered cells"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("false · entropic line · 3 ordered cells"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("false · zipper line · 3 ordered cells"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("false · double arrow · 3 ordered cells"),
|
||||
).toBeInTheDocument();
|
||||
expect(currentPuzzle().constraints).toHaveLength(4);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Modular line" }));
|
||||
expect(
|
||||
currentPuzzle().constraints?.filter(
|
||||
({ type }) => type === "modular-line",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove false · zipper line · 3 ordered cells",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
currentPuzzle().constraints?.some(({ type }) => type === "zipper-line"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects incompatible Pack 3 grid and line lengths", () => {
|
||||
const { unmount } = render(<EditorHarness selection={[0, 1, 2]} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Entropic line" }),
|
||||
).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<EditorHarness selection={[0, 1, 2, 3]} initial={createEmptyPuzzle(6)} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Modular line" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Double arrow" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Entropic line" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Zipper line" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add / replace indexer" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("replaces indexer kinds at one marker and keeps polarity controls", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EditorHarness selection={[5]} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace indexer" }),
|
||||
);
|
||||
expect(screen.getByText("false · row indexer · r2c2")).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Indexer kind"), "column");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace indexer" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("false · row indexer · r2c2"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("false · column indexer · r2c2"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
currentPuzzle().constraints?.filter(({ type }) => type === "indexer"),
|
||||
).toHaveLength(1);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove false · column indexer · r2c2",
|
||||
}),
|
||||
);
|
||||
expect(currentPuzzle().constraints).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires a trusted solution for fog and replaces its lights and radius", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<EditorHarness selection={[0, 5]} />);
|
||||
const addFog = screen.getByRole("button", {
|
||||
name: "Add / replace Fog of War",
|
||||
});
|
||||
expect(addFog).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Fog of War requires a complete trusted solution. Generate or import one before choosing initial lights.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
|
||||
render(
|
||||
<EditorHarness
|
||||
selection={[0, 5]}
|
||||
initial={{ ...createEmptyPuzzle(4), solution }}
|
||||
/>,
|
||||
);
|
||||
const enabledFog = screen.getByRole("button", {
|
||||
name: "Add / replace Fog of War",
|
||||
});
|
||||
expect(enabledFog).toBeEnabled();
|
||||
await user.click(enabledFog);
|
||||
expect(
|
||||
screen.getByText("fog · 2 initial lights · radius 1"),
|
||||
).toBeInTheDocument();
|
||||
expect(currentPuzzle().constraints).toContainEqual({
|
||||
type: "fog",
|
||||
lights: [0, 5],
|
||||
revealRadius: 1,
|
||||
});
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Fog reveal radius"), "0");
|
||||
await user.click(enabledFog);
|
||||
expect(
|
||||
screen.queryByText("fog · 2 initial lights · radius 1"),
|
||||
).not.toBeInTheDocument();
|
||||
const description = screen.getByText("fog · 2 initial lights · radius 0");
|
||||
expect(description).toBeInTheDocument();
|
||||
expect(
|
||||
currentPuzzle().constraints?.filter(({ type }) => type === "fog"),
|
||||
).toHaveLength(1);
|
||||
const item = description.closest("li");
|
||||
expect(item).not.toBeNull();
|
||||
expect(
|
||||
within(item as HTMLElement).queryByRole("button", {
|
||||
name: /require false/iu,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(
|
||||
within(item as HTMLElement).getByRole("button", {
|
||||
name: "Remove fog · 2 initial lights · radius 0",
|
||||
}),
|
||||
);
|
||||
expect(currentPuzzle().constraints).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,11 @@ 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";
|
||||
import { classicRegions } from "../../src/domain";
|
||||
import type {
|
||||
GeneratedVariantBatch,
|
||||
GeneratedVariantPuzzle,
|
||||
} from "../../src/solver";
|
||||
|
||||
describe("Sudoku generator workspace", () => {
|
||||
it("offers only reliable sizes and submits an explicit local recipe", async () => {
|
||||
@@ -97,4 +102,158 @@ describe("Sudoku generator workspace", () => {
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits mixed families, density, minimality and a full profile as a batch", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGenerateBatch = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
onGenerate={vi.fn()}
|
||||
onGenerateBatch={onGenerateBatch}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "thermo");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Kropki dots" }));
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Given symmetry"),
|
||||
"horizontal",
|
||||
);
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Constraint density"),
|
||||
"dense",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", { name: "Prove minimal givens" }),
|
||||
);
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Practice technique"),
|
||||
"x-wing",
|
||||
);
|
||||
await user.clear(screen.getByLabelText("Minimum occurrences"));
|
||||
await user.type(screen.getByLabelText("Minimum occurrences"), "2");
|
||||
await user.type(screen.getByLabelText("Maximum occurrences"), "3");
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Forbidden technique"),
|
||||
"swordfish",
|
||||
);
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Hardest technique target"),
|
||||
"x-wing",
|
||||
);
|
||||
await user.clear(screen.getByLabelText("Batch size"));
|
||||
await user.type(screen.getByLabelText("Batch size"), "3");
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Batch ranking"),
|
||||
"fewest-givens",
|
||||
);
|
||||
await user.type(screen.getByLabelText("Seed"), "mixed-batch");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Generate 3-puzzle batch" }),
|
||||
);
|
||||
|
||||
expect(onGenerateBatch).toHaveBeenCalledWith({
|
||||
variant: "thermo",
|
||||
variants: ["thermo", "kropki"],
|
||||
size: 9,
|
||||
targetDifficulty: "medium",
|
||||
symmetry: "horizontal",
|
||||
constraintCount: 8,
|
||||
constraintDensity: "dense",
|
||||
minimalGivens: true,
|
||||
requiredTechnique: "x-wing",
|
||||
techniqueProfile: {
|
||||
forbidden: ["swordfish"],
|
||||
counts: [{ technique: "x-wing", min: 2, max: 3 }],
|
||||
hardestTechnique: "x-wing",
|
||||
},
|
||||
maxTechniqueAttempts: 10,
|
||||
seed: "mixed-batch",
|
||||
batchSize: 3,
|
||||
ranking: "fewest-givens",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders ranked evidence and lets the caller open a candidate", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectGenerated = vi.fn();
|
||||
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3];
|
||||
const generation: GeneratedVariantPuzzle = {
|
||||
puzzle: {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: solution,
|
||||
solution,
|
||||
regions: classicRegions(4),
|
||||
constraints: [],
|
||||
},
|
||||
difficulty: {
|
||||
score: 12,
|
||||
level: "beginner",
|
||||
label: "Beginner",
|
||||
uniqueness: "unique",
|
||||
clueCount: 16,
|
||||
emptyCount: 0,
|
||||
logicalStatus: "solved",
|
||||
logicalSteps: 0,
|
||||
techniqueCounts: {},
|
||||
exactNodes: 1,
|
||||
exactTruncated: false,
|
||||
summary: "fixture",
|
||||
},
|
||||
variant: "classic",
|
||||
families: ["classic"],
|
||||
seed: "ranked:1",
|
||||
generatedConstraintCount: 0,
|
||||
generationAttempts: 1,
|
||||
constraintDensity: "balanced",
|
||||
minimality: {
|
||||
status: "not-requested",
|
||||
checksPerformed: 0,
|
||||
nodes: 0,
|
||||
removedClues: 0,
|
||||
criticalCells: [],
|
||||
unknownCells: [],
|
||||
limitReasons: [],
|
||||
symmetryPreserved: true,
|
||||
},
|
||||
};
|
||||
const batch: GeneratedVariantBatch = {
|
||||
entries: [generation],
|
||||
summaries: [
|
||||
{
|
||||
rank: 1,
|
||||
seed: generation.seed,
|
||||
families: generation.families,
|
||||
clueCount: 16,
|
||||
constraintCount: 0,
|
||||
score: 12,
|
||||
level: "beginner",
|
||||
minimalityStatus: "not-requested",
|
||||
},
|
||||
],
|
||||
failures: [],
|
||||
requested: 1,
|
||||
completed: 1,
|
||||
truncated: false,
|
||||
ranking: "difficulty",
|
||||
baseSeed: "ranked",
|
||||
};
|
||||
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
batch={batch}
|
||||
onGenerate={vi.fn()}
|
||||
onSelectGenerated={onSelectGenerated}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1 of 1 generated")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Use" }));
|
||||
expect(onSelectGenerated).toHaveBeenCalledWith(generation);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
GuidedHint,
|
||||
type GuidedHintProps,
|
||||
} from "../../src/components/GuidedHint";
|
||||
import {
|
||||
deriveGuidedHintCellSets,
|
||||
guidedHintEffectItems,
|
||||
guidedHintFocusSummary,
|
||||
guidedHintOverlay,
|
||||
guidedHintStepIsVisible,
|
||||
nextGuidedHintStage,
|
||||
} from "../../src/components/guidedHint";
|
||||
import type { LogicalStep } from "../../src/solver";
|
||||
|
||||
const step: LogicalStep = {
|
||||
technique: "naked-pair",
|
||||
focusCells: [0, 1, 0],
|
||||
placements: [{ cell: 9, value: 4 }],
|
||||
eliminations: [{ cell: 10, values: [2, 3] }],
|
||||
explanation: "The private pair reasoning is now visible.",
|
||||
};
|
||||
|
||||
function props(overrides: Partial<GuidedHintProps> = {}): GuidedHintProps {
|
||||
return {
|
||||
size: 9,
|
||||
step,
|
||||
stage: "focus",
|
||||
autoMaintainPeerNotes: false,
|
||||
onRequestHint: vi.fn(),
|
||||
onRevealNext: vi.fn(),
|
||||
onApply: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
onFillLegalCandidates: vi.fn(),
|
||||
onRemoveInvalidNotes: vi.fn(),
|
||||
onAutoMaintainPeerNotesChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("guided hint presentation helpers", () => {
|
||||
it("derives unique focus and effect cells without revealing effects early", () => {
|
||||
expect(deriveGuidedHintCellSets(step)).toEqual({
|
||||
focusCells: [0, 1],
|
||||
placementCells: [9],
|
||||
eliminationCells: [10],
|
||||
affectedCells: [9, 10],
|
||||
});
|
||||
expect(guidedHintOverlay(step, "reasoning")).toEqual({
|
||||
focusCells: [0, 1],
|
||||
placementCells: [],
|
||||
eliminationCells: [],
|
||||
});
|
||||
expect(guidedHintOverlay(step, "preview")).toEqual({
|
||||
focusCells: [0, 1],
|
||||
placementCells: [9],
|
||||
eliminationCells: [10],
|
||||
});
|
||||
});
|
||||
|
||||
it("describes focus locations, effects and the finite reveal sequence", () => {
|
||||
expect(guidedHintFocusSummary(step, 9)).toBe("Look across row 1.");
|
||||
expect(guidedHintFocusSummary({ ...step, focusCells: [0, 9] }, 9)).toBe(
|
||||
"Look down column 1.",
|
||||
);
|
||||
expect(guidedHintFocusSummary({ ...step, focusCells: [0, 10] }, 9)).toBe(
|
||||
"Look within box 1.",
|
||||
);
|
||||
expect(guidedHintEffectItems(step, 9)).toEqual([
|
||||
"Place 4 in r2c1.",
|
||||
"Remove 2 and 3 from r2c2.",
|
||||
]);
|
||||
expect(nextGuidedHintStage("focus")).toBe("technique");
|
||||
expect(nextGuidedHintStage("technique")).toBe("reasoning");
|
||||
expect(nextGuidedHintStage("reasoning")).toBe("preview");
|
||||
expect(nextGuidedHintStage("preview")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a hint when fog hides a premise, placement or elimination", () => {
|
||||
expect(guidedHintStepIsVisible(step, new Set())).toBe(true);
|
||||
expect(guidedHintStepIsVisible(step, new Set([0]))).toBe(false);
|
||||
expect(guidedHintStepIsVisible(step, new Set([9]))).toBe(false);
|
||||
expect(guidedHintStepIsVisible(step, new Set([10]))).toBe(false);
|
||||
expect(guidedHintStepIsVisible(step, new Set([80]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guided hint", () => {
|
||||
it("keeps unrevealed answer content out of the document", () => {
|
||||
const base = props();
|
||||
const { rerender } = render(<GuidedHint {...base} />);
|
||||
|
||||
expect(screen.getByText("Look across row 1.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Naked Pair")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<GuidedHint {...base} stage="technique" />);
|
||||
expect(screen.getByText("Naked Pair")).toBeInTheDocument();
|
||||
expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<GuidedHint {...base} stage="reasoning" />);
|
||||
expect(screen.getByText(step.explanation)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<GuidedHint {...base} stage="preview" />);
|
||||
expect(screen.getByText("Place 4 in r2c1.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remove 2 and 3 from r2c2.")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/will start a complete legal centre-candidate grid/u),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply this step" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("delegates reveal, apply, replacement and dismissal to its parent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const callbacks = {
|
||||
onRevealNext: vi.fn(),
|
||||
onApply: vi.fn(),
|
||||
onRequestHint: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<GuidedHint {...props(callbacks)} stage="focus" />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Reveal technique" }));
|
||||
expect(callbacks.onRevealNext).toHaveBeenCalledOnce();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New hint" }));
|
||||
await user.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(callbacks.onRequestHint).toHaveBeenCalledOnce();
|
||||
expect(callbacks.onDismiss).toHaveBeenCalledOnce();
|
||||
|
||||
rerender(<GuidedHint {...props(callbacks)} stage="preview" />);
|
||||
await user.click(screen.getByRole("button", { name: "Apply this step" }));
|
||||
expect(callbacks.onApply).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers candidate maintenance without requiring an open hint", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRequestHint = vi.fn();
|
||||
const onFillLegalCandidates = vi.fn();
|
||||
const onRemoveInvalidNotes = vi.fn();
|
||||
const onAutoMaintainPeerNotesChange = vi.fn();
|
||||
render(
|
||||
<GuidedHint
|
||||
{...props({
|
||||
step: undefined,
|
||||
onRequestHint,
|
||||
onFillLegalCandidates,
|
||||
onRemoveInvalidNotes,
|
||||
onAutoMaintainPeerNotesChange,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Get a guided hint" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Fill legal candidates" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove invalid notes" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Automatically remove peer notes after placing a digit",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onRequestHint).toHaveBeenCalledOnce();
|
||||
expect(onFillLegalCandidates).toHaveBeenCalledOnce();
|
||||
expect(onRemoveInvalidNotes).toHaveBeenCalledOnce();
|
||||
expect(onAutoMaintainPeerNotesChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { ImportExportDialog } from "../../src/components/ImportExportDialog";
|
||||
import { createEmptyPuzzle } from "../../src/domain";
|
||||
import type { PuzzleDefinition } from "../../src/domain/types";
|
||||
import { fromDomainPuzzle } from "../../src/formats";
|
||||
import {
|
||||
fromDomainPuzzle,
|
||||
type PreservedSudokuDocumentExtras,
|
||||
} from "../../src/formats";
|
||||
import type { PortableAidMemoire } from "../../src/state/aidMemoire";
|
||||
import { createSession } from "../../src/state/session";
|
||||
|
||||
@@ -40,6 +43,7 @@ function renderDialog(
|
||||
options: {
|
||||
readonly puzzle?: PuzzleDefinition;
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
readonly preservedExtras?: PreservedSudokuDocumentExtras;
|
||||
} = {},
|
||||
) {
|
||||
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
|
||||
@@ -51,6 +55,7 @@ function renderDialog(
|
||||
puzzle={puzzle}
|
||||
session={createSession(puzzle.givens)}
|
||||
aidMemoire={options.aidMemoire}
|
||||
preservedExtras={options.preservedExtras}
|
||||
onClose={onClose}
|
||||
onImport={onImport}
|
||||
/>,
|
||||
@@ -79,11 +84,14 @@ describe("ImportExportDialog interoperability", () => {
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
source: { format: "sudokupad", id: "synthetic-local-test" },
|
||||
}),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports unsupported constructs and never fetches short IDs", async () => {
|
||||
it("previews safe visuals and never fetches short IDs", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
@@ -91,12 +99,35 @@ describe("ImportExportDialog interoperability", () => {
|
||||
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: localScl({ overlays: [{ text: "visual only" }] }) },
|
||||
target: {
|
||||
value: localScl({
|
||||
overlays: [
|
||||
{
|
||||
center: [0.5, 0.5],
|
||||
width: 1,
|
||||
height: 1,
|
||||
text: "visual only",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("region", { name: "Import mapping preview" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Preserved visuals")).toBeInTheDocument();
|
||||
expect(screen.getByText("Text overlay: 1")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: localScl({ overlays: [{ text: "missing center" }] }) },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(await screen.findByText(/center.*point/u)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: "https://sudokupad.app/serverOnly42" },
|
||||
@@ -164,6 +195,47 @@ describe("ImportExportDialog interoperability", () => {
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4 }),
|
||||
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
|
||||
{ source: { format: "sudoku-tools" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("includes preserved source extras in SudokuPad exports", async () => {
|
||||
const user = userEvent.setup();
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:scl-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
renderDialog({
|
||||
preservedExtras: {
|
||||
source: { format: "sudokupad", id: "kept-source" },
|
||||
metadata: { edition: "nightly" },
|
||||
visuals: [
|
||||
{
|
||||
type: "text",
|
||||
layer: "overlay",
|
||||
position: { kind: "cell", cell: 0 },
|
||||
text: "kept visual",
|
||||
style: { fill: "#123456" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download SudokuPad JSON" }),
|
||||
);
|
||||
|
||||
const exported = JSON.parse((await downloaded?.text()) ?? "{}") as {
|
||||
metadata?: Record<string, unknown>;
|
||||
overlays?: unknown[];
|
||||
};
|
||||
expect(exported.metadata?.edition).toBe("nightly");
|
||||
expect(exported.overlays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ text: "kept visual" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LibraryDialog } from "../../src/components/LibraryDialog";
|
||||
|
||||
const summaries = [
|
||||
{
|
||||
id: "killer",
|
||||
title: "Evening Killer",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
size: 4,
|
||||
completed: false,
|
||||
tags: ["killer", "hard"],
|
||||
thumbnail: "1...............",
|
||||
},
|
||||
{
|
||||
id: "classic",
|
||||
title: "Morning Classic",
|
||||
createdAt: 1,
|
||||
updatedAt: 3,
|
||||
size: 4,
|
||||
completed: true,
|
||||
tags: ["classic"],
|
||||
thumbnail: "....2...........",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function renderDialog(overrides: Record<string, unknown> = {}) {
|
||||
const props = {
|
||||
open: true,
|
||||
summaries,
|
||||
mode: "indexeddb" as const,
|
||||
busy: false,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onExportSelected: vi.fn(),
|
||||
onDuplicateSelected: vi.fn(),
|
||||
onDeleteSelected: vi.fn(),
|
||||
onUpdateTags: vi.fn(),
|
||||
onImport: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<LibraryDialog {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("LibraryDialog", () => {
|
||||
it("filters by text, tag and completion while showing safe previews", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("img", { name: /puzzle preview/u }),
|
||||
).toHaveLength(2);
|
||||
await user.type(screen.getByRole("searchbox"), "killer");
|
||||
expect(screen.getByText("Evening Killer")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Morning Classic")).not.toBeInTheDocument();
|
||||
|
||||
await user.clear(screen.getByRole("searchbox"));
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Completion filter"),
|
||||
"complete",
|
||||
);
|
||||
expect(screen.getByText("Morning Classic")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Evening Killer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exports, duplicates and deletes an explicit selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDialog();
|
||||
|
||||
await user.click(screen.getByLabelText("Select Evening Killer"));
|
||||
const selection = screen.getByText(/1 selected/u).parentElement!;
|
||||
await user.click(
|
||||
within(selection).getByRole("button", { name: "Export selected" }),
|
||||
);
|
||||
await user.click(
|
||||
within(selection).getByRole("button", { name: "Duplicate selected" }),
|
||||
);
|
||||
await user.click(
|
||||
within(selection).getByRole("button", { name: "Delete selected" }),
|
||||
);
|
||||
|
||||
expect(props.onExportSelected).toHaveBeenCalledWith(["killer"]);
|
||||
expect(props.onDuplicateSelected).toHaveBeenCalledWith(["killer"]);
|
||||
expect(props.onDeleteSelected).toHaveBeenCalledWith(["killer"]);
|
||||
});
|
||||
|
||||
it("edits bounded comma-separated tags", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDialog();
|
||||
|
||||
const item = screen.getByText("Evening Killer").closest("li")!;
|
||||
await user.click(within(item).getByRole("button", { name: "Edit tags" }));
|
||||
const input = within(item).getByLabelText("Tags for Evening Killer");
|
||||
await user.clear(input);
|
||||
await user.type(input, "killer, weekend");
|
||||
await user.click(within(item).getByRole("button", { name: "Apply" }));
|
||||
expect(props.onUpdateTags).toHaveBeenCalledWith("killer", [
|
||||
"killer",
|
||||
"weekend",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NumberPad } from "../../src/components/NumberPad";
|
||||
|
||||
describe("NumberPad colour accessibility", () => {
|
||||
it("names each colour by both hue and pattern", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValue = vi.fn();
|
||||
const { container } = render(
|
||||
<NumberPad
|
||||
size={9}
|
||||
mode="color"
|
||||
onMode={vi.fn()}
|
||||
onValue={onValue}
|
||||
onErase={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stripes = screen.getByRole("button", {
|
||||
name: "Colour 1: red, diagonal stripes",
|
||||
});
|
||||
expect(stripes).toHaveClass("color-1");
|
||||
expect(container.querySelector(".color-8")).toHaveAccessibleName(
|
||||
"Colour 8: pink, rings",
|
||||
);
|
||||
await user.click(stripes);
|
||||
expect(onValue).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SafeVisualLayer } from "../../src/components/SafeVisualLayer";
|
||||
import type { SafeVisualPrimitive } from "../../src/formats";
|
||||
|
||||
const visuals: readonly SafeVisualPrimitive[] = [
|
||||
{
|
||||
type: "line",
|
||||
layer: "underlay",
|
||||
start: { kind: "coordinate", x: 0.25, y: 0.75 },
|
||||
end: { kind: "cell", cell: 5, offsetX: 0.1, offsetY: -0.1 },
|
||||
style: { stroke: "#123456", strokeWidth: 0.05, opacity: 0.75 },
|
||||
},
|
||||
{
|
||||
type: "polyline",
|
||||
layer: "underlay",
|
||||
points: [
|
||||
{ kind: "cell", cell: 0 },
|
||||
{ kind: "cell", cell: 1 },
|
||||
{ kind: "cell", cell: 5 },
|
||||
],
|
||||
style: { stroke: "#abcdef", fill: "transparent" },
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
layer: "overlay",
|
||||
center: { kind: "cell", cell: 6 },
|
||||
width: 0.8,
|
||||
height: 0.6,
|
||||
cornerRadius: 0.1,
|
||||
style: { fill: "#ffeecc", stroke: "#112233" },
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
layer: "overlay",
|
||||
center: { kind: "coordinate", x: 2.5, y: 2.5 },
|
||||
radiusX: 0.4,
|
||||
radiusY: 0.2,
|
||||
},
|
||||
{
|
||||
type: "circle",
|
||||
layer: "overlay",
|
||||
center: { kind: "cell", cell: 10 },
|
||||
radius: 0.25,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
layer: "overlay",
|
||||
position: { kind: "cell", cell: 15 },
|
||||
text: "source label",
|
||||
style: { fill: "#010203", fontSize: 0.4 },
|
||||
},
|
||||
];
|
||||
|
||||
describe("SafeVisualLayer", () => {
|
||||
it("renders every canonical primitive with stable classes and grid geometry", () => {
|
||||
const { container } = render(
|
||||
<svg viewBox="0 0 4 4">
|
||||
<SafeVisualLayer size={4} visuals={visuals} layer="underlay" />
|
||||
<SafeVisualLayer size={4} visuals={visuals} layer="overlay" />
|
||||
</svg>,
|
||||
);
|
||||
|
||||
const line = container.querySelector(".source-visual--line");
|
||||
expect(line).toHaveAttribute("x1", "0.25");
|
||||
expect(line).toHaveAttribute("y1", "0.75");
|
||||
expect(line).toHaveAttribute("x2", "1.6");
|
||||
expect(line).toHaveAttribute("y2", "1.4");
|
||||
expect(line).toHaveAttribute("stroke", "#123456");
|
||||
expect(line).toHaveAttribute("stroke-width", "0.05");
|
||||
expect(container.querySelector(".source-visual--polyline")).toHaveAttribute(
|
||||
"points",
|
||||
"0.5,0.5 1.5,0.5 1.5,1.5",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(".source-visual--rectangle"),
|
||||
).toHaveAttribute("rx", "0.1");
|
||||
expect(container.querySelector(".source-visual--ellipse")).toHaveAttribute(
|
||||
"rx",
|
||||
"0.4",
|
||||
);
|
||||
expect(container.querySelector(".source-visual--circle")).toHaveAttribute(
|
||||
"r",
|
||||
"0.25",
|
||||
);
|
||||
expect(container.querySelector(".source-visual--text")).toHaveTextContent(
|
||||
"source label",
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll(".safe-visual-layer--underlay > *"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll(".safe-visual-layer--overlay > *"),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("uses a React text node and never creates executable descendants", () => {
|
||||
const malicious =
|
||||
'</text><script onload="alert(1)">x</script><image href="x">';
|
||||
const { container } = render(
|
||||
<svg viewBox="0 0 4 4">
|
||||
<SafeVisualLayer
|
||||
size={4}
|
||||
layer="overlay"
|
||||
visuals={[
|
||||
{
|
||||
type: "text",
|
||||
layer: "overlay",
|
||||
position: { kind: "cell", cell: 0 },
|
||||
text: malicious,
|
||||
style: { fill: "#000000" },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".source-visual--text")?.textContent).toBe(
|
||||
malicious,
|
||||
);
|
||||
expect(container.querySelector("script, image")).toBeNull();
|
||||
expect(
|
||||
container.querySelector("[href], [src], [onclick], [onload]"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
SetterQualityLab,
|
||||
type SetterQualityLabProps,
|
||||
} from "../../src/components/SetterQualityLab";
|
||||
import type {
|
||||
PuzzleQualityAnalysis,
|
||||
QualityItemReference,
|
||||
} from "../../src/solver/quality";
|
||||
|
||||
const given: QualityItemReference = { kind: "given", cell: 0, value: 1 };
|
||||
const constraint: QualityItemReference = {
|
||||
kind: "constraint",
|
||||
index: 0,
|
||||
constraintType: "diagonal",
|
||||
};
|
||||
|
||||
const bounds = {
|
||||
perCheck: { maxNodes: 2_000_000, timeoutMs: 10_000 },
|
||||
aggregate: { maxChecks: 1_000, maxNodes: 20_000_000, timeoutMs: 30_000 },
|
||||
} as const;
|
||||
|
||||
const multiple: PuzzleQualityAnalysis = {
|
||||
analysisDepth: "full",
|
||||
solutionStatus: "multiple",
|
||||
ambiguityWitness: {
|
||||
firstSolution: [1, 2, 3, 4],
|
||||
secondSolution: [2, 1, 3, 4],
|
||||
differences: [
|
||||
{ cell: 0, first: 1, second: 2 },
|
||||
{ cell: 1, first: 2, second: 1 },
|
||||
],
|
||||
},
|
||||
contradiction: {
|
||||
status: "not-applicable",
|
||||
core: [],
|
||||
necessary: [],
|
||||
removable: [],
|
||||
unknown: [],
|
||||
},
|
||||
redundancy: {
|
||||
givens: [
|
||||
{
|
||||
item: given,
|
||||
classification: "critical",
|
||||
checkIndex: 1,
|
||||
solutionStatus: "multiple",
|
||||
},
|
||||
],
|
||||
constraints: [
|
||||
{
|
||||
item: constraint,
|
||||
classification: "redundant",
|
||||
checkIndex: 2,
|
||||
solutionStatus: "unique",
|
||||
},
|
||||
],
|
||||
},
|
||||
criticalityHeatmap: [
|
||||
{
|
||||
cell: 0,
|
||||
score: 1,
|
||||
criticalWeight: 1,
|
||||
redundantWeight: 0,
|
||||
unknownWeight: 0,
|
||||
},
|
||||
{
|
||||
cell: 1,
|
||||
score: null,
|
||||
criticalWeight: 0,
|
||||
redundantWeight: 0,
|
||||
unknownWeight: 1,
|
||||
},
|
||||
],
|
||||
minimality: {
|
||||
status: "not-minimal",
|
||||
redundant: [constraint],
|
||||
unknown: [],
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
index: 0,
|
||||
purpose: "baseline",
|
||||
solutionStatus: "multiple",
|
||||
solutionsFound: 2,
|
||||
conclusive: true,
|
||||
truncated: true,
|
||||
limitReason: "solution-cap",
|
||||
nodes: 10,
|
||||
elapsedMs: 2,
|
||||
},
|
||||
],
|
||||
bounds,
|
||||
budget: {
|
||||
checksPlanned: 3,
|
||||
checksPerformed: 3,
|
||||
nodes: 42,
|
||||
elapsedMs: 12,
|
||||
truncated: false,
|
||||
unknownReasons: [],
|
||||
},
|
||||
};
|
||||
|
||||
function props(
|
||||
overrides: Partial<SetterQualityLabProps> = {},
|
||||
): SetterQualityLabProps {
|
||||
return {
|
||||
size: 4,
|
||||
onRunQuick: vi.fn(),
|
||||
onRunMinimality: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onFocusCells: vi.fn(),
|
||||
onFocusItem: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("setter quality lab", () => {
|
||||
it("runs distinct quick and bounded-minimality profiles and supports cancel", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRunQuick = vi.fn();
|
||||
const onRunMinimality = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const base = props({ onRunQuick, onRunMinimality, onCancel });
|
||||
const { rerender } = render(<SetterQualityLab {...base} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Nodes per check"), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Run quick quality check" }),
|
||||
);
|
||||
expect(onRunQuick).toHaveBeenCalledWith({
|
||||
perCheckMaxNodes: 123_456,
|
||||
perCheckTimeoutMs: 10_000,
|
||||
aggregateMaxChecks: 1_000,
|
||||
aggregateMaxNodes: 20_000_000,
|
||||
aggregateTimeoutMs: 30_000,
|
||||
analysisDepth: "baseline",
|
||||
proveMinimality: false,
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Run full bounded minimality" }),
|
||||
);
|
||||
expect(onRunMinimality).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
analysisDepth: "full",
|
||||
proveMinimality: true,
|
||||
}),
|
||||
);
|
||||
|
||||
rerender(<SetterQualityLab {...base} running="minimality" />);
|
||||
expect(
|
||||
screen.getByText("Full bounded minimality analysis running locally…"),
|
||||
).toHaveAttribute("role", "status");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Run quick quality check" }),
|
||||
).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel analysis" }));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders an ambiguity witness, findings, heatmap and bounded metrics", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFocusCells = vi.fn();
|
||||
const onFocusItem = vi.fn();
|
||||
render(
|
||||
<SetterQualityLab
|
||||
{...props({ result: multiple, onFocusCells, onFocusItem })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Multiple completions found" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("2 differing cells")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Focus differences" }));
|
||||
expect(onFocusCells).toHaveBeenCalledWith([0, 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /r1c1: critical; score 1\.00/u,
|
||||
}),
|
||||
);
|
||||
expect(onFocusCells).toHaveBeenLastCalledWith([0]);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /r1c2: unknown; score unknown/u }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Given 1 at r1c1" }));
|
||||
expect(onFocusItem).toHaveBeenCalledWith(given);
|
||||
|
||||
const metrics = screen
|
||||
.getByRole("heading", { name: "Checks and limits" })
|
||||
.closest("section")!;
|
||||
expect(within(metrics).getByText("3 / 3")).toBeInTheDocument();
|
||||
expect(
|
||||
within(metrics).getByText("2,000,000 nodes / 10,000 ms"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(metrics).getByText("1,000 checks / 20,000,000 nodes / 30,000 ms"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Not minimal" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels bounded unknowns and exposes contradiction suspects", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFocusItem = vi.fn();
|
||||
const unknown: PuzzleQualityAnalysis = {
|
||||
...multiple,
|
||||
solutionStatus: "unknown",
|
||||
ambiguityWitness: undefined,
|
||||
contradiction: {
|
||||
status: "incomplete",
|
||||
core: [given, constraint],
|
||||
necessary: [given],
|
||||
removable: [],
|
||||
unknown: [constraint],
|
||||
reason: "The aggregate time budget ended localization.",
|
||||
},
|
||||
redundancy: {
|
||||
givens: [
|
||||
{
|
||||
item: given,
|
||||
classification: "unknown",
|
||||
unknownReason: "aggregate-timeout",
|
||||
},
|
||||
],
|
||||
constraints: [],
|
||||
},
|
||||
criticalityHeatmap: [
|
||||
{
|
||||
cell: 0,
|
||||
score: null,
|
||||
criticalWeight: 0,
|
||||
redundantWeight: 0,
|
||||
unknownWeight: 1,
|
||||
},
|
||||
],
|
||||
minimality: {
|
||||
status: "unknown",
|
||||
redundant: [],
|
||||
unknown: [constraint],
|
||||
reason: "One or more clue checks were bounded.",
|
||||
},
|
||||
budget: {
|
||||
...multiple.budget,
|
||||
checksPerformed: 2,
|
||||
truncated: true,
|
||||
unknownReasons: ["aggregate-timeout"],
|
||||
},
|
||||
};
|
||||
render(<SetterQualityLab {...props({ result: unknown, onFocusItem })} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Solution status unknown" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText(/incomplete \/ unknown/iu).length,
|
||||
).toBeGreaterThan(1);
|
||||
expect(
|
||||
screen.getByText("The aggregate time budget ended localization."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Aggregate timeout")).toBeInTheDocument();
|
||||
|
||||
const necessary = screen
|
||||
.getByRole("heading", { name: "Proven necessary suspects" })
|
||||
.closest("section")!;
|
||||
await user.click(
|
||||
within(necessary).getByRole("button", { name: "Given 1 at r1c1" }),
|
||||
);
|
||||
expect(onFocusItem).toHaveBeenCalledWith(given);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,77 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizePuzzle } from "../../src/domain";
|
||||
import { SudokuBoard } from "../../src/components/SudokuBoard";
|
||||
import { foggedCellsForPuzzle } from "../../src/components/fogVisibility";
|
||||
|
||||
describe("Sudoku board constraint visuals", () => {
|
||||
it("orders retained underlays before semantic clues and overlays afterward", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
solution: [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3],
|
||||
constraints: [
|
||||
{ type: "diagonal", direction: "main" },
|
||||
{ type: "fog", lights: [0], revealRadius: 0 },
|
||||
],
|
||||
});
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
visuals={[
|
||||
{
|
||||
type: "circle",
|
||||
layer: "underlay",
|
||||
center: { kind: "cell", cell: 0 },
|
||||
radius: 0.3,
|
||||
style: { fill: "#abcdef" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
layer: "overlay",
|
||||
position: { kind: "cell", cell: 15 },
|
||||
text: "overlay",
|
||||
style: { fill: "#123456" },
|
||||
},
|
||||
]}
|
||||
selected={new Set()}
|
||||
activeCell={0}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const underlay = container.querySelector(".safe-visual-layer--underlay")!;
|
||||
const regions = container.querySelector(".region-boundaries")!;
|
||||
const semantic = container.querySelector(".constraint-diagonal")!;
|
||||
const overlay = container.querySelector(".safe-visual-layer--overlay")!;
|
||||
const fogMask = container.querySelector(".fog-constraint-mask")!;
|
||||
expect(
|
||||
underlay.compareDocumentPosition(regions) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
underlay.compareDocumentPosition(semantic) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
semantic.compareDocumentPosition(overlay) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
overlay.compareDocumentPosition(fogMask) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(fogMask.querySelectorAll("rect").length).toBeGreaterThan(0);
|
||||
expect(underlay.querySelector(".source-visual--circle")).toBeTruthy();
|
||||
expect(overlay.querySelector(".source-visual--text")).toHaveTextContent(
|
||||
"overlay",
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes XV sums from directional inequalities", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
@@ -112,6 +180,412 @@ describe("Sudoku board constraint visuals", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders Pack 1 cell, global and outside clues with distinct semantics", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
constraints: [
|
||||
{ type: "maximum", cell: 4 },
|
||||
{ type: "minimum", cell: 5, negated: true },
|
||||
{ type: "odd", cell: 9 },
|
||||
{ type: "even", cell: 10 },
|
||||
{ type: "disjoint-groups" },
|
||||
{
|
||||
type: "little-killer",
|
||||
side: "top",
|
||||
index: 1,
|
||||
direction: "down-right",
|
||||
sum: 7,
|
||||
},
|
||||
{ type: "sandwich", side: "left", index: 2, sum: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set()}
|
||||
activeCell={0}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const maximumPath = container
|
||||
.querySelector(".constraint-maximum path")
|
||||
?.getAttribute("d");
|
||||
const minimumPath = container
|
||||
.querySelector(".constraint-minimum path")
|
||||
?.getAttribute("d");
|
||||
expect(maximumPath).toBeTruthy();
|
||||
expect(minimumPath).toBeTruthy();
|
||||
expect(minimumPath).not.toBe(maximumPath);
|
||||
expect(container.querySelector(".constraint-minimum")).toHaveClass(
|
||||
"is-negated",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(".constraint-odd circle"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-even rect"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".constraint-disjoint-groups path"),
|
||||
).toHaveLength(4);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'.constraint-little-killer[data-direction="down-right"] .little-killer-arrow',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-little-killer text"),
|
||||
).toHaveTextContent("7");
|
||||
expect(
|
||||
container.querySelector(".constraint-sandwich .outside-clue-kinds"),
|
||||
).toHaveTextContent("1⋯N");
|
||||
expect(
|
||||
container.querySelector(".constraint-sandwich .outside-clue-value"),
|
||||
).toHaveTextContent("3");
|
||||
|
||||
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"Disjoint groups rule: corresponding positions in every standard box contain each digit once.",
|
||||
),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"little killer sum 7 from the top, column 2, travelling down right.",
|
||||
),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"sandwich sum 3 from the left, row 3, between 1 and 4.",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 3, column 2, empty" }),
|
||||
).toHaveAccessibleDescription(
|
||||
expect.stringMatching(/odd digit.*sandwich sum 3/iu),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders Pack 2 lines and regions as distinct accessible clues", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
constraints: [
|
||||
{ type: "between-line", cells: [0, 1, 2], negated: true },
|
||||
{
|
||||
type: "german-whisper",
|
||||
cells: [4, 5, 6],
|
||||
minimumDifference: 2,
|
||||
},
|
||||
{ type: "region-sum-line", cells: [8, 9, 10] },
|
||||
{ type: "clone", cells: [0, 4], cloneCells: [3, 7] },
|
||||
{ type: "extra-region", cells: [0, 3, 12, 15] },
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set()}
|
||||
activeCell={0}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".constraint-between-line")).toHaveClass(
|
||||
"is-negated",
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll(".constraint-between-line > circle"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector(".constraint-german-whisper polyline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-region-sum-line polyline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(
|
||||
".constraint-region-sum-line .region-sum-divider",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelectorAll(".constraint-clone .clone-cell-fill"),
|
||||
).toHaveLength(4);
|
||||
expect(
|
||||
container.querySelectorAll(".constraint-clone .clone-boundary"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector(".constraint-clone .clone-link"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".constraint-clone .clone-label"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll(
|
||||
".constraint-extra-region .extra-region-cell-fill",
|
||||
),
|
||||
).toHaveLength(4);
|
||||
|
||||
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"False clue: between line from row 1, column 1 through row 1, column 2 to row 1, column 3",
|
||||
),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"German whisper through row 2, column 1; row 2, column 2; row 2, column 3; adjacent digits differ by at least 2.",
|
||||
),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"clone regions pairing row 1, column 1; row 2, column 1 with row 1, column 4; row 2, column 4 in order.",
|
||||
),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"Extra region through row 1, column 1; row 1, column 4; row 4, column 1; row 4, column 4; every digit appears exactly once.",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
|
||||
).toHaveAccessibleDescription(
|
||||
expect.stringMatching(/between line.*clone regions.*extra region/iu),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders Pack 3 pattern lines and indexers with distinct non-colour shapes", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: new Array<number>(36).fill(0),
|
||||
constraints: [
|
||||
{ type: "modular-line", cells: [0, 1, 2], negated: true },
|
||||
{ type: "entropic-line", cells: [6, 7, 8] },
|
||||
{ type: "zipper-line", cells: [12, 13, 14, 15, 16] },
|
||||
{ type: "double-arrow", cells: [18, 19, 20] },
|
||||
{ type: "indexer", kind: "row", cell: 24 },
|
||||
{ type: "indexer", kind: "column", cell: 25 },
|
||||
{ type: "indexer", kind: "box", cell: 26 },
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set()}
|
||||
activeCell={0}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".constraint-modular-line")).toHaveClass(
|
||||
"is-negated",
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll(".constraint-modular-line .modular-line-node"),
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
container.querySelector(
|
||||
".constraint-entropic-line .entropic-line-underlay",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-entropic-line .entropic-line-path"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-zipper-line .zipper-line-centre"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".constraint-double-arrow > circle"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll(
|
||||
".constraint-double-arrow .double-arrow-chevron",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector(".constraint-indexer--row circle"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-indexer--column rect"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-indexer--box rect"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-indexer--row text"),
|
||||
).toHaveTextContent("R");
|
||||
expect(
|
||||
container.querySelector(".constraint-indexer--column text"),
|
||||
).toHaveTextContent("C");
|
||||
expect(
|
||||
container.querySelector(".constraint-indexer--box text"),
|
||||
).toHaveTextContent("B");
|
||||
|
||||
const board = screen.getByRole("grid", { name: "6 by 6 Sudoku grid" });
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"False clue: modular line through row 1, column 1; row 1, column 2; row 1, column 3",
|
||||
),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"entropic line through row 2, column 1; row 2, column 2; row 2, column 3",
|
||||
),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining(
|
||||
"row indexer at row 5, column 1; the marker digit selects a row in the same column",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("masks fogged content and clues until a correct entry reveals them", () => {
|
||||
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
solution,
|
||||
constraints: [
|
||||
{ type: "fog", lights: [0], revealRadius: 1 },
|
||||
{ type: "even", cell: 10 },
|
||||
],
|
||||
});
|
||||
const pointerDown = vi.fn();
|
||||
const keyDown = vi.fn();
|
||||
const wrongValues = new Array<number>(16).fill(0);
|
||||
wrongValues[5] = 2;
|
||||
wrongValues[10] = 3;
|
||||
const { container, rerender } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={wrongValues}
|
||||
candidates={new Array<number>(16).fill(15)}
|
||||
selected={new Set([10])}
|
||||
highlighted={new Set([10])}
|
||||
conflicts={new Set([10])}
|
||||
activeCell={1}
|
||||
candidateOverlay={{
|
||||
activeValues: [4],
|
||||
candidateCells: [0, 10],
|
||||
links: [
|
||||
{
|
||||
id: "hidden-link",
|
||||
kind: "strong",
|
||||
a: { cell: 0, value: 4 },
|
||||
b: { cell: 10, value: 4 },
|
||||
contexts: [{ kind: "house", label: "Test" }],
|
||||
},
|
||||
],
|
||||
}}
|
||||
guidedHintOverlay={{
|
||||
focusCells: [10],
|
||||
placements: [{ cell: 10, value: 4 }],
|
||||
}}
|
||||
onCellPointerDown={pointerDown}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={keyDown}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hidden = screen.getByRole("gridcell", {
|
||||
name: "Row 3, column 3, obscured by fog",
|
||||
});
|
||||
expect(hidden).toBeDisabled();
|
||||
expect(hidden).toHaveClass("is-fogged");
|
||||
expect(hidden).not.toHaveClass(
|
||||
"is-selected",
|
||||
"has-conflict",
|
||||
"is-hint-focus",
|
||||
);
|
||||
expect(hidden).toHaveAccessibleDescription(
|
||||
"Obscured by Fog of War. This cell cannot be selected until revealed.",
|
||||
);
|
||||
expect(hidden).toBeEmptyDOMElement();
|
||||
expect(container.querySelector(".constraint-even")).not.toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".fog-constraint-mask rect"),
|
||||
).toHaveLength(12);
|
||||
expect(
|
||||
container.querySelector(".candidate-link-layer"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }),
|
||||
).not.toHaveAccessibleDescription(expect.stringContaining("even digit"));
|
||||
|
||||
fireEvent.pointerDown(hidden);
|
||||
expect(pointerDown).not.toHaveBeenCalled();
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
|
||||
);
|
||||
expect(pointerDown).toHaveBeenCalledWith(0, expect.anything());
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
|
||||
{ key: "ArrowRight" },
|
||||
);
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
|
||||
{ key: "a", ctrlKey: true },
|
||||
);
|
||||
expect(keyDown).not.toHaveBeenCalled();
|
||||
|
||||
const correctValues = new Array<number>(16).fill(0);
|
||||
correctValues[5] = 4;
|
||||
rerender(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={correctValues}
|
||||
selected={new Set()}
|
||||
activeCell={1}
|
||||
onCellPointerDown={pointerDown}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={keyDown}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 3, column 3, empty" }),
|
||||
).toHaveAccessibleDescription(expect.stringContaining("even digit"));
|
||||
expect(container.querySelector(".constraint-even")).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".fog-constraint-mask rect"),
|
||||
).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("reveals given cells and their Chebyshev neighbourhood under fog", () => {
|
||||
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
|
||||
const givens = new Array<number>(16).fill(0);
|
||||
givens[15] = 1;
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens,
|
||||
solution,
|
||||
constraints: [{ type: "fog", lights: [0], revealRadius: 1 }],
|
||||
});
|
||||
|
||||
const fogged = foggedCellsForPuzzle(puzzle, givens);
|
||||
expect(fogged.has(10)).toBe(false);
|
||||
expect(fogged.has(15)).toBe(false);
|
||||
expect(fogged.has(9)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders candidate filters and strong/weak graph overlays", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
@@ -164,6 +638,51 @@ describe("Sudoku board constraint visuals", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps guided effects distinct from focus cells and exposes the preview", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
});
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
centerMarks={[0, 15, 15, ...new Array<number>(13).fill(0)]}
|
||||
selected={new Set()}
|
||||
activeCell={0}
|
||||
guidedHintOverlay={{
|
||||
focusCells: [0, 1, 2],
|
||||
placements: [{ cell: 0, value: 4 }],
|
||||
eliminations: [{ cell: 1, values: [2, 3] }],
|
||||
}}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".is-hint-focus")).toHaveLength(3);
|
||||
expect(container.querySelectorAll(".is-hint-placement")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".is-hint-elimination")).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelector(".hint-placement-preview"),
|
||||
).toHaveTextContent("4");
|
||||
expect(
|
||||
container.querySelector(".hint-elimination-preview"),
|
||||
).toHaveTextContent("−23");
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
|
||||
).toHaveAccessibleDescription(
|
||||
expect.stringContaining("hint preview: place 4"),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
|
||||
).toHaveAccessibleDescription(
|
||||
expect.stringContaining("hint preview: remove 2, 3 from the candidates"),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes real row semantics and detailed per-cell state", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
@@ -215,4 +734,80 @@ describe("Sudoku board constraint visuals", () => {
|
||||
expect(annotated).toHaveAttribute("aria-invalid", "true");
|
||||
expect(annotated).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("offers off, concise and detailed screen-reader candidate output", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
});
|
||||
const baseProps = {
|
||||
puzzle,
|
||||
values: puzzle.givens,
|
||||
cornerMarks: [3, ...new Array<number>(15).fill(0)],
|
||||
centerMarks: [12, ...new Array<number>(15).fill(0)],
|
||||
selected: new Set([0]),
|
||||
activeCell: 0,
|
||||
onCellPointerDown: vi.fn(),
|
||||
onCellPointerEnter: vi.fn(),
|
||||
onKeyDown: vi.fn(),
|
||||
} as const;
|
||||
const { rerender } = render(
|
||||
<SudokuBoard {...baseProps} candidateVerbosity="detailed" />,
|
||||
);
|
||||
const cell = () =>
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" });
|
||||
|
||||
expect(cell()).toHaveAccessibleDescription(
|
||||
expect.stringContaining("corner notes 1, 2"),
|
||||
);
|
||||
expect(cell()).toHaveAccessibleDescription(
|
||||
expect.stringContaining("centre notes 3, 4"),
|
||||
);
|
||||
|
||||
rerender(<SudokuBoard {...baseProps} candidateVerbosity="concise" />);
|
||||
expect(cell()).toHaveAccessibleDescription(
|
||||
expect.stringContaining("2 corner notes"),
|
||||
);
|
||||
expect(cell()).toHaveAccessibleDescription(
|
||||
expect.stringContaining("2 centre notes"),
|
||||
);
|
||||
expect(cell()).not.toHaveAccessibleDescription(
|
||||
expect.stringContaining("corner notes 1, 2"),
|
||||
);
|
||||
|
||||
rerender(<SudokuBoard {...baseProps} candidateVerbosity="off" />);
|
||||
expect(cell()).not.toHaveAccessibleDescription(
|
||||
expect.stringMatching(/candidate|corner note|centre note/iu),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a non-colour pattern layer and names the pattern", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
});
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
colors={[1, ...new Array<number>(15).fill(0)]}
|
||||
selected={new Set()}
|
||||
activeCell={0}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector(".has-color-1 .cell-color-pattern"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
|
||||
).toHaveAccessibleDescription(
|
||||
expect.stringContaining("colour 1: red, diagonal stripes"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ 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() {}
|
||||
@@ -63,6 +65,37 @@ describe("Sudoku workbench", () => {
|
||||
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 />);
|
||||
|
||||
@@ -91,6 +124,94 @@ describe("Sudoku workbench", () => {
|
||||
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 />);
|
||||
@@ -257,8 +378,8 @@ describe("Sudoku workbench", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
|
||||
|
||||
await user.clear(screen.getByLabelText("Clue"));
|
||||
await user.type(screen.getByLabelText("Clue"), "6562");
|
||||
await user.clear(screen.getByLabelText("Sum"));
|
||||
await user.type(screen.getByLabelText("Sum"), "6562");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
||||
).toBeDisabled();
|
||||
@@ -375,4 +496,69 @@ describe("Sudoku workbench", () => {
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user