feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+72
View File
@@ -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();
});
});
+474
View File
@@ -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);
});
});
+181
View File
@@ -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);
});
});
+76 -4
View File
@@ -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" }),
]),
);
});
+109
View File
@@ -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",
]);
});
});
+30
View File
@@ -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);
});
});
+126
View File
@@ -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();
});
});
+279
View File
@@ -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);
});
});
+596 -1
View File
@@ -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"),
);
});
});
+188 -2
View File
@@ -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);
});
});