475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
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([]);
|
|
});
|
|
});
|