Files

182 lines
6.3 KiB
TypeScript

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);
});
});