feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
@@ -0,0 +1,186 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ImportExportDialog } from "../../src/components/ImportExportDialog";
import { createEmptyPuzzle } from "../../src/domain";
import type { PuzzleDefinition } from "../../src/domain/types";
import { fromDomainPuzzle } from "../../src/formats";
import type { PortableAidMemoire } from "../../src/state/aidMemoire";
import { createSession } from "../../src/state/session";
const TEST_AID_MEMOIRE: PortableAidMemoire = {
version: 1,
enabled: true,
columns: 1,
cells: [
{
label: "A",
value: 2,
cornerMarks: [],
centerMarks: [],
color: 3,
},
],
};
function localScl(overrides: Record<string, unknown> = {}) {
const cells = Array.from({ length: 4 }, () =>
Array.from({ length: 4 }, () => ({})),
);
cells[0]![0] = { value: 1 };
return JSON.stringify({
id: "synthetic-local-test",
cells,
metadata: { title: "Synthetic local puzzle" },
...overrides,
});
}
function renderDialog(
options: {
readonly puzzle?: PuzzleDefinition;
readonly aidMemoire?: PortableAidMemoire;
} = {},
) {
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
const onClose = vi.fn();
const onImport = vi.fn();
render(
<ImportExportDialog
open
puzzle={puzzle}
session={createSession(puzzle.givens)}
aidMemoire={options.aidMemoire}
onClose={onClose}
onImport={onImport}
/>,
);
return { onClose, onImport };
}
describe("ImportExportDialog interoperability", () => {
it("reports a detected compatible format before importing it", async () => {
const user = userEvent.setup();
const { onClose, onImport } = renderDialog();
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
target: { value: localScl() },
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
expect(
await screen.findByText(
/SudokuPad\/CTC JSON: 4×4, 1 givens and 0 constraints/u,
),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Import locally" }));
expect(onImport).toHaveBeenCalledWith(
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
expect.any(Object),
);
expect(onClose).toHaveBeenCalledOnce();
});
it("reports unsupported constructs and never fetches short IDs", async () => {
const user = userEvent.setup();
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
renderDialog();
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
fireEvent.change(input, {
target: { value: localScl({ overlays: [{ text: "visual only" }] }) },
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument();
fireEvent.change(input, {
target: { value: "https://sudokupad.app/serverOnly42" },
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
expect(
await screen.findByText(/server-hosted SudokuPad puzzle ID/u),
).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("downloads the current puzzle as a self-contained SVG", async () => {
const user = userEvent.setup();
let downloaded: Blob | undefined;
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
downloaded = value as Blob;
return "blob:visual-export-test";
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
renderDialog();
await user.click(screen.getByRole("button", { name: "Download SVG" }));
expect(
await screen.findByText("SVG rendered and downloaded locally."),
).toBeInTheDocument();
expect(downloaded?.type).toContain("image/svg+xml");
expect(await downloaded?.text()).toContain("<svg");
});
it("round-trips aid-mémoire progress through project import and export", async () => {
const user = userEvent.setup();
const puzzle = createEmptyPuzzle(4);
const { onImport } = renderDialog({
puzzle,
aidMemoire: TEST_AID_MEMOIRE,
});
let downloaded: Blob | undefined;
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
downloaded = value as Blob;
return "blob:project-export-test";
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
await user.click(
screen.getByRole("button", { name: "Download project JSON" }),
);
expect(
await screen.findByText("Project JSON downloaded locally."),
).toBeInTheDocument();
expect(JSON.parse((await downloaded?.text()) ?? "{}").aidMemoire).toEqual(
TEST_AID_MEMOIRE,
);
const importedDocument = {
...fromDomainPuzzle(puzzle),
aidMemoire: TEST_AID_MEMOIRE,
};
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
target: { value: JSON.stringify(importedDocument) },
});
await user.click(screen.getByRole("button", { name: "Import locally" }));
expect(onImport).toHaveBeenCalledWith(
expect.objectContaining({ size: 4 }),
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
);
});
it("reports an f-puzzles export incompatibility without throwing", async () => {
const user = userEvent.setup();
renderDialog({
puzzle: {
...createEmptyPuzzle(4),
constraints: [{ type: "xv", a: 0, b: 1, total: 5, negated: true }],
},
});
await user.click(
screen.getByRole("button", { name: "Download f-puzzles JSON" }),
);
expect(
await screen.findByText(/cannot preserve an individually false clue/u),
).toBeInTheDocument();
});
});