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 { 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(); 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(); 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( , ); 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(); 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); }); });