feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user