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 { 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(); 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(); expect(screen.getByText("Naked Pair")).toBeInTheDocument(); expect(screen.queryByText(step.explanation)).not.toBeInTheDocument(); expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument(); rerender(); expect(screen.getByText(step.explanation)).toBeInTheDocument(); expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument(); rerender(); 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( , ); 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(); 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( , ); 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); }); });