259 lines
8.0 KiB
TypeScript
259 lines
8.0 KiB
TypeScript
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,
|
||
type PreservedSudokuDocumentExtras,
|
||
} 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;
|
||
readonly preservedExtras?: PreservedSudokuDocumentExtras;
|
||
} = {},
|
||
) {
|
||
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}
|
||
preservedExtras={options.preservedExtras}
|
||
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.objectContaining({
|
||
source: { format: "sudokupad", id: "synthetic-local-test" },
|
||
}),
|
||
);
|
||
expect(onClose).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("previews safe visuals 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: [
|
||
{
|
||
center: [0.5, 0.5],
|
||
width: 1,
|
||
height: 1,
|
||
text: "visual only",
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
});
|
||
await user.click(
|
||
screen.getByRole("button", { name: "Check compatibility" }),
|
||
);
|
||
expect(
|
||
await screen.findByRole("region", { name: "Import mapping preview" }),
|
||
).toBeInTheDocument();
|
||
expect(screen.getByText("Preserved visuals")).toBeInTheDocument();
|
||
expect(screen.getByText("Text overlay: 1")).toBeInTheDocument();
|
||
|
||
fireEvent.change(input, {
|
||
target: { value: localScl({ overlays: [{ text: "missing center" }] }) },
|
||
});
|
||
await user.click(
|
||
screen.getByRole("button", { name: "Check compatibility" }),
|
||
);
|
||
expect(await screen.findByText(/center.*point/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 }),
|
||
{ source: { format: "sudoku-tools" } },
|
||
);
|
||
});
|
||
|
||
it("includes preserved source extras in SudokuPad exports", async () => {
|
||
const user = userEvent.setup();
|
||
let downloaded: Blob | undefined;
|
||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||
downloaded = value as Blob;
|
||
return "blob:scl-export-test";
|
||
});
|
||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||
renderDialog({
|
||
preservedExtras: {
|
||
source: { format: "sudokupad", id: "kept-source" },
|
||
metadata: { edition: "nightly" },
|
||
visuals: [
|
||
{
|
||
type: "text",
|
||
layer: "overlay",
|
||
position: { kind: "cell", cell: 0 },
|
||
text: "kept visual",
|
||
style: { fill: "#123456" },
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
await user.click(
|
||
screen.getByRole("button", { name: "Download SudokuPad JSON" }),
|
||
);
|
||
|
||
const exported = JSON.parse((await downloaded?.text()) ?? "{}") as {
|
||
metadata?: Record<string, unknown>;
|
||
overlays?: unknown[];
|
||
};
|
||
expect(exported.metadata?.edition).toBe("nightly");
|
||
expect(exported.overlays).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ text: "kept visual" }),
|
||
]),
|
||
);
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|