feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user