feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AidMemoire } from "../../src/components/AidMemoire";
|
||||
import {
|
||||
configureAidMemoire,
|
||||
createAidMemoire,
|
||||
type AidMemoireState,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import type { EntryMode } from "../../src/state/session";
|
||||
|
||||
function Harness({
|
||||
initial,
|
||||
readOnly = false,
|
||||
}: {
|
||||
initial?: AidMemoireState;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [state, setState] = useState(
|
||||
initial ?? createAidMemoire(9, { enabled: true }),
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [active, setActive] = useState(false);
|
||||
const [mode, setMode] = useState<EntryMode>("value");
|
||||
return (
|
||||
<AidMemoire
|
||||
size={9}
|
||||
state={state}
|
||||
selectedCell={selected}
|
||||
active={active}
|
||||
readOnly={readOnly}
|
||||
mode={mode}
|
||||
onStateChange={setState}
|
||||
onSelect={(cell) => {
|
||||
setSelected(cell);
|
||||
setActive(true);
|
||||
}}
|
||||
onMode={setMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("aid-mémoire component", () => {
|
||||
it("renders normal-like cells with keyboard entry and full descriptions", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const grid = screen.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
expect(within(grid).getAllByRole("row")).toHaveLength(1);
|
||||
const first = within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
});
|
||||
expect(first).toHaveAttribute("aria-colindex", "1");
|
||||
await user.click(first);
|
||||
await user.keyboard("4");
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 4",
|
||||
}),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const second = within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 2, label 2, empty",
|
||||
});
|
||||
second.focus();
|
||||
await user.keyboard("c7");
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 2, label 2, empty, centre marks 7",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Centre" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses row-aware WAI grid navigation without horizontal wrapping", () => {
|
||||
const initial = configureAidMemoire(
|
||||
createAidMemoire(9, { enabled: true }),
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
);
|
||||
render(<Harness initial={initial} />);
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
const rows = screen.getAllByRole("row");
|
||||
expect(rows[0]).toHaveAttribute("aria-rowindex", "1");
|
||||
expect(rows[2]).toHaveAttribute("aria-rowindex", "3");
|
||||
expect(cells[2]).toHaveAttribute("aria-colindex", "1");
|
||||
|
||||
cells[1]!.focus();
|
||||
fireEvent.keyDown(cells[1]!, { key: "ArrowRight" });
|
||||
expect(document.activeElement).toBe(cells[1]);
|
||||
fireEvent.keyDown(cells[1]!, { key: "Home" });
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
fireEvent.keyDown(cells[0]!, { key: "End", ctrlKey: true });
|
||||
expect(document.activeElement).toBe(cells[4]);
|
||||
fireEvent.keyDown(cells[4]!, { key: "PageUp" });
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
fireEvent.keyDown(cells[0]!, { key: "PageDown" });
|
||||
expect(document.activeElement).toBe(cells[4]);
|
||||
});
|
||||
|
||||
it("keeps read-only replay cells navigable without allowing edits", () => {
|
||||
const initial = configureAidMemoire(
|
||||
createAidMemoire(9, { enabled: true }),
|
||||
9,
|
||||
4,
|
||||
2,
|
||||
);
|
||||
render(<Harness initial={initial} readOnly />);
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
|
||||
cells[0]!.focus();
|
||||
fireEvent.keyDown(cells[0]!, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(cells[2]);
|
||||
fireEvent.keyDown(cells[2]!, { key: "5" });
|
||||
expect(cells[2]).toHaveAccessibleName("Aid-mémoire cell 3, label 3, empty");
|
||||
});
|
||||
|
||||
it("edits labels, reflows, clears entries and resets with confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(<Harness />);
|
||||
|
||||
const label = screen.getByLabelText("Selected cell label");
|
||||
await user.clear(label);
|
||||
await user.type(label, "Pseudo A");
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, empty",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Configure scratch layout"));
|
||||
await user.click(screen.getByRole("button", { name: "Compact grid" }));
|
||||
expect(screen.getByLabelText("Columns")).toHaveValue(3);
|
||||
await user.click(screen.getByRole("button", { name: "Clear entries" }));
|
||||
await user.click(screen.getByRole("button", { name: "Reset all" }));
|
||||
expect(window.confirm).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByLabelText("Columns")).toHaveValue(9);
|
||||
expect(screen.getByLabelText("Selected cell label")).toHaveValue("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
class WorkerStub {
|
||||
addEventListener() {}
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("Worker", WorkerStub);
|
||||
});
|
||||
|
||||
function mainDigit(name: string) {
|
||||
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
||||
"button",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
describe("aid-mémoire Workbench integration", () => {
|
||||
it("routes the shared keypad, undo and redo to a selected scratch cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
const toggle = screen.getByLabelText("Show aid-mémoire scratch cells");
|
||||
await user.click(toggle);
|
||||
|
||||
const grid = screen.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
await user.click(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
}),
|
||||
);
|
||||
await user.click(mainDigit("5"));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Redo" }));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.queryByRole("grid", { name: "9 aid-mémoire scratch cells" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores scratch labels and entries from the local Library", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
|
||||
await user.clear(screen.getByLabelText("Selected cell label"));
|
||||
await user.type(screen.getByLabelText("Selected cell label"), "Pseudo A");
|
||||
await user.click(mainDigit("6"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Library" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save current" }));
|
||||
expect(
|
||||
await screen.findByText("Current puzzle and progress saved locally."),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(mainDigit("7"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, value 7",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Library" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /A first classic/u }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores scratch state with a savepoint and reverses that restore", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
|
||||
await user.click(mainDigit("5"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.type(screen.getByLabelText("Savepoint name"), "Scratch five");
|
||||
await user.click(screen.getByRole("button", { name: "Save current grid" }));
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(mainDigit("6"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CandidateLab } from "../../src/components/CandidateLab";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
import type { CandidateOverlay } from "../../src/helpers";
|
||||
|
||||
function mask(...values: number[]): number {
|
||||
return values.reduce((result, value) => result | (1 << (value - 1)), 0);
|
||||
}
|
||||
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
|
||||
describe("candidate lab", () => {
|
||||
it("inspects selected cells, filters digits and focuses a link overlay", async () => {
|
||||
const user = userEvent.setup();
|
||||
const overlays: (CandidateOverlay | undefined)[] = [];
|
||||
const candidateMasks = new Array<number>(16).fill(0);
|
||||
candidateMasks[0] = mask(1, 2);
|
||||
candidateMasks[1] = mask(1, 2);
|
||||
|
||||
render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={[0]}
|
||||
onOverlayChange={(overlay) => overlays.push(overlay)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selected = screen.getByRole("heading", {
|
||||
name: "Selected cells",
|
||||
}).parentElement!;
|
||||
expect(within(selected).getByText("r1c1")).toBeInTheDocument();
|
||||
expect(within(selected).getByText("1 2")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 1" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(overlays.at(-1)?.activeValues).toEqual([1]);
|
||||
expect(overlays.at(-1)?.candidateCells).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
const link = screen.getByRole("button", { name: /r1c1\(1\).*r1c2\(1\)/u });
|
||||
await user.click(link);
|
||||
await waitFor(() => expect(overlays.at(-1)?.links).toHaveLength(1));
|
||||
expect(link).toHaveAttribute("aria-pressed", "true");
|
||||
await user.click(screen.getByRole("button", { name: "Show all" }));
|
||||
expect(link).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("reveals weak links on demand within a chosen house", async () => {
|
||||
const user = userEvent.setup();
|
||||
const candidateMasks = new Array<number>(16).fill(0);
|
||||
candidateMasks[0] = mask(3);
|
||||
candidateMasks[1] = mask(3);
|
||||
candidateMasks[2] = mask(3);
|
||||
|
||||
render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={[0]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Candidate house scope"),
|
||||
"unit:0",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 3" }),
|
||||
);
|
||||
expect(screen.getByText(/No links match this scope/u)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /weak · Row 1/u }),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("clears the board overlay when it unmounts", async () => {
|
||||
const onOverlayChange = vi.fn();
|
||||
const { unmount } = render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={new Array<number>(16).fill(0)}
|
||||
selectedCells={[0]}
|
||||
onOverlayChange={onOverlayChange}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(onOverlayChange).toHaveBeenCalled());
|
||||
unmount();
|
||||
expect(onOverlayChange).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
class WorkerStub {
|
||||
addEventListener() {}
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("Worker", WorkerStub);
|
||||
});
|
||||
|
||||
function digitButton(name: string) {
|
||||
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
||||
"button",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
describe("gameplay history integration", () => {
|
||||
it("restores savepoints and preserves discarded hypotheses for replay", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 3, empty",
|
||||
}),
|
||||
{ buttons: 1 },
|
||||
);
|
||||
await user.click(digitButton("2"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Branches, savepoints and replay",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Savepoint name"), "After two");
|
||||
await user.click(screen.getByRole("button", { name: "Save current grid" }));
|
||||
expect(screen.getByText("After two")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(digitButton("3"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.type(screen.getByLabelText("Hypothesis name"), "Try three");
|
||||
await user.click(screen.getByRole("button", { name: "Start hypothesis" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Hypothesis: Try three" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(digitButton("3"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Hypothesis: Try three" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Discard and return" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
expect(screen.getByText("discarded")).toBeInTheDocument();
|
||||
await user.click(
|
||||
within(screen.getByRole("list", { name: "Solve history" })).getAllByRole(
|
||||
"button",
|
||||
{ name: /Set r1c3 to 3/u },
|
||||
)[0]!,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Set r1c3 to 3" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Return to live grid" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -59,4 +59,42 @@ describe("Sudoku generator workspace", () => {
|
||||
);
|
||||
expect(onRate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("submits a bounded evidence-backed practice target", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGenerate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
onGenerate={onGenerate}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Practice technique"),
|
||||
"x-wing",
|
||||
);
|
||||
const attempts = screen.getByLabelText("Mining attempts");
|
||||
await user.clear(attempts);
|
||||
await user.type(attempts, "7");
|
||||
await user.clear(screen.getByLabelText("Seed"));
|
||||
await user.type(screen.getByLabelText("Seed"), "x-wing-training");
|
||||
await user.click(screen.getByRole("button", { name: "Generate Classic" }));
|
||||
|
||||
expect(onGenerate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requiredTechnique: "x-wing",
|
||||
maxTechniqueAttempts: 7,
|
||||
seed: "x-wing-training",
|
||||
}),
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "killer");
|
||||
expect(
|
||||
within(screen.getByLabelText("Practice technique")).getByRole("option", {
|
||||
name: "Killer Cage",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HelpersWorkspace } from "../../src/components/HelpersWorkspace";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
|
||||
function mask(...digits: number[]): number {
|
||||
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
|
||||
}
|
||||
|
||||
describe("Sum Lab helper", () => {
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(9));
|
||||
|
||||
it("eliminates and restores combinations without changing the inputs", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<HelpersWorkspace
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(81).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Generalized Sum Lab" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("4", { selector: "strong" })).toBeInTheDocument();
|
||||
|
||||
const combination = screen.getByRole("button", {
|
||||
name: "1 + 9; eliminate",
|
||||
});
|
||||
await user.click(combination);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "1 + 9; restore" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(
|
||||
screen.getByText("3", { selector: ".metric-row strong" }).parentElement,
|
||||
).toHaveTextContent("3 active of 4");
|
||||
expect(screen.getByRole("button", { name: "Restore all" })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Restore all" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "1 + 9; eliminate" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("filters through selected board-cell candidate masks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const candidateMasks = Array<number>(81).fill(0);
|
||||
candidateMasks[0] = mask(1, 2);
|
||||
candidateMasks[1] = mask(8, 9);
|
||||
render(
|
||||
<HelpersWorkspace
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[0, 1]}
|
||||
candidateMasks={candidateMasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Use 2 board cells and candidates",
|
||||
}),
|
||||
);
|
||||
|
||||
const positions = screen.getByRole("list");
|
||||
expect(within(positions).getByText("r1c1: 1 2")).toBeInTheDocument();
|
||||
expect(within(positions).getByText("r1c2: 8 9")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "3 + 7; eliminate" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets digit bounds when the puzzle size changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<HelpersWorkspace
|
||||
key={9}
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(81).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum digit" }),
|
||||
).toHaveValue(9);
|
||||
|
||||
const smallerPuzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
rerender(
|
||||
<HelpersWorkspace
|
||||
key={4}
|
||||
size={4}
|
||||
puzzle={smallerPuzzle}
|
||||
values={smallerPuzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(16).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum digit" }),
|
||||
).toHaveValue(4);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
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 type { PortableAidMemoire } from "../../src/state/aidMemoire";
|
||||
import { createSession } from "../../src/state/session";
|
||||
|
||||
const TEST_AID_MEMOIRE: PortableAidMemoire = {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: [
|
||||
{
|
||||
label: "A",
|
||||
value: 2,
|
||||
cornerMarks: [],
|
||||
centerMarks: [],
|
||||
color: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function localScl(overrides: Record<string, unknown> = {}) {
|
||||
const cells = Array.from({ length: 4 }, () =>
|
||||
Array.from({ length: 4 }, () => ({})),
|
||||
);
|
||||
cells[0]![0] = { value: 1 };
|
||||
return JSON.stringify({
|
||||
id: "synthetic-local-test",
|
||||
cells,
|
||||
metadata: { title: "Synthetic local puzzle" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
options: {
|
||||
readonly puzzle?: PuzzleDefinition;
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
} = {},
|
||||
) {
|
||||
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
|
||||
const onClose = vi.fn();
|
||||
const onImport = vi.fn();
|
||||
render(
|
||||
<ImportExportDialog
|
||||
open
|
||||
puzzle={puzzle}
|
||||
session={createSession(puzzle.givens)}
|
||||
aidMemoire={options.aidMemoire}
|
||||
onClose={onClose}
|
||||
onImport={onImport}
|
||||
/>,
|
||||
);
|
||||
return { onClose, onImport };
|
||||
}
|
||||
|
||||
describe("ImportExportDialog interoperability", () => {
|
||||
it("reports a detected compatible format before importing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose, onImport } = renderDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
||||
target: { value: localScl() },
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/SudokuPad\/CTC JSON: 4×4, 1 givens and 0 constraints/u,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports unsupported constructs and never fetches short IDs", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
renderDialog();
|
||||
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: localScl({ overlays: [{ text: "visual only" }] }) },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: "https://sudokupad.app/serverOnly42" },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/server-hosted SudokuPad puzzle ID/u),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("downloads the current puzzle as a self-contained SVG", async () => {
|
||||
const user = userEvent.setup();
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:visual-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Download SVG" }));
|
||||
expect(
|
||||
await screen.findByText("SVG rendered and downloaded locally."),
|
||||
).toBeInTheDocument();
|
||||
expect(downloaded?.type).toContain("image/svg+xml");
|
||||
expect(await downloaded?.text()).toContain("<svg");
|
||||
});
|
||||
|
||||
it("round-trips aid-mémoire progress through project import and export", async () => {
|
||||
const user = userEvent.setup();
|
||||
const puzzle = createEmptyPuzzle(4);
|
||||
const { onImport } = renderDialog({
|
||||
puzzle,
|
||||
aidMemoire: TEST_AID_MEMOIRE,
|
||||
});
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:project-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download project JSON" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("Project JSON downloaded locally."),
|
||||
).toBeInTheDocument();
|
||||
expect(JSON.parse((await downloaded?.text()) ?? "{}").aidMemoire).toEqual(
|
||||
TEST_AID_MEMOIRE,
|
||||
);
|
||||
|
||||
const importedDocument = {
|
||||
...fromDomainPuzzle(puzzle),
|
||||
aidMemoire: TEST_AID_MEMOIRE,
|
||||
};
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
||||
target: { value: JSON.stringify(importedDocument) },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4 }),
|
||||
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an f-puzzles export incompatibility without throwing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog({
|
||||
puzzle: {
|
||||
...createEmptyPuzzle(4),
|
||||
constraints: [{ type: "xv", a: 0, b: 1, total: 5, negated: true }],
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download f-puzzles JSON" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/cannot preserve an individually false clue/u),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SolveWorkspace } from "../../src/components/SolveWorkspace";
|
||||
import type { ExactSolveResult } from "../../src/solver";
|
||||
|
||||
const limited: ExactSolveResult = {
|
||||
solutions: [],
|
||||
count: 0,
|
||||
truncated: true,
|
||||
limitReason: "timeout",
|
||||
nodes: 100,
|
||||
elapsedMs: 10_000,
|
||||
};
|
||||
|
||||
describe("solve result reporting", () => {
|
||||
it("does not report zero solutions at a safety limit as unsatisfiable", () => {
|
||||
const props = {
|
||||
size: 9,
|
||||
busy: false,
|
||||
onLogical: vi.fn(),
|
||||
onExact: vi.fn(),
|
||||
onApplyValues: vi.fn(),
|
||||
onFocusCells: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<SolveWorkspace {...props} exact={limited} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/solvability is not established/u),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("No completion satisfies every supported rule."),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SolveWorkspace
|
||||
{...props}
|
||||
exact={{ ...limited, truncated: false, limitReason: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No completion satisfies every supported rule."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizePuzzle } from "../../src/domain";
|
||||
import { SudokuBoard } from "../../src/components/SudokuBoard";
|
||||
@@ -41,4 +41,178 @@ describe("Sudoku board constraint visuals", () => {
|
||||
container.querySelector(".constraint-inequality-tip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders false local clues and combines dual-purpose outside labels", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
constraints: [
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
{ type: "thermo", cells: [8, 9], negated: true },
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 3],
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "top",
|
||||
index: 0,
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "top",
|
||||
index: 0,
|
||||
count: 42,
|
||||
negated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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(".sudoku-board-frame")).toHaveClass(
|
||||
"has-outside-clues",
|
||||
);
|
||||
expect(container.querySelectorAll(".constraint-outside")).toHaveLength(1);
|
||||
expect(container.querySelector(".outside-clue-kinds")).toHaveTextContent(
|
||||
"Σ · ▥",
|
||||
);
|
||||
expect(container.querySelector(".outside-clue-value")).toHaveTextContent(
|
||||
"≠42",
|
||||
);
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(4);
|
||||
expect(
|
||||
container.querySelector(".constraint-thermo.is-negated polyline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-thermo .constraint-false-mark"),
|
||||
).toHaveTextContent("≠");
|
||||
|
||||
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("False clue: X-sum 42 from the top, column 1."),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("False clue: maximum at row 2, column 2."),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders candidate filters and strong/weak graph overlays", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
});
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set([0])}
|
||||
activeCell={0}
|
||||
candidateOverlay={{
|
||||
activeValues: [1],
|
||||
candidateCells: [0, 1],
|
||||
links: [
|
||||
{
|
||||
id: "strong",
|
||||
kind: "strong",
|
||||
a: { cell: 0, value: 1 },
|
||||
b: { cell: 1, value: 1 },
|
||||
contexts: [{ kind: "house", label: "Row 1" }],
|
||||
},
|
||||
{
|
||||
id: "weak",
|
||||
kind: "weak",
|
||||
a: { cell: 0, value: 2 },
|
||||
b: { cell: 0, value: 3 },
|
||||
contexts: [{ kind: "cell", label: "Cell r1c1" }],
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelectorAll(".is-candidate-highlighted"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll(".candidate-overlay-link--strong"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelectorAll(".candidate-overlay-link--weak"),
|
||||
).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".candidate-overlay-node")).toHaveLength(
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes real row semantics and detailed per-cell state", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
constraints: [{ type: "killer-cage", cells: [0, 1], sum: 3 }],
|
||||
});
|
||||
render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
cornerMarks={[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
centerMarks={new Array<number>(16).fill(0)}
|
||||
candidates={[0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
colors={[0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
selected={new Set([1])}
|
||||
conflicts={new Set([1])}
|
||||
activeCell={1}
|
||||
showCandidates
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
|
||||
expect(board).toHaveAttribute("aria-rowcount", "4");
|
||||
expect(board).toHaveAttribute("aria-colcount", "4");
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("Home and End move to the first and last cell"),
|
||||
);
|
||||
expect(screen.getAllByRole("row")).toHaveLength(4);
|
||||
const given = screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 1",
|
||||
});
|
||||
expect(given).toHaveAttribute("aria-readonly", "true");
|
||||
expect(given).toHaveAccessibleDescription(
|
||||
expect.stringMatching(/region 1.*given digit.*killer cage 3/i),
|
||||
);
|
||||
const annotated = screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 2, empty",
|
||||
});
|
||||
expect(annotated).toHaveAccessibleDescription(
|
||||
expect.stringMatching(
|
||||
/region 1.*corner notes 1.*automatic candidates 1, 2.*colour 2.*conflict/i,
|
||||
),
|
||||
);
|
||||
expect(annotated).toHaveAttribute("aria-colindex", "2");
|
||||
expect(annotated).toHaveAttribute("aria-invalid", "true");
|
||||
expect(annotated).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,34 @@ describe("Sudoku workbench", () => {
|
||||
expect(undo).toBeDisabled();
|
||||
});
|
||||
|
||||
it("supports non-wrapping WAI-ARIA grid navigation", () => {
|
||||
render(<Workbench />);
|
||||
|
||||
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
const cell = (row: number, column: number) =>
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: new RegExp(`^Row ${String(row)}, column ${String(column)},`, "u"),
|
||||
});
|
||||
|
||||
cell(1, 1).focus();
|
||||
fireEvent.keyDown(cell(1, 1), { key: "ArrowLeft" });
|
||||
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
|
||||
|
||||
fireEvent.keyDown(cell(1, 1), { key: "End" });
|
||||
expect(cell(1, 9)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(1, 9), { key: "ArrowRight" });
|
||||
expect(cell(1, 9)).toHaveAttribute("tabindex", "0");
|
||||
|
||||
fireEvent.keyDown(cell(1, 9), { key: "ArrowDown" });
|
||||
expect(cell(2, 9)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(2, 9), { key: "Home" });
|
||||
expect(cell(2, 1)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(2, 1), { key: "PageDown" });
|
||||
expect(cell(9, 1)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(9, 1), { key: "Home", ctrlKey: true });
|
||||
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
@@ -124,6 +152,30 @@ describe("Sudoku workbench", () => {
|
||||
screen.getByRole("button", { name: "Remove selected cage" }),
|
||||
);
|
||||
expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Cage digits may repeat"));
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.clear(cageSum);
|
||||
await user.type(cageSum, "2");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace cage" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("false · 2 cage · 2 cells · repeats allowed"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Cage digits may repeat"));
|
||||
await user.clear(cageSum);
|
||||
await user.type(cageSum, "3");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace cage" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("false · 2 cage · 2 cells · repeats allowed"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("false · 3 cage · 2 cells")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the expanded built-in variant examples", async () => {
|
||||
@@ -157,6 +209,66 @@ describe("Sudoku workbench", () => {
|
||||
expect(
|
||||
container.querySelector(".constraint-xv--10 text"),
|
||||
).toHaveTextContent("10");
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Open built-in puzzle"),
|
||||
"truth-and-lies",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Truth and lies" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".constraint-outside")).toHaveLength(4);
|
||||
expect(container.querySelectorAll(".constraint-quadruple")).toHaveLength(2);
|
||||
expect(container.querySelectorAll(".constraint-maximum")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sets and batch-toggles false local and outside clues", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Workbench />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New blank" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add quadruple" }),
|
||||
).toBeDisabled();
|
||||
await user.clear(screen.getByLabelText("Quadruple digits"));
|
||||
await user.type(screen.getByLabelText("Quadruple digits"), "1");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add quadruple" }),
|
||||
).toBeDisabled();
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Maximum cell" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("false · maximum · r1c1")).toBeInTheDocument();
|
||||
expect(screen.getByText("false · Σ3 · top 1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Require true for false · maximum · r1c1",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove false · Σ3 · top 1",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
|
||||
|
||||
await user.clear(screen.getByLabelText("Clue"));
|
||||
await user.type(screen.getByLabelText("Clue"), "6562");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Make all clues true" }),
|
||||
);
|
||||
expect(screen.getByText("maximum · r1c1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Σ3 · top 1")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("switches between setting, solving, playing and helper workspaces", async () => {
|
||||
@@ -206,6 +318,38 @@ describe("Sudoku workbench", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("projects candidate filters and links onto the board only while inspecting", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Workbench />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Helpers" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Candidate links" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Links and houses" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
|
||||
expect(
|
||||
container.querySelector(".candidate-link-layer"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 1" }),
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll(".sudoku-cell.is-candidate-highlighted")
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(
|
||||
container.querySelector(".candidate-link-layer"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".sudoku-cell.is-candidate-highlighted"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("makes the import boundary and self-contained local sharing explicit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user