test: expand coverage across jobs and tools (#380)

This commit is contained in:
SnapOtter
2026-06-29 22:06:24 +08:00
committed by GitHub
parent ef342c268b
commit fd6ebe77b5
22 changed files with 3692 additions and 0 deletions
@@ -0,0 +1,244 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("zustand/middleware", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, persist: (config: unknown) => config };
});
vi.stubGlobal("URL", {
...globalThis.URL,
revokeObjectURL: vi.fn(),
});
import { dashStyleToArray, hexToRgba, useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, SelectionState } from "@/types/editor";
const INITIAL_STATE = useEditorStore.getState();
function state() {
return useEditorStore.getState();
}
function makeRect(id: string, attrs: Record<string, number | string> = {}): CanvasObject {
return {
id,
type: "rect",
layerId: state().activeLayerId,
attrs: {
x: 10,
y: 20,
width: 30,
height: 40,
strokeWidth: 2,
rotation: 0,
...attrs,
},
} as CanvasObject;
}
function makeLine(id: string, points = [0, 0, 10, 10]): CanvasObject {
return {
id,
type: "line",
layerId: state().activeLayerId,
attrs: {
points,
strokeWidth: 2,
rotation: 0,
},
} as CanvasObject;
}
function makeEllipse(id: string): CanvasObject {
return {
id,
type: "ellipse",
layerId: state().activeLayerId,
attrs: {
x: 30,
y: 40,
radiusX: 10,
radiusY: 20,
rotation: 0,
},
} as CanvasObject;
}
describe("editor store branch helpers", () => {
beforeEach(() => {
useEditorStore.setState({ ...INITIAL_STATE }, true);
});
it("converts hex colors to rgba strings", () => {
expect(hexToRgba("#336699", 0.5)).toBe("rgba(51, 102, 153, 0.5)");
});
it("converts dash styles to canvas dash arrays", () => {
expect(dashStyleToArray("dashed", 3)).toEqual([12, 6]);
expect(dashStyleToArray("dotted", 3)).toEqual([3, 6]);
expect(dashStyleToArray("solid", 3)).toBeUndefined();
});
it("initializes crop bounds when entering crop mode and clears them when leaving", () => {
state().setTool("crop");
expect(state().cropState).toEqual({
x: 192,
y: 108,
width: 1536,
height: 864,
aspectRatio: null,
});
expect(state().isCropping).toBe(true);
state().setTool("move");
expect(state().cropState).toBeNull();
expect(state().isCropping).toBe(false);
});
it("resizes canvas from a bottom-right anchor by offsetting objects", () => {
state().addObject(makeRect("rect"));
state().addObject(makeLine("line"));
state().resizeCanvas(2000, 1100, "bottom-right", "#abcdef");
expect(state().canvasBackground).toBe("#abcdef");
expect(state().objects[0].attrs).toMatchObject({ x: 90, y: 40 });
expect((state().objects[1].attrs as { points: number[] }).points).toEqual([80, 20, 90, 30]);
});
it("rotates point and center-based objects for 90 and 270 degrees", () => {
useEditorStore.setState({
canvasSize: { width: 100, height: 50 },
objects: [makeLine("line"), makeEllipse("ellipse")],
});
state().rotateCanvas(90);
expect((state().objects[0].attrs as { points: number[] }).points).toEqual([50, 0, 40, 10]);
expect(state().objects[1].attrs).toMatchObject({
x: 10,
y: 30,
radiusX: 20,
radiusY: 10,
rotation: 90,
});
state().rotateCanvas(270);
expect((state().objects[0].attrs as { points: number[] }).points).toEqual([0, 0, 10, 10]);
expect(state().objects[1].attrs).toMatchObject({
x: 30,
y: 40,
radiusX: 10,
radiusY: 20,
rotation: 0,
});
});
it("flips point and center-based objects horizontally and vertically", () => {
useEditorStore.setState({
canvasSize: { width: 100, height: 80 },
objects: [makeLine("line"), makeEllipse("ellipse")],
});
state().flipCanvasHorizontal();
expect((state().objects[0].attrs as { points: number[] }).points).toEqual([100, 0, 90, 10]);
expect(state().objects[1].attrs).toMatchObject({ x: 70, rotation: 0 });
state().flipCanvasVertical();
expect((state().objects[0].attrs as { points: number[] }).points).toEqual([100, 80, 90, 70]);
expect(state().objects[1].attrs).toMatchObject({ y: 40, rotation: 0 });
});
it("inverts geometric selections and masked selections", () => {
useEditorStore.setState({ canvasSize: { width: 4, height: 3 } });
const geometricSelection: SelectionState = {
type: "rect",
bounds: { x: 1, y: 1, width: 2, height: 1 },
};
state().setSelection(geometricSelection);
state().invertSelection();
expect(state().selection?.bounds).toEqual({ x: 0, y: 0, width: 4, height: 3 });
expect(Array.from(state().selection?.mask ?? [])).toEqual([1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1]);
const maskedSelection: SelectionState = {
type: "wand",
bounds: { x: 1, y: 0, width: 2, height: 2 },
mask: new Uint8Array([1, 0, 0, 1]),
};
state().setSelection(maskedSelection);
state().invertSelection();
expect(Array.from(state().selection?.mask ?? [])).toEqual([1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1]);
});
it("ignores crop and clipboard operations when there is no state to apply", () => {
state().applyCrop();
state().cutObjects();
state().pasteObjects();
state().pasteInPlace();
expect(state().canvasSize).toEqual({ width: 1920, height: 1080 });
expect(state().objects).toEqual([]);
expect(state().clipboard).toBeNull();
});
it("pastes copied objects offset or in place onto the active layer", () => {
state().addObject(makeRect("rect"));
state().setSelectedObjects(["rect"]);
state().copyObjects();
state().pasteObjects();
const offsetPaste = state().objects[1];
expect(offsetPaste.id).not.toBe("rect");
expect(offsetPaste.layerId).toBe(state().activeLayerId);
expect(offsetPaste.attrs).toMatchObject({ x: 20, y: 30 });
expect(state().selectedObjectIds).toEqual([offsetPaste.id]);
state().pasteInPlace();
const inPlacePaste = state().objects[2];
expect(inPlacePaste.id).not.toBe("rect");
expect(inPlacePaste.attrs).toMatchObject({ x: 10, y: 20 });
expect(state().selectedObjectIds).toEqual([inPlacePaste.id]);
});
it("batch nudges positioned and point-based objects in one history entry", () => {
state().addObject(makeRect("rect"));
state().addObject(makeLine("line"));
const version = state()._historyVersion;
state().batchNudge(["rect", "line"], 5, -3);
expect(state().objects[0].attrs).toMatchObject({ x: 15, y: 17 });
expect((state().objects[1].attrs as { points: number[] }).points).toEqual([5, -3, 15, 7]);
expect(state().lastAction).toBe("Nudge");
expect(state()._historyVersion).toBe(version + 1);
});
it("clamps editor control ranges at their documented limits", () => {
state().setBrushSize(0);
state().setBrushOpacity(2);
state().setBrushHardness(-1);
state().setBrushFlow(2);
state().setShapeFillOpacity(-1);
state().setShapeStrokeOpacity(2);
state().setFillTolerance(999);
state().setGradientOpacity(-1);
state().setPixelBrushStrength(0);
expect(state()).toMatchObject({
brushSize: 1,
brushOpacity: 1,
brushHardness: 0,
brushFlow: 1,
shapeFillOpacity: 0,
shapeStrokeOpacity: 1,
fillTolerance: 255,
gradientOpacity: 0,
pixelBrushStrength: 1,
});
});
});
@@ -0,0 +1,140 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
const revokeObjectURL = vi.fn();
const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:fake-url");
vi.stubGlobal("URL", {
...globalThis.URL,
createObjectURL,
revokeObjectURL,
});
const imagePreviewMock = vi.hoisted(() => ({
needsServerPreview: vi.fn(() => false),
fetchDecodedPreview: vi.fn(() => Promise.resolve(null)),
}));
vi.mock("@/lib/image-preview", () => imagePreviewMock);
vi.mock("@/lib/analytics", () => ({
track: vi.fn(),
}));
import { previewKindFor, useFileStore } from "@/stores/file-store";
function makeFile(name: string, size = 1024, type = "image/png"): File {
const buf = new ArrayBuffer(size);
return new File([buf], name, { type });
}
describe("useFileStore branch coverage", () => {
beforeEach(() => {
useFileStore.getState().reset();
vi.clearAllMocks();
imagePreviewMock.needsServerPreview.mockReturnValue(false);
imagePreviewMock.fetchDecodedPreview.mockResolvedValue(null);
let urlCounter = 0;
createObjectURL.mockImplementation((_obj: Blob | MediaSource) => `blob:url-${++urlCounter}`);
});
it("maps unknown modalities to no preview", () => {
expect(previewKindFor("unknown" as never)).toBe("none");
});
it("removeFile is a no-op for missing indexes", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
const before = useFileStore.getState().entries;
revokeObjectURL.mockClear();
useFileStore.getState().removeFile(3);
expect(useFileStore.getState().entries).toBe(before);
expect(revokeObjectURL).not.toHaveBeenCalled();
});
it("removeFile revokes processed preview URLs", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().updateEntry(0, {
processedUrl: "blob:processed",
processedPreviewUrl: "blob:processed-preview",
});
revokeObjectURL.mockClear();
useFileStore.getState().removeFile(0);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:processed");
expect(revokeObjectURL).toHaveBeenCalledWith("blob:processed-preview");
});
it("setError stops processing only when an error is present", () => {
useFileStore.getState().setProcessing(true);
useFileStore.getState().setError(null);
expect(useFileStore.getState()).toMatchObject({ error: null, processing: true });
useFileStore.getState().setError("failed");
expect(useFileStore.getState()).toMatchObject({ error: "failed", processing: false });
});
it("setProcessedUrl and setSizes are no-ops without a selected entry", () => {
expect(() => useFileStore.getState().setProcessedUrl("blob:result")).not.toThrow();
expect(() => useFileStore.getState().setSizes(1, 2)).not.toThrow();
expect(useFileStore.getState().entries).toEqual([]);
expect(useFileStore.getState().processedUrl).toBeNull();
expect(useFileStore.getState().processedSize).toBeNull();
});
it("stores processed preview URLs on the selected entry", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().setProcessedUrl("blob:result", "blob:preview");
expect(useFileStore.getState().entries[0]).toMatchObject({
processedUrl: "blob:result",
processedPreviewUrl: "blob:preview",
processedFilename: null,
status: "completed",
});
expect(useFileStore.getState().processedPreviewUrl).toBe("blob:preview");
});
it("applies decoded previews only when the entry still contains the same file", async () => {
imagePreviewMock.needsServerPreview.mockReturnValue(true);
imagePreviewMock.fetchDecodedPreview.mockImplementation((file: File) =>
Promise.resolve(
file.name === "a.heic"
? { url: "blob:decoded-a", originalWidth: 640, originalHeight: 480 }
: { url: "blob:decoded-b", originalWidth: 320, originalHeight: 240 },
),
);
const firstFile = makeFile("a.heic", 100, "image/heic");
const replacementFile = makeFile("b.heic", 100, "image/heic");
useFileStore.getState().setFiles([firstFile]);
useFileStore.getState().setFiles([replacementFile]);
await vi.waitFor(() => {
expect(useFileStore.getState().entries[0].blobUrl).toBe("blob:decoded-b");
});
expect(useFileStore.getState().entries[0]).toMatchObject({
file: replacementFile,
originalWidth: 320,
originalHeight: 240,
previewLoading: false,
});
expect(useFileStore.getState().entries[0].blobUrl).not.toBe("blob:decoded-a");
});
it("clears previewLoading when decoded preview returns null", async () => {
imagePreviewMock.needsServerPreview.mockReturnValue(true);
imagePreviewMock.fetchDecodedPreview.mockResolvedValue(null);
useFileStore.getState().setFiles([makeFile("a.heic", 100, "image/heic")]);
await vi.waitFor(() => {
expect(useFileStore.getState().entries[0].previewLoading).toBe(false);
});
expect(useFileStore.getState().entries[0].blobUrl).toBe("blob:url-1");
});
});
@@ -0,0 +1,199 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api", () => ({
formatHeaders: vi.fn((headers: HeadersInit) => new Headers(headers)),
}));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
import { formatHeaders } from "@/lib/api";
import { useHtmlToImageStore } from "@/stores/html-to-image-store";
const DEFAULT_STATE = useHtmlToImageStore.getState();
function state() {
return useHtmlToImageStore.getState();
}
function okJson(data: unknown) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(data),
} as Response);
}
function failJson(data: unknown) {
return Promise.resolve({
ok: false,
json: () => Promise.resolve(data),
} as Response);
}
describe("useHtmlToImageStore", () => {
beforeEach(() => {
useHtmlToImageStore.setState({ ...DEFAULT_STATE }, true);
fetchMock.mockReset();
vi.mocked(formatHeaders).mockClear();
});
it("clears stale errors when switching input mode and editing input", () => {
useHtmlToImageStore.setState({ error: "old error" });
state().setMode("html");
expect(state().mode).toBe("html");
expect(state().error).toBeNull();
useHtmlToImageStore.setState({ error: "old error" });
state().setHtmlContent("<main>Test</main>");
expect(state().htmlContent).toBe("<main>Test</main>");
expect(state().error).toBeNull();
useHtmlToImageStore.setState({ error: "old error" });
state().setUrl("https://example.com");
expect(state().url).toBe("https://example.com");
expect(state().error).toBeNull();
});
it("updates capture settings without clearing unrelated state", () => {
state().setFormat("webp");
state().setQuality(82);
state().setFullPage(true);
state().setDevicePreset("custom");
state().setViewportWidth(390);
state().setViewportHeight(844);
expect(state()).toMatchObject({
format: "webp",
quality: 82,
fullPage: true,
devicePreset: "custom",
viewportWidth: 390,
viewportHeight: 844,
});
});
it("does not capture when the current mode has no input", async () => {
await state().capture();
expect(fetchMock).not.toHaveBeenCalled();
state().setMode("html");
state().setUrl("https://example.com");
await state().capture();
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not start a second capture while already capturing", async () => {
state().setUrl("https://example.com");
useHtmlToImageStore.setState({ capturing: true });
await state().capture();
expect(fetchMock).not.toHaveBeenCalled();
});
it("posts URL capture options and stores successful result metadata", async () => {
fetchMock.mockResolvedValueOnce(
await okJson({ downloadUrl: "/downloads/result.png", processedSize: 1234 }),
);
state().setUrl("https://example.com");
state().setFormat("jpg");
state().setQuality(75);
state().setFullPage(true);
state().setDevicePreset("mobile");
state().setViewportWidth(414);
state().setViewportHeight(896);
await state().capture();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/api/v1/tools/image/html-to-image");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
url: "https://example.com",
format: "jpg",
quality: 75,
fullPage: true,
devicePreset: "mobile",
viewportWidth: 414,
viewportHeight: 896,
});
expect(state().resultUrl).toBe("/downloads/result.png");
expect(state().resultSize).toBe(1234);
expect(state().capturing).toBe(false);
expect(state().error).toBeNull();
});
it("posts HTML content instead of URL in html mode", async () => {
fetchMock.mockResolvedValueOnce(await okJson({ downloadUrl: "/out.png", processedSize: 10 }));
state().setMode("html");
state().setHtmlContent("<h1>Hello</h1>");
await state().capture();
const options = fetchMock.mock.calls[0][1] as RequestInit;
expect(JSON.parse(options.body as string)).toMatchObject({
html: "<h1>Hello</h1>",
format: "png",
});
expect(JSON.parse(options.body as string)).not.toHaveProperty("url");
});
it("prefers details then error then fallback text for failed captures", async () => {
state().setUrl("https://example.com");
fetchMock.mockResolvedValueOnce(await failJson({ details: "Invalid URL" }));
await state().capture();
expect(state().error).toBe("Invalid URL");
fetchMock.mockResolvedValueOnce(await failJson({ error: "Timed out" }));
await state().capture();
expect(state().error).toBe("Timed out");
fetchMock.mockResolvedValueOnce(await failJson({}));
await state().capture();
expect(state().error).toBe("Capture failed");
});
it("stores network error messages and non-Error fallback text", async () => {
state().setUrl("https://example.com");
fetchMock.mockRejectedValueOnce(new Error("Network down"));
await state().capture();
expect(state().error).toBe("Network down");
expect(state().capturing).toBe(false);
fetchMock.mockRejectedValueOnce("offline");
await state().capture();
expect(state().error).toBe("Network error");
expect(state().capturing).toBe(false);
});
it("reset restores defaults after a completed capture", async () => {
fetchMock.mockResolvedValueOnce(await okJson({ downloadUrl: "/out.png", processedSize: 10 }));
state().setMode("html");
state().setHtmlContent("<p>Done</p>");
state().setQuality(40);
await state().capture();
state().reset();
expect(state()).toMatchObject({
mode: "url",
url: "",
htmlContent: "",
format: "png",
quality: 90,
fullPage: false,
devicePreset: "desktop",
viewportWidth: 1280,
viewportHeight: 720,
capturing: false,
resultUrl: null,
resultSize: null,
error: null,
});
});
});