280 lines
7.6 KiB
TypeScript
280 lines
7.6 KiB
TypeScript
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);
|
|
});
|
|
});
|