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 ( <> {JSON.stringify(puzzle)} ); } 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([]); }); });