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 (
<>
>
);
}
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();
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();
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();
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();
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();
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();
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(
,
);
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();
expect(
screen.getByRole("button", { name: "Entropic line" }),
).toBeDisabled();
unmount();
render(
,
);
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();
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();
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(
,
);
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([]);
});
});