mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Fix installAll tests timing out by returning installed status from refreshBundles mock (prevents infinite retry loop) and increasing waitFor/test timeouts to accommodate the 2s inter-install delay - Fix zustand-stores installAll test with same refreshBundles mock fix - Icon-map test already fixed to derive from shared constants
2875 lines
96 KiB
TypeScript
2875 lines
96 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Bypass zustand persist middleware — it captures window.localStorage at import
|
|
// time before jsdom's storage is reliably available in the vitest fork pool.
|
|
vi.mock("zustand/middleware", async (importOriginal) => {
|
|
const actual: Record<string, unknown> = await importOriginal();
|
|
return {
|
|
...actual,
|
|
persist: (config: unknown) => config,
|
|
};
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const revokeObjectURL = vi.fn();
|
|
const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:fake-url");
|
|
|
|
vi.stubGlobal("URL", {
|
|
...globalThis.URL,
|
|
createObjectURL,
|
|
revokeObjectURL,
|
|
});
|
|
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const storageMap = new Map<string, string>();
|
|
vi.stubGlobal("localStorage", {
|
|
getItem: vi.fn((key: string) => storageMap.get(key) ?? null),
|
|
setItem: vi.fn((key: string, val: string) => storageMap.set(key, val)),
|
|
removeItem: vi.fn((key: string) => storageMap.delete(key)),
|
|
clear: vi.fn(() => storageMap.clear()),
|
|
get length() {
|
|
return storageMap.size;
|
|
},
|
|
key: vi.fn((_i: number) => null),
|
|
});
|
|
|
|
// Mock matchMedia for theme store
|
|
vi.stubGlobal(
|
|
"matchMedia",
|
|
vi.fn().mockReturnValue({
|
|
matches: false,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
}),
|
|
);
|
|
|
|
// ==========================================================================
|
|
// Base64Store
|
|
// ==========================================================================
|
|
|
|
import type { Base64Error, Base64Result } from "@/stores/base64-store";
|
|
import { useBase64Store } from "@/stores/base64-store";
|
|
|
|
describe("useBase64Store", () => {
|
|
beforeEach(() => {
|
|
useBase64Store.getState().reset();
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useBase64Store.getState();
|
|
expect(s.results).toEqual([]);
|
|
expect(s.errors).toEqual([]);
|
|
expect(s.processing).toBe(false);
|
|
expect(s.progress).toBeNull();
|
|
expect(s.expandedIndex).toBe(0);
|
|
});
|
|
|
|
it("setResults stores results and errors, resets expandedIndex", () => {
|
|
useBase64Store.getState().setExpandedIndex(3);
|
|
const results: Base64Result[] = [
|
|
{
|
|
filename: "a.png",
|
|
mimeType: "image/png",
|
|
width: 100,
|
|
height: 100,
|
|
originalSize: 1024,
|
|
encodedSize: 1365,
|
|
overheadPercent: 33.3,
|
|
base64: "abc",
|
|
dataUri: "data:image/png;base64,abc",
|
|
},
|
|
];
|
|
const errors: Base64Error[] = [{ filename: "b.png", error: "too big" }];
|
|
useBase64Store.getState().setResults(results, errors);
|
|
|
|
const s = useBase64Store.getState();
|
|
expect(s.results).toEqual(results);
|
|
expect(s.errors).toEqual(errors);
|
|
expect(s.expandedIndex).toBe(0);
|
|
});
|
|
|
|
it("setProcessing updates processing flag", () => {
|
|
useBase64Store.getState().setProcessing(true);
|
|
expect(useBase64Store.getState().processing).toBe(true);
|
|
useBase64Store.getState().setProcessing(false);
|
|
expect(useBase64Store.getState().processing).toBe(false);
|
|
});
|
|
|
|
it("setProgress updates progress", () => {
|
|
const progress = { completed: 2, total: 5, currentFile: "c.png" };
|
|
useBase64Store.getState().setProgress(progress);
|
|
expect(useBase64Store.getState().progress).toEqual(progress);
|
|
|
|
useBase64Store.getState().setProgress(null);
|
|
expect(useBase64Store.getState().progress).toBeNull();
|
|
});
|
|
|
|
it("addResult appends to results array", () => {
|
|
const r1: Base64Result = {
|
|
filename: "a.png",
|
|
mimeType: "image/png",
|
|
width: 10,
|
|
height: 10,
|
|
originalSize: 100,
|
|
encodedSize: 133,
|
|
overheadPercent: 33,
|
|
base64: "x",
|
|
dataUri: "data:image/png;base64,x",
|
|
};
|
|
const r2: Base64Result = { ...r1, filename: "b.png" };
|
|
|
|
useBase64Store.getState().addResult(r1);
|
|
expect(useBase64Store.getState().results).toHaveLength(1);
|
|
|
|
useBase64Store.getState().addResult(r2);
|
|
expect(useBase64Store.getState().results).toHaveLength(2);
|
|
expect(useBase64Store.getState().results[1].filename).toBe("b.png");
|
|
});
|
|
|
|
it("addError appends to errors array", () => {
|
|
useBase64Store.getState().addError({ filename: "x.png", error: "fail" });
|
|
useBase64Store.getState().addError({ filename: "y.png", error: "crash" });
|
|
expect(useBase64Store.getState().errors).toHaveLength(2);
|
|
});
|
|
|
|
it("setExpandedIndex updates expanded index", () => {
|
|
useBase64Store.getState().setExpandedIndex(5);
|
|
expect(useBase64Store.getState().expandedIndex).toBe(5);
|
|
});
|
|
|
|
it("reset clears all state to initial values", () => {
|
|
useBase64Store.getState().setProcessing(true);
|
|
useBase64Store.getState().setProgress({ completed: 1, total: 2, currentFile: "f" });
|
|
useBase64Store.getState().addResult({
|
|
filename: "a.png",
|
|
mimeType: "image/png",
|
|
width: 1,
|
|
height: 1,
|
|
originalSize: 1,
|
|
encodedSize: 1,
|
|
overheadPercent: 0,
|
|
base64: "",
|
|
dataUri: "",
|
|
});
|
|
useBase64Store.getState().addError({ filename: "b.png", error: "err" });
|
|
useBase64Store.getState().setExpandedIndex(2);
|
|
|
|
useBase64Store.getState().reset();
|
|
const s = useBase64Store.getState();
|
|
expect(s.results).toEqual([]);
|
|
expect(s.errors).toEqual([]);
|
|
expect(s.processing).toBe(false);
|
|
expect(s.progress).toBeNull();
|
|
expect(s.expandedIndex).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// DuplicateStore
|
|
// ==========================================================================
|
|
|
|
import type { DuplicateResult } from "@/stores/duplicate-store";
|
|
import { useDuplicateStore } from "@/stores/duplicate-store";
|
|
|
|
describe("useDuplicateStore", () => {
|
|
beforeEach(() => {
|
|
useDuplicateStore.getState().reset();
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useDuplicateStore.getState();
|
|
expect(s.results).toBeNull();
|
|
expect(s.scanning).toBe(false);
|
|
expect(s.viewMode).toBe("overview");
|
|
expect(s.selectedGroupIndex).toBe(0);
|
|
expect(s.bestOverrides).toEqual({});
|
|
});
|
|
|
|
it("setResults stores result and resets view state", () => {
|
|
useDuplicateStore.getState().setViewMode("detail");
|
|
useDuplicateStore.getState().setSelectedGroup(3);
|
|
useDuplicateStore.getState().overrideBest(0, 2);
|
|
|
|
const result: DuplicateResult = {
|
|
totalImages: 10,
|
|
uniqueImages: 7,
|
|
spaceSaveable: 1024,
|
|
duplicateGroups: [
|
|
{
|
|
groupId: 1,
|
|
files: [
|
|
{
|
|
filename: "a.png",
|
|
similarity: 0.99,
|
|
width: 100,
|
|
height: 100,
|
|
fileSize: 512,
|
|
format: "png",
|
|
isBest: true,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
useDuplicateStore.getState().setResults(result);
|
|
|
|
const s = useDuplicateStore.getState();
|
|
expect(s.results).toEqual(result);
|
|
expect(s.viewMode).toBe("overview");
|
|
expect(s.selectedGroupIndex).toBe(0);
|
|
expect(s.bestOverrides).toEqual({});
|
|
});
|
|
|
|
it("setResults with null clears results", () => {
|
|
useDuplicateStore.getState().setResults({
|
|
totalImages: 1,
|
|
uniqueImages: 1,
|
|
spaceSaveable: 0,
|
|
duplicateGroups: [],
|
|
});
|
|
useDuplicateStore.getState().setResults(null);
|
|
expect(useDuplicateStore.getState().results).toBeNull();
|
|
});
|
|
|
|
it("setScanning updates scanning flag", () => {
|
|
useDuplicateStore.getState().setScanning(true);
|
|
expect(useDuplicateStore.getState().scanning).toBe(true);
|
|
});
|
|
|
|
it("setViewMode changes view mode", () => {
|
|
useDuplicateStore.getState().setViewMode("detail");
|
|
expect(useDuplicateStore.getState().viewMode).toBe("detail");
|
|
});
|
|
|
|
it("setSelectedGroup sets index and switches to detail view", () => {
|
|
useDuplicateStore.getState().setSelectedGroup(2);
|
|
expect(useDuplicateStore.getState().selectedGroupIndex).toBe(2);
|
|
expect(useDuplicateStore.getState().viewMode).toBe("detail");
|
|
});
|
|
|
|
it("overrideBest accumulates overrides per group", () => {
|
|
useDuplicateStore.getState().overrideBest(0, 1);
|
|
useDuplicateStore.getState().overrideBest(2, 3);
|
|
const overrides = useDuplicateStore.getState().bestOverrides;
|
|
expect(overrides[0]).toBe(1);
|
|
expect(overrides[2]).toBe(3);
|
|
});
|
|
|
|
it("overrideBest replaces previous override for same group", () => {
|
|
useDuplicateStore.getState().overrideBest(0, 1);
|
|
useDuplicateStore.getState().overrideBest(0, 2);
|
|
expect(useDuplicateStore.getState().bestOverrides[0]).toBe(2);
|
|
});
|
|
|
|
it("reset clears everything", () => {
|
|
useDuplicateStore.getState().setScanning(true);
|
|
useDuplicateStore.getState().setViewMode("detail");
|
|
useDuplicateStore.getState().setSelectedGroup(5);
|
|
useDuplicateStore.getState().overrideBest(1, 2);
|
|
|
|
useDuplicateStore.getState().reset();
|
|
const s = useDuplicateStore.getState();
|
|
expect(s.results).toBeNull();
|
|
expect(s.scanning).toBe(false);
|
|
expect(s.viewMode).toBe("overview");
|
|
expect(s.selectedGroupIndex).toBe(0);
|
|
expect(s.bestOverrides).toEqual({});
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// PipelineStore
|
|
// ==========================================================================
|
|
|
|
import { usePipelineStore } from "@/stores/pipeline-store";
|
|
|
|
describe("usePipelineStore", () => {
|
|
beforeEach(() => {
|
|
usePipelineStore.getState().reset();
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = usePipelineStore.getState();
|
|
expect(s.steps).toEqual([]);
|
|
expect(s.expandedStepId).toBeNull();
|
|
expect(s.savedPipelines).toEqual([]);
|
|
});
|
|
|
|
it("addStep adds a step with generated ID and expands it", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
const s = usePipelineStore.getState();
|
|
expect(s.steps).toHaveLength(1);
|
|
expect(s.steps[0].toolId).toBe("resize");
|
|
expect(s.steps[0].settings).toEqual({});
|
|
expect(s.steps[0].id).toBeTruthy();
|
|
expect(s.expandedStepId).toBe(s.steps[0].id);
|
|
});
|
|
|
|
it("addStep appends additional steps", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
usePipelineStore.getState().addStep("compress");
|
|
const s = usePipelineStore.getState();
|
|
expect(s.steps).toHaveLength(2);
|
|
expect(s.steps[0].toolId).toBe("resize");
|
|
expect(s.steps[1].toolId).toBe("compress");
|
|
expect(s.expandedStepId).toBe(s.steps[1].id);
|
|
});
|
|
|
|
it("removeStep removes the step by id", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
usePipelineStore.getState().addStep("compress");
|
|
const firstId = usePipelineStore.getState().steps[0].id;
|
|
usePipelineStore.getState().removeStep(firstId);
|
|
expect(usePipelineStore.getState().steps).toHaveLength(1);
|
|
expect(usePipelineStore.getState().steps[0].toolId).toBe("compress");
|
|
});
|
|
|
|
it("removeStep clears expandedStepId if removed step was expanded", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
const id = usePipelineStore.getState().steps[0].id;
|
|
expect(usePipelineStore.getState().expandedStepId).toBe(id);
|
|
|
|
usePipelineStore.getState().removeStep(id);
|
|
expect(usePipelineStore.getState().expandedStepId).toBeNull();
|
|
});
|
|
|
|
it("removeStep preserves expandedStepId if different step removed", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
usePipelineStore.getState().addStep("compress");
|
|
const compressId = usePipelineStore.getState().steps[1].id;
|
|
const resizeId = usePipelineStore.getState().steps[0].id;
|
|
usePipelineStore.getState().setExpandedStep(compressId);
|
|
|
|
usePipelineStore.getState().removeStep(resizeId);
|
|
expect(usePipelineStore.getState().expandedStepId).toBe(compressId);
|
|
});
|
|
|
|
it("reorderSteps swaps two steps", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
usePipelineStore.getState().addStep("compress");
|
|
usePipelineStore.getState().addStep("convert");
|
|
const ids = usePipelineStore.getState().steps.map((s) => s.id);
|
|
|
|
usePipelineStore.getState().reorderSteps(ids[0], ids[2]);
|
|
const steps = usePipelineStore.getState().steps;
|
|
expect(steps[0].toolId).toBe("compress");
|
|
expect(steps[1].toolId).toBe("convert");
|
|
expect(steps[2].toolId).toBe("resize");
|
|
});
|
|
|
|
it("reorderSteps no-ops for invalid ids", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
usePipelineStore.getState().reorderSteps("nonexistent", "also-nonexistent");
|
|
expect(usePipelineStore.getState().steps).toHaveLength(1);
|
|
});
|
|
|
|
it("updateStepSettings merges settings into the step", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
const id = usePipelineStore.getState().steps[0].id;
|
|
usePipelineStore.getState().updateStepSettings(id, { width: 800, height: 600 });
|
|
expect(usePipelineStore.getState().steps[0].settings).toEqual({ width: 800, height: 600 });
|
|
});
|
|
|
|
it("updateStepSettings replaces settings entirely", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
const id = usePipelineStore.getState().steps[0].id;
|
|
usePipelineStore.getState().updateStepSettings(id, { width: 800 });
|
|
usePipelineStore.getState().updateStepSettings(id, { height: 600 });
|
|
expect(usePipelineStore.getState().steps[0].settings).toEqual({ height: 600 });
|
|
});
|
|
|
|
it("setExpandedStep updates the expanded step", () => {
|
|
usePipelineStore.getState().setExpandedStep("some-id");
|
|
expect(usePipelineStore.getState().expandedStepId).toBe("some-id");
|
|
usePipelineStore.getState().setExpandedStep(null);
|
|
expect(usePipelineStore.getState().expandedStepId).toBeNull();
|
|
});
|
|
|
|
it("loadSteps replaces all steps with new ones and clears expanded", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
usePipelineStore.getState().loadSteps([
|
|
{ toolId: "compress", settings: { quality: 80 } },
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
]);
|
|
|
|
const s = usePipelineStore.getState();
|
|
expect(s.steps).toHaveLength(2);
|
|
expect(s.steps[0].toolId).toBe("compress");
|
|
expect(s.steps[0].settings).toEqual({ quality: 80 });
|
|
expect(s.steps[1].toolId).toBe("convert");
|
|
expect(s.expandedStepId).toBeNull();
|
|
});
|
|
|
|
it("setSavedPipelines stores the pipeline list", () => {
|
|
const pipelines = [
|
|
{
|
|
id: "p1",
|
|
name: "My Pipeline",
|
|
description: "Test",
|
|
steps: [{ toolId: "resize", settings: {} }],
|
|
createdAt: "2024-01-01",
|
|
},
|
|
];
|
|
usePipelineStore.getState().setSavedPipelines(pipelines);
|
|
expect(usePipelineStore.getState().savedPipelines).toEqual(pipelines);
|
|
});
|
|
|
|
it("reset clears all state", () => {
|
|
usePipelineStore.getState().addStep("resize");
|
|
usePipelineStore.getState().setSavedPipelines([
|
|
{
|
|
id: "p1",
|
|
name: "P",
|
|
description: null,
|
|
steps: [],
|
|
createdAt: "",
|
|
},
|
|
]);
|
|
|
|
usePipelineStore.getState().reset();
|
|
const s = usePipelineStore.getState();
|
|
expect(s.steps).toEqual([]);
|
|
expect(s.expandedStepId).toBeNull();
|
|
expect(s.savedPipelines).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// QrStore
|
|
// ==========================================================================
|
|
|
|
import { encodeQrData, useQrStore } from "@/stores/qr-store";
|
|
|
|
describe("useQrStore", () => {
|
|
beforeEach(() => {
|
|
useQrStore.getState().reset();
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useQrStore.getState();
|
|
expect(s.contentType).toBe("url");
|
|
expect(s.textData).toBe("");
|
|
expect(s.dotType).toBe("rounded");
|
|
expect(s.dotColor).toBe("#000000");
|
|
expect(s.bgColor).toBe("#FFFFFF");
|
|
expect(s.bgTransparent).toBe(false);
|
|
expect(s.size).toBe(1000);
|
|
expect(s.errorCorrection).toBe("Q");
|
|
expect(s.downloadFormat).toBe("png");
|
|
expect(s.logoFile).toBeNull();
|
|
expect(s.logoDataUrl).toBeNull();
|
|
expect(s.logoSize).toBe(0.4);
|
|
expect(s.hideBackgroundDots).toBe(true);
|
|
expect(s.wifiData).toEqual({
|
|
ssid: "",
|
|
password: "",
|
|
encryption: "WPA",
|
|
hidden: false,
|
|
});
|
|
});
|
|
|
|
it("setContentType changes content type", () => {
|
|
useQrStore.getState().setContentType("wifi");
|
|
expect(useQrStore.getState().contentType).toBe("wifi");
|
|
});
|
|
|
|
it("setTextData updates text data", () => {
|
|
useQrStore.getState().setTextData("https://example.com");
|
|
expect(useQrStore.getState().textData).toBe("https://example.com");
|
|
});
|
|
|
|
it("setWifiData merges partial wifi data", () => {
|
|
useQrStore.getState().setWifiData({ ssid: "MyNetwork" });
|
|
const wifi = useQrStore.getState().wifiData;
|
|
expect(wifi.ssid).toBe("MyNetwork");
|
|
expect(wifi.encryption).toBe("WPA"); // unchanged
|
|
|
|
useQrStore.getState().setWifiData({ password: "secret123" });
|
|
expect(useQrStore.getState().wifiData.ssid).toBe("MyNetwork");
|
|
expect(useQrStore.getState().wifiData.password).toBe("secret123");
|
|
});
|
|
|
|
it("setVcardData merges partial vcard data", () => {
|
|
useQrStore.getState().setVcardData({ firstName: "John", lastName: "Doe" });
|
|
const vcard = useQrStore.getState().vcardData;
|
|
expect(vcard.firstName).toBe("John");
|
|
expect(vcard.lastName).toBe("Doe");
|
|
expect(vcard.phone).toBe(""); // unchanged
|
|
});
|
|
|
|
it("setEmailData merges partial email data", () => {
|
|
useQrStore.getState().setEmailData({ to: "a@b.com", subject: "Hi" });
|
|
expect(useQrStore.getState().emailData.to).toBe("a@b.com");
|
|
expect(useQrStore.getState().emailData.subject).toBe("Hi");
|
|
expect(useQrStore.getState().emailData.body).toBe(""); // unchanged
|
|
});
|
|
|
|
it("setSmsData merges partial sms data", () => {
|
|
useQrStore.getState().setSmsData({ phone: "+1234567890" });
|
|
expect(useQrStore.getState().smsData.phone).toBe("+1234567890");
|
|
expect(useQrStore.getState().smsData.message).toBe("");
|
|
});
|
|
|
|
it("setDotColor updates dot color", () => {
|
|
useQrStore.getState().setDotColor("#FF0000");
|
|
expect(useQrStore.getState().dotColor).toBe("#FF0000");
|
|
});
|
|
|
|
it("setBgTransparent toggles background transparency", () => {
|
|
useQrStore.getState().setBgTransparent(true);
|
|
expect(useQrStore.getState().bgTransparent).toBe(true);
|
|
});
|
|
|
|
it("setSize updates size", () => {
|
|
useQrStore.getState().setSize(500);
|
|
expect(useQrStore.getState().size).toBe(500);
|
|
});
|
|
|
|
it("setErrorCorrection updates error correction level", () => {
|
|
useQrStore.getState().setErrorCorrection("H");
|
|
expect(useQrStore.getState().errorCorrection).toBe("H");
|
|
});
|
|
|
|
it("setDownloadFormat updates download format", () => {
|
|
useQrStore.getState().setDownloadFormat("svg");
|
|
expect(useQrStore.getState().downloadFormat).toBe("svg");
|
|
});
|
|
|
|
it("setLogoFile with null clears logo data", () => {
|
|
useQrStore.getState().setLogoFile(null);
|
|
expect(useQrStore.getState().logoFile).toBeNull();
|
|
expect(useQrStore.getState().logoDataUrl).toBeNull();
|
|
});
|
|
|
|
it("setLogoFile with a File reads it as a data URL", async () => {
|
|
const file = new File(["fake-png-data"], "logo.png", { type: "image/png" });
|
|
useQrStore.getState().setLogoFile(file);
|
|
await vi.waitFor(() => {
|
|
expect(useQrStore.getState().logoDataUrl).not.toBeNull();
|
|
});
|
|
const s = useQrStore.getState();
|
|
expect(s.logoFile).toBe(file);
|
|
expect(s.logoDataUrl).toMatch(/^data:/);
|
|
});
|
|
|
|
it("setLogoFile(null) after a file clears both fields", async () => {
|
|
const file = new File(["fake-png-data"], "logo.png", { type: "image/png" });
|
|
useQrStore.getState().setLogoFile(file);
|
|
await vi.waitFor(() => {
|
|
expect(useQrStore.getState().logoDataUrl).not.toBeNull();
|
|
});
|
|
useQrStore.getState().setLogoFile(null);
|
|
expect(useQrStore.getState().logoFile).toBeNull();
|
|
expect(useQrStore.getState().logoDataUrl).toBeNull();
|
|
});
|
|
|
|
it("reset restores all defaults", () => {
|
|
useQrStore.getState().setContentType("wifi");
|
|
useQrStore.getState().setTextData("changed");
|
|
useQrStore.getState().setDotColor("#FF0000");
|
|
useQrStore.getState().setSize(500);
|
|
useQrStore.getState().setWifiData({ ssid: "test" });
|
|
|
|
useQrStore.getState().reset();
|
|
const s = useQrStore.getState();
|
|
expect(s.contentType).toBe("url");
|
|
expect(s.textData).toBe("");
|
|
expect(s.dotColor).toBe("#000000");
|
|
expect(s.size).toBe(1000);
|
|
expect(s.wifiData.ssid).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("encodeQrData", () => {
|
|
it("encodes url/text content type as plain text", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "url" as const,
|
|
textData: "https://example.com",
|
|
};
|
|
expect(encodeQrData(state)).toBe("https://example.com");
|
|
});
|
|
|
|
it("encodes text content type as plain text", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "text" as const,
|
|
textData: "Hello World",
|
|
};
|
|
expect(encodeQrData(state)).toBe("Hello World");
|
|
});
|
|
|
|
it("encodes wifi data in WIFI: format", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "wifi" as const,
|
|
wifiData: { ssid: "MyNet", password: "pass123", encryption: "WPA" as const, hidden: false },
|
|
};
|
|
expect(encodeQrData(state)).toBe("WIFI:T:WPA;S:MyNet;P:pass123;H:false;;");
|
|
});
|
|
|
|
it("encodes vcard data in vCard 3.0 format", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "vcard" as const,
|
|
vcardData: {
|
|
firstName: "John",
|
|
lastName: "Doe",
|
|
phone: "+1234567890",
|
|
email: "john@example.com",
|
|
organization: "Acme",
|
|
title: "Engineer",
|
|
url: "https://johndoe.com",
|
|
},
|
|
};
|
|
const result = encodeQrData(state);
|
|
expect(result).toContain("BEGIN:VCARD");
|
|
expect(result).toContain("VERSION:3.0");
|
|
expect(result).toContain("N:Doe;John");
|
|
expect(result).toContain("FN:John Doe");
|
|
expect(result).toContain("TEL:+1234567890");
|
|
expect(result).toContain("EMAIL:john@example.com");
|
|
expect(result).toContain("ORG:Acme");
|
|
expect(result).toContain("TITLE:Engineer");
|
|
expect(result).toContain("URL:https://johndoe.com");
|
|
expect(result).toContain("END:VCARD");
|
|
});
|
|
|
|
it("encodes vcard without optional fields", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "vcard" as const,
|
|
vcardData: {
|
|
firstName: "Jane",
|
|
lastName: "",
|
|
phone: "",
|
|
email: "",
|
|
organization: "",
|
|
title: "",
|
|
url: "",
|
|
},
|
|
};
|
|
const result = encodeQrData(state);
|
|
expect(result).toContain("FN:Jane");
|
|
expect(result).not.toContain("TEL:");
|
|
expect(result).not.toContain("EMAIL:");
|
|
});
|
|
|
|
it("encodes email data as mailto URI", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "email" as const,
|
|
emailData: { to: "test@example.com", subject: "Hello World", body: "Hi there" },
|
|
};
|
|
const result = encodeQrData(state);
|
|
expect(result).toContain("mailto:test@example.com");
|
|
expect(result).toContain("subject=Hello%20World");
|
|
expect(result).toContain("body=Hi%20there");
|
|
});
|
|
|
|
it("encodes email without subject/body", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "email" as const,
|
|
emailData: { to: "plain@test.com", subject: "", body: "" },
|
|
};
|
|
expect(encodeQrData(state)).toBe("mailto:plain@test.com");
|
|
});
|
|
|
|
it("encodes phone data as tel URI", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "phone" as const,
|
|
phoneData: "+1234567890",
|
|
};
|
|
expect(encodeQrData(state)).toBe("tel:+1234567890");
|
|
});
|
|
|
|
it("encodes sms data as smsto URI", () => {
|
|
const state = {
|
|
...useQrStore.getState(),
|
|
contentType: "sms" as const,
|
|
smsData: { phone: "+1234567890", message: "Hey!" },
|
|
};
|
|
expect(encodeQrData(state)).toBe("smsto:+1234567890:Hey!");
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// SplitStore
|
|
// ==========================================================================
|
|
|
|
import { useSplitStore } from "@/stores/split-store";
|
|
|
|
describe("useSplitStore", () => {
|
|
beforeEach(() => {
|
|
revokeObjectURL.mockClear();
|
|
useSplitStore.setState({
|
|
mode: "grid",
|
|
columns: 3,
|
|
rows: 3,
|
|
tileWidth: 200,
|
|
tileHeight: 200,
|
|
outputFormat: "original",
|
|
quality: 90,
|
|
imageDimensions: null,
|
|
processing: false,
|
|
error: null,
|
|
tiles: [],
|
|
zipBlobUrl: null,
|
|
});
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useSplitStore.getState();
|
|
expect(s.mode).toBe("grid");
|
|
expect(s.columns).toBe(3);
|
|
expect(s.rows).toBe(3);
|
|
expect(s.tileWidth).toBe(200);
|
|
expect(s.tileHeight).toBe(200);
|
|
expect(s.outputFormat).toBe("original");
|
|
expect(s.quality).toBe(90);
|
|
expect(s.imageDimensions).toBeNull();
|
|
expect(s.processing).toBe(false);
|
|
expect(s.error).toBeNull();
|
|
expect(s.tiles).toEqual([]);
|
|
expect(s.zipBlobUrl).toBeNull();
|
|
});
|
|
|
|
it("setMode changes mode and clears tiles/zip/error", () => {
|
|
useSplitStore.getState().setTiles([
|
|
{
|
|
row: 0,
|
|
col: 0,
|
|
label: "1",
|
|
width: 100,
|
|
height: 100,
|
|
blobUrl: null,
|
|
},
|
|
]);
|
|
useSplitStore.getState().setMode("tile-size");
|
|
const s = useSplitStore.getState();
|
|
expect(s.mode).toBe("tile-size");
|
|
expect(s.tiles).toEqual([]);
|
|
expect(s.error).toBeNull();
|
|
});
|
|
|
|
it("setColumns clamps to 1-100 range", () => {
|
|
useSplitStore.getState().setColumns(0);
|
|
expect(useSplitStore.getState().columns).toBe(1);
|
|
|
|
useSplitStore.getState().setColumns(150);
|
|
expect(useSplitStore.getState().columns).toBe(100);
|
|
|
|
useSplitStore.getState().setColumns(5);
|
|
expect(useSplitStore.getState().columns).toBe(5);
|
|
});
|
|
|
|
it("setRows clamps to 1-100 range", () => {
|
|
useSplitStore.getState().setRows(-1);
|
|
expect(useSplitStore.getState().rows).toBe(1);
|
|
|
|
useSplitStore.getState().setRows(200);
|
|
expect(useSplitStore.getState().rows).toBe(100);
|
|
});
|
|
|
|
it("setTileWidth clamps to minimum 10", () => {
|
|
useSplitStore.getState().setTileWidth(5);
|
|
expect(useSplitStore.getState().tileWidth).toBe(10);
|
|
|
|
useSplitStore.getState().setTileWidth(300);
|
|
expect(useSplitStore.getState().tileWidth).toBe(300);
|
|
});
|
|
|
|
it("setTileHeight clamps to minimum 10", () => {
|
|
useSplitStore.getState().setTileHeight(1);
|
|
expect(useSplitStore.getState().tileHeight).toBe(10);
|
|
});
|
|
|
|
it("setZipBlobUrl revokes previous URL", () => {
|
|
useSplitStore.setState({ zipBlobUrl: "blob:old-zip" });
|
|
revokeObjectURL.mockClear();
|
|
|
|
useSplitStore.getState().setZipBlobUrl("blob:new-zip");
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:old-zip");
|
|
expect(useSplitStore.getState().zipBlobUrl).toBe("blob:new-zip");
|
|
});
|
|
|
|
it("applyPreset sets mode to grid with given cols/rows", () => {
|
|
useSplitStore.getState().applyPreset(4, 4);
|
|
const s = useSplitStore.getState();
|
|
expect(s.mode).toBe("grid");
|
|
expect(s.columns).toBe(4);
|
|
expect(s.rows).toBe(4);
|
|
expect(s.tiles).toEqual([]);
|
|
});
|
|
|
|
it("getEffectiveGrid returns columns/rows in grid mode", () => {
|
|
useSplitStore.getState().setColumns(5);
|
|
useSplitStore.getState().setRows(3);
|
|
const grid = useSplitStore.getState().getEffectiveGrid();
|
|
expect(grid).toEqual({ columns: 5, rows: 3 });
|
|
});
|
|
|
|
it("getEffectiveGrid computes from tile size in tile-size mode", () => {
|
|
useSplitStore.getState().setMode("tile-size");
|
|
useSplitStore.getState().setTileWidth(100);
|
|
useSplitStore.getState().setTileHeight(50);
|
|
useSplitStore.getState().setImageDimensions({ width: 300, height: 200 });
|
|
const grid = useSplitStore.getState().getEffectiveGrid();
|
|
expect(grid).toEqual({ columns: 3, rows: 4 });
|
|
});
|
|
|
|
it("getTileCount returns columns * rows", () => {
|
|
useSplitStore.getState().setColumns(4);
|
|
useSplitStore.getState().setRows(3);
|
|
expect(useSplitStore.getState().getTileCount()).toBe(12);
|
|
});
|
|
|
|
it("getComputedTileDimensions returns null without image dimensions", () => {
|
|
expect(useSplitStore.getState().getComputedTileDimensions()).toBeNull();
|
|
});
|
|
|
|
it("getComputedTileDimensions computes tile size in grid mode", () => {
|
|
useSplitStore.getState().setImageDimensions({ width: 900, height: 600 });
|
|
useSplitStore.getState().setColumns(3);
|
|
useSplitStore.getState().setRows(2);
|
|
const dims = useSplitStore.getState().getComputedTileDimensions();
|
|
expect(dims).toEqual({ width: 300, height: 300 });
|
|
});
|
|
|
|
it("getComputedTileDimensions returns tile size in tile-size mode", () => {
|
|
useSplitStore.getState().setMode("tile-size");
|
|
useSplitStore.getState().setTileWidth(150);
|
|
useSplitStore.getState().setTileHeight(75);
|
|
useSplitStore.getState().setImageDimensions({ width: 600, height: 300 });
|
|
const dims = useSplitStore.getState().getComputedTileDimensions();
|
|
expect(dims).toEqual({ width: 150, height: 75 });
|
|
});
|
|
|
|
it("reset revokes zip and tile blob URLs", () => {
|
|
useSplitStore.setState({
|
|
zipBlobUrl: "blob:zip",
|
|
tiles: [
|
|
{ row: 0, col: 0, label: "1", width: 100, height: 100, blobUrl: "blob:tile1" },
|
|
{ row: 0, col: 1, label: "2", width: 100, height: 100, blobUrl: null },
|
|
],
|
|
});
|
|
revokeObjectURL.mockClear();
|
|
|
|
useSplitStore.getState().reset();
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:zip");
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:tile1");
|
|
expect(useSplitStore.getState().tiles).toEqual([]);
|
|
expect(useSplitStore.getState().zipBlobUrl).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// ThemeStore
|
|
// ==========================================================================
|
|
|
|
import { useThemeStore } from "@/stores/theme-store";
|
|
|
|
describe("useThemeStore", () => {
|
|
beforeEach(() => {
|
|
useThemeStore.setState({ theme: "light", resolvedTheme: "light" });
|
|
localStorage.removeItem("snapotter-theme-user-set");
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useThemeStore.getState();
|
|
expect(s.theme).toBe("light");
|
|
expect(s.resolvedTheme).toBe("light");
|
|
});
|
|
|
|
it("setTheme to dark updates theme and resolvedTheme", () => {
|
|
useThemeStore.getState().setTheme("dark");
|
|
const s = useThemeStore.getState();
|
|
expect(s.theme).toBe("dark");
|
|
expect(s.resolvedTheme).toBe("dark");
|
|
});
|
|
|
|
it("setTheme to light updates theme and resolvedTheme", () => {
|
|
useThemeStore.getState().setTheme("dark");
|
|
useThemeStore.getState().setTheme("light");
|
|
const s = useThemeStore.getState();
|
|
expect(s.theme).toBe("light");
|
|
expect(s.resolvedTheme).toBe("light");
|
|
});
|
|
|
|
it("setTheme to system resolves based on matchMedia", () => {
|
|
useThemeStore.getState().setTheme("system");
|
|
const s = useThemeStore.getState();
|
|
expect(s.theme).toBe("system");
|
|
// matchMedia mock returns matches: false, so system resolves to "light"
|
|
expect(s.resolvedTheme).toBe("light");
|
|
});
|
|
|
|
it("setTheme sets user-set flag in localStorage", () => {
|
|
useThemeStore.getState().setTheme("dark");
|
|
expect(localStorage.getItem("snapotter-theme-user-set")).toBe("1");
|
|
});
|
|
|
|
it("applyServerDefault applies theme when no user-set flag", () => {
|
|
useThemeStore.getState().applyServerDefault("dark");
|
|
const s = useThemeStore.getState();
|
|
expect(s.theme).toBe("dark");
|
|
expect(s.resolvedTheme).toBe("dark");
|
|
});
|
|
|
|
it("applyServerDefault skips when user-set flag exists", () => {
|
|
useThemeStore.getState().setTheme("light");
|
|
useThemeStore.getState().applyServerDefault("dark");
|
|
const s = useThemeStore.getState();
|
|
expect(s.theme).toBe("light");
|
|
expect(s.resolvedTheme).toBe("light");
|
|
});
|
|
|
|
it("applyServerDefault does not set user-set flag", () => {
|
|
useThemeStore.getState().applyServerDefault("dark");
|
|
expect(localStorage.getItem("snapotter-theme-user-set")).toBeNull();
|
|
});
|
|
|
|
it("system theme resolves to dark when matchMedia prefers dark", () => {
|
|
const originalMatchMedia = globalThis.matchMedia;
|
|
vi.stubGlobal(
|
|
"matchMedia",
|
|
vi.fn().mockReturnValue({
|
|
matches: true,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
}),
|
|
);
|
|
useThemeStore.getState().setTheme("system");
|
|
const s = useThemeStore.getState();
|
|
expect(s.theme).toBe("system");
|
|
expect(s.resolvedTheme).toBe("dark");
|
|
vi.stubGlobal("matchMedia", originalMatchMedia);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// PdfToImageStore (synchronous state operations only)
|
|
// ==========================================================================
|
|
|
|
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
|
|
|
|
describe("usePdfToImageStore", () => {
|
|
beforeEach(() => {
|
|
usePdfToImageStore.getState().reset();
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = usePdfToImageStore.getState();
|
|
expect(s.file).toBeNull();
|
|
expect(s.pageCount).toBeNull();
|
|
expect(s.thumbnails).toEqual([]);
|
|
expect(s.format).toBe("png");
|
|
expect(s.dpi).toBe(150);
|
|
expect(s.customDpi).toBe(false);
|
|
expect(s.quality).toBe(85);
|
|
expect(s.colorMode).toBe("color");
|
|
expect(s.pages).toBe("");
|
|
expect(s.selectedPages.size).toBe(0);
|
|
expect(s.processing).toBe(false);
|
|
expect(s.loadingPreview).toBe(false);
|
|
expect(s.error).toBeNull();
|
|
expect(s.results).toBeNull();
|
|
expect(s.zipUrl).toBeNull();
|
|
expect(s.zipSize).toBeNull();
|
|
});
|
|
|
|
it("setFormat updates format", () => {
|
|
usePdfToImageStore.getState().setFormat("jpg");
|
|
expect(usePdfToImageStore.getState().format).toBe("jpg");
|
|
});
|
|
|
|
it("setDpi updates dpi", () => {
|
|
usePdfToImageStore.getState().setDpi(300);
|
|
expect(usePdfToImageStore.getState().dpi).toBe(300);
|
|
});
|
|
|
|
it("setCustomDpi updates customDpi flag", () => {
|
|
usePdfToImageStore.getState().setCustomDpi(true);
|
|
expect(usePdfToImageStore.getState().customDpi).toBe(true);
|
|
});
|
|
|
|
it("setQuality updates quality", () => {
|
|
usePdfToImageStore.getState().setQuality(50);
|
|
expect(usePdfToImageStore.getState().quality).toBe(50);
|
|
});
|
|
|
|
it("setColorMode updates color mode", () => {
|
|
usePdfToImageStore.getState().setColorMode("grayscale");
|
|
expect(usePdfToImageStore.getState().colorMode).toBe("grayscale");
|
|
});
|
|
|
|
it("setFile with null resets to initial state", () => {
|
|
usePdfToImageStore.getState().setFormat("jpg");
|
|
usePdfToImageStore.getState().setDpi(300);
|
|
usePdfToImageStore.getState().setFile(null);
|
|
const s = usePdfToImageStore.getState();
|
|
expect(s.file).toBeNull();
|
|
// format and dpi are part of initialState spread, so they also reset
|
|
expect(s.format).toBe("png");
|
|
expect(s.dpi).toBe(150);
|
|
});
|
|
|
|
it("setFile with a File resets results and pages", () => {
|
|
const file = new File(["pdf content"], "test.pdf", { type: "application/pdf" });
|
|
usePdfToImageStore.getState().setFile(file);
|
|
const s = usePdfToImageStore.getState();
|
|
expect(s.file).toBe(file);
|
|
expect(s.pageCount).toBeNull();
|
|
expect(s.thumbnails).toEqual([]);
|
|
expect(s.results).toBeNull();
|
|
expect(s.error).toBeNull();
|
|
expect(s.selectedPages.size).toBe(0);
|
|
expect(s.pages).toBe("");
|
|
});
|
|
|
|
it("togglePage adds and removes pages", () => {
|
|
usePdfToImageStore.setState({ pageCount: 5 });
|
|
usePdfToImageStore.getState().togglePage(3);
|
|
expect(usePdfToImageStore.getState().selectedPages.has(3)).toBe(true);
|
|
|
|
usePdfToImageStore.getState().togglePage(3);
|
|
expect(usePdfToImageStore.getState().selectedPages.has(3)).toBe(false);
|
|
});
|
|
|
|
it("selectAllPages selects all pages when pageCount is set", () => {
|
|
usePdfToImageStore.setState({ pageCount: 4 });
|
|
usePdfToImageStore.getState().selectAllPages();
|
|
const pages = usePdfToImageStore.getState().selectedPages;
|
|
expect(pages.size).toBe(4);
|
|
expect(pages.has(1)).toBe(true);
|
|
expect(pages.has(4)).toBe(true);
|
|
expect(usePdfToImageStore.getState().pages).toBe("");
|
|
});
|
|
|
|
it("selectAllPages is a no-op when pageCount is null", () => {
|
|
usePdfToImageStore.getState().selectAllPages();
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(0);
|
|
});
|
|
|
|
it("deselectAllPages clears selection and sets pages to 'none'", () => {
|
|
usePdfToImageStore.setState({ pageCount: 3 });
|
|
usePdfToImageStore.getState().selectAllPages();
|
|
usePdfToImageStore.getState().deselectAllPages();
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(0);
|
|
expect(usePdfToImageStore.getState().pages).toBe("none");
|
|
});
|
|
|
|
it("reset restores initial state", () => {
|
|
usePdfToImageStore.getState().setFormat("webp");
|
|
usePdfToImageStore.getState().setDpi(600);
|
|
usePdfToImageStore.setState({ processing: true, error: "something" });
|
|
|
|
usePdfToImageStore.getState().reset();
|
|
const s = usePdfToImageStore.getState();
|
|
expect(s.format).toBe("png");
|
|
expect(s.dpi).toBe(150);
|
|
expect(s.processing).toBe(false);
|
|
expect(s.error).toBeNull();
|
|
});
|
|
|
|
// -- setPages (page range parsing) ----------------------------------------
|
|
|
|
it("setPages with valid range sets selectedPages from parsed range", () => {
|
|
usePdfToImageStore.setState({ pageCount: 10 });
|
|
usePdfToImageStore.getState().setPages("1-3, 5");
|
|
const s = usePdfToImageStore.getState();
|
|
expect(s.pages).toBe("1-3, 5");
|
|
expect(s.selectedPages.size).toBe(4);
|
|
expect(s.selectedPages.has(1)).toBe(true);
|
|
expect(s.selectedPages.has(2)).toBe(true);
|
|
expect(s.selectedPages.has(3)).toBe(true);
|
|
expect(s.selectedPages.has(5)).toBe(true);
|
|
});
|
|
|
|
it("setPages with 'all' selects all pages", () => {
|
|
usePdfToImageStore.setState({ pageCount: 5 });
|
|
usePdfToImageStore.getState().setPages("all");
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(5);
|
|
});
|
|
|
|
it("setPages with empty string selects all pages", () => {
|
|
usePdfToImageStore.setState({ pageCount: 3 });
|
|
usePdfToImageStore.getState().setPages("");
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(3);
|
|
});
|
|
|
|
it("setPages with invalid range falls back to all pages", () => {
|
|
usePdfToImageStore.setState({ pageCount: 4 });
|
|
usePdfToImageStore.getState().setPages("5-3"); // invalid: start > end
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(4);
|
|
});
|
|
|
|
it("setPages with out-of-range page falls back to all pages", () => {
|
|
usePdfToImageStore.setState({ pageCount: 3 });
|
|
usePdfToImageStore.getState().setPages("5"); // page 5 > pageCount 3
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(3);
|
|
});
|
|
|
|
it("setPages with null pageCount yields empty selectedPages", () => {
|
|
// pageCount is null by default after reset
|
|
usePdfToImageStore.getState().setPages("1-3");
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(0);
|
|
});
|
|
|
|
it("setPages with single page number", () => {
|
|
usePdfToImageStore.setState({ pageCount: 10 });
|
|
usePdfToImageStore.getState().setPages("7");
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(1);
|
|
expect(usePdfToImageStore.getState().selectedPages.has(7)).toBe(true);
|
|
});
|
|
|
|
// -- togglePage generates compact range strings ---------------------------
|
|
|
|
it("togglePage updates pages string as compact range", () => {
|
|
usePdfToImageStore.setState({ pageCount: 5 });
|
|
usePdfToImageStore.getState().togglePage(1);
|
|
usePdfToImageStore.getState().togglePage(2);
|
|
usePdfToImageStore.getState().togglePage(3);
|
|
expect(usePdfToImageStore.getState().pages).toBe("1-3");
|
|
});
|
|
|
|
it("togglePage with non-contiguous pages shows individual values", () => {
|
|
usePdfToImageStore.setState({ pageCount: 10 });
|
|
usePdfToImageStore.getState().togglePage(1);
|
|
usePdfToImageStore.getState().togglePage(5);
|
|
expect(usePdfToImageStore.getState().pages).toBe("1, 5");
|
|
});
|
|
|
|
it("togglePage clearing all pages sets empty pages string", () => {
|
|
usePdfToImageStore.setState({ pageCount: 2 });
|
|
usePdfToImageStore.getState().togglePage(1);
|
|
usePdfToImageStore.getState().togglePage(1); // untoggle
|
|
expect(usePdfToImageStore.getState().pages).toBe("");
|
|
expect(usePdfToImageStore.getState().selectedPages.size).toBe(0);
|
|
});
|
|
|
|
// -- loadPreview ----------------------------------------------------------
|
|
|
|
it("loadPreview fetches preview and sets pageCount and thumbnails", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
pageCount: 3,
|
|
thumbnails: [{ page: 1, dataUrl: "data:...", width: 100, height: 150 }],
|
|
}),
|
|
});
|
|
|
|
await usePdfToImageStore.getState().loadPreview(file);
|
|
|
|
const s = usePdfToImageStore.getState();
|
|
expect(s.pageCount).toBe(3);
|
|
expect(s.thumbnails).toHaveLength(1);
|
|
expect(s.selectedPages.size).toBe(3);
|
|
expect(s.loadingPreview).toBe(false);
|
|
});
|
|
|
|
it("loadPreview sets error on HTTP failure", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
json: () => Promise.resolve({ error: "Server error" }),
|
|
});
|
|
|
|
await usePdfToImageStore.getState().loadPreview(file);
|
|
|
|
const s = usePdfToImageStore.getState();
|
|
expect(s.error).toBe("Server error");
|
|
expect(s.file).toBeNull();
|
|
expect(s.pageCount).toBeNull();
|
|
expect(s.loadingPreview).toBe(false);
|
|
});
|
|
|
|
it("loadPreview sets error on fetch failure with fallback", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
json: () => Promise.reject(new Error("not json")),
|
|
});
|
|
|
|
await usePdfToImageStore.getState().loadPreview(file);
|
|
|
|
expect(usePdfToImageStore.getState().error).toBe("Failed: 500");
|
|
});
|
|
|
|
it("loadPreview sets error on network exception", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
fetchMock.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
await usePdfToImageStore.getState().loadPreview(file);
|
|
|
|
expect(usePdfToImageStore.getState().error).toBe("Network error");
|
|
expect(usePdfToImageStore.getState().loadingPreview).toBe(false);
|
|
});
|
|
|
|
it("loadPreview sets generic error for non-Error throws", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
fetchMock.mockRejectedValueOnce("string error");
|
|
|
|
await usePdfToImageStore.getState().loadPreview(file);
|
|
|
|
expect(usePdfToImageStore.getState().error).toBe("Failed to read PDF");
|
|
});
|
|
|
|
// -- convert --------------------------------------------------------------
|
|
|
|
it("convert sends file with settings and stores results", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
usePdfToImageStore.setState({
|
|
file,
|
|
format: "jpg",
|
|
dpi: 300,
|
|
quality: 90,
|
|
colorMode: "grayscale",
|
|
pages: "",
|
|
selectedPages: new Set([1, 2]),
|
|
pageCount: 3,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
pages: [{ page: 1, downloadUrl: "/dl/1", size: 100 }],
|
|
zipUrl: "/dl/zip",
|
|
zipSize: 500,
|
|
}),
|
|
});
|
|
|
|
await usePdfToImageStore.getState().convert();
|
|
|
|
const s = usePdfToImageStore.getState();
|
|
expect(s.results).toHaveLength(1);
|
|
expect(s.zipUrl).toBe("/dl/zip");
|
|
expect(s.zipSize).toBe(500);
|
|
expect(s.processing).toBe(false);
|
|
});
|
|
|
|
it("convert is a no-op when no file is set", async () => {
|
|
const callsBefore = fetchMock.mock.calls.length;
|
|
await usePdfToImageStore.getState().convert();
|
|
|
|
// No new fetch calls should have been made
|
|
expect(fetchMock.mock.calls.length).toBe(callsBefore);
|
|
expect(usePdfToImageStore.getState().processing).toBe(false);
|
|
});
|
|
|
|
it("convert sets error on HTTP failure", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
usePdfToImageStore.setState({ file, pageCount: 2 });
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
json: () => Promise.resolve({ error: "Conversion error" }),
|
|
});
|
|
|
|
await usePdfToImageStore.getState().convert();
|
|
|
|
expect(usePdfToImageStore.getState().error).toBe("Conversion error");
|
|
expect(usePdfToImageStore.getState().processing).toBe(false);
|
|
});
|
|
|
|
it("convert sets error on network exception", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
usePdfToImageStore.setState({ file, pageCount: 2 });
|
|
|
|
fetchMock.mockRejectedValueOnce(new Error("Offline"));
|
|
|
|
await usePdfToImageStore.getState().convert();
|
|
|
|
expect(usePdfToImageStore.getState().error).toBe("Offline");
|
|
expect(usePdfToImageStore.getState().processing).toBe(false);
|
|
});
|
|
|
|
it("convert sets generic error for non-Error throws", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
usePdfToImageStore.setState({ file, pageCount: 2 });
|
|
|
|
fetchMock.mockRejectedValueOnce(42);
|
|
|
|
await usePdfToImageStore.getState().convert();
|
|
|
|
expect(usePdfToImageStore.getState().error).toBe("Conversion failed");
|
|
});
|
|
|
|
it("convert uses 'all' when pages is empty", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
usePdfToImageStore.setState({
|
|
file,
|
|
pages: "",
|
|
selectedPages: new Set([1, 2, 3]),
|
|
pageCount: 3,
|
|
});
|
|
|
|
let capturedFormData: FormData | null = null;
|
|
fetchMock.mockImplementationOnce((_url: string, opts: { body: FormData }) => {
|
|
capturedFormData = opts.body;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ pages: [], zipUrl: null, zipSize: null }),
|
|
});
|
|
});
|
|
|
|
await usePdfToImageStore.getState().convert();
|
|
|
|
expect(capturedFormData).not.toBeNull();
|
|
// FormData.get works in jsdom when appended synchronously
|
|
const settings = capturedFormData?.get("settings");
|
|
expect(settings).not.toBeNull();
|
|
const parsed = JSON.parse(settings as string);
|
|
expect(parsed.pages).toBe("all");
|
|
});
|
|
|
|
it("convert sends compact page range for specific selection", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
usePdfToImageStore.setState({
|
|
file,
|
|
pages: "1-2",
|
|
selectedPages: new Set([1, 2]),
|
|
pageCount: 5,
|
|
});
|
|
|
|
let capturedFormData: FormData | null = null;
|
|
fetchMock.mockImplementationOnce((_url: string, opts: { body: FormData }) => {
|
|
capturedFormData = opts.body;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ pages: [], zipUrl: null, zipSize: null }),
|
|
});
|
|
});
|
|
|
|
await usePdfToImageStore.getState().convert();
|
|
|
|
expect(capturedFormData).not.toBeNull();
|
|
const settings = capturedFormData?.get("settings");
|
|
expect(settings).not.toBeNull();
|
|
const parsed = JSON.parse(settings as string);
|
|
expect(parsed.pages).toBe("1-2");
|
|
});
|
|
|
|
it("convert handles JSON parse failure on error response", async () => {
|
|
const file = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
|
|
usePdfToImageStore.setState({ file, pageCount: 2 });
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
json: () => Promise.reject(new Error("bad json")),
|
|
});
|
|
|
|
await usePdfToImageStore.getState().convert();
|
|
|
|
expect(usePdfToImageStore.getState().error).toBe("Conversion failed: 500");
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// CollageStore
|
|
// ==========================================================================
|
|
|
|
// Mock the collage-templates module so we don't depend on real template data.
|
|
// The store uses getDefaultTemplate() and COLLAGE_TEMPLATES — provide
|
|
// minimal stubs that return predictable values.
|
|
vi.mock("@/lib/collage-templates", () => ({
|
|
COLLAGE_TEMPLATES: [
|
|
{
|
|
id: "2-h-equal",
|
|
imageCount: 2,
|
|
label: "Side by side",
|
|
gridTemplateColumns: "1fr 1fr",
|
|
gridTemplateRows: "1fr",
|
|
cells: [
|
|
{ gridColumn: "1", gridRow: "1" },
|
|
{ gridColumn: "2", gridRow: "1" },
|
|
],
|
|
},
|
|
{
|
|
id: "3-h-equal",
|
|
imageCount: 3,
|
|
label: "Three across",
|
|
gridTemplateColumns: "1fr 1fr 1fr",
|
|
gridTemplateRows: "1fr",
|
|
cells: [
|
|
{ gridColumn: "1", gridRow: "1" },
|
|
{ gridColumn: "2", gridRow: "1" },
|
|
{ gridColumn: "3", gridRow: "1" },
|
|
],
|
|
},
|
|
],
|
|
getDefaultTemplate: (count: number) => {
|
|
if (count >= 3) {
|
|
return {
|
|
id: "3-h-equal",
|
|
imageCount: 3,
|
|
cells: [
|
|
{ gridColumn: "1", gridRow: "1" },
|
|
{ gridColumn: "2", gridRow: "1" },
|
|
{ gridColumn: "3", gridRow: "1" },
|
|
],
|
|
};
|
|
}
|
|
return {
|
|
id: "2-h-equal",
|
|
imageCount: 2,
|
|
cells: [
|
|
{ gridColumn: "1", gridRow: "1" },
|
|
{ gridColumn: "2", gridRow: "1" },
|
|
],
|
|
};
|
|
},
|
|
}));
|
|
|
|
// Mock image-preview helpers to avoid real fetch calls
|
|
vi.mock("@/lib/image-preview", () => ({
|
|
needsServerPreview: vi.fn(() => false),
|
|
fetchDecodedPreview: vi.fn(() => Promise.resolve(null)),
|
|
revokePreviewUrl: vi.fn(),
|
|
}));
|
|
|
|
import { useCollageStore } from "@/stores/collage-store";
|
|
|
|
describe("useCollageStore", () => {
|
|
beforeEach(() => {
|
|
revokeObjectURL.mockClear();
|
|
createObjectURL.mockClear();
|
|
useCollageStore.getState().reset();
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useCollageStore.getState();
|
|
expect(s.images).toEqual([]);
|
|
expect(s.templateId).toBe("2-h-equal");
|
|
expect(s.cellAssignments).toEqual([]);
|
|
expect(s.cellTransforms).toEqual({});
|
|
expect(s.gap).toBe(8);
|
|
expect(s.cornerRadius).toBe(0);
|
|
expect(s.backgroundColor).toBe("#FFFFFF");
|
|
expect(s.bgPreset).toBe("white");
|
|
expect(s.aspectRatio).toBe("free");
|
|
expect(s.outputFormat).toBe("png");
|
|
expect(s.quality).toBe(90);
|
|
expect(s.selectedCell).toBeNull();
|
|
expect(s.phase).toBe("upload");
|
|
expect(s.progress).toBe(0);
|
|
expect(s.resultUrl).toBeNull();
|
|
expect(s.resultSize).toBeNull();
|
|
expect(s.originalSize).toBeNull();
|
|
expect(s.error).toBeNull();
|
|
expect(s.jobId).toBeNull();
|
|
});
|
|
|
|
it("addImages creates CollageImage entries and transitions to editing phase", () => {
|
|
const file1 = new File(["a"], "photo1.png", { type: "image/png" });
|
|
const file2 = new File(["b"], "photo2.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file1, file2]);
|
|
|
|
const s = useCollageStore.getState();
|
|
expect(s.images).toHaveLength(2);
|
|
expect(s.images[0].file).toBe(file1);
|
|
expect(s.images[0].blobUrl).toBe("blob:fake-url");
|
|
expect(s.images[1].file).toBe(file2);
|
|
expect(s.phase).toBe("editing");
|
|
expect(s.templateId).toBe("2-h-equal");
|
|
expect(s.cellAssignments).toHaveLength(2);
|
|
expect(s.cellAssignments[0]).toBe(0);
|
|
expect(s.cellAssignments[1]).toBe(1);
|
|
});
|
|
|
|
it("addImages appends to existing images", () => {
|
|
const file1 = new File(["a"], "a.png", { type: "image/png" });
|
|
const file2 = new File(["b"], "b.png", { type: "image/png" });
|
|
const file3 = new File(["c"], "c.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file1, file2]);
|
|
useCollageStore.getState().addImages([file3]);
|
|
|
|
const s = useCollageStore.getState();
|
|
expect(s.images).toHaveLength(3);
|
|
expect(s.templateId).toBe("3-h-equal");
|
|
});
|
|
|
|
it("removeImage removes image by index and revokes blob URL", () => {
|
|
const file1 = new File(["a"], "a.png", { type: "image/png" });
|
|
const file2 = new File(["b"], "b.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file1, file2]);
|
|
revokeObjectURL.mockClear();
|
|
|
|
useCollageStore.getState().removeImage(0);
|
|
|
|
const s = useCollageStore.getState();
|
|
expect(s.images).toHaveLength(1);
|
|
expect(s.images[0].file).toBe(file2);
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:fake-url");
|
|
});
|
|
|
|
it("removeImage resets when last image is removed", () => {
|
|
const file = new File(["a"], "a.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file]);
|
|
|
|
useCollageStore.getState().removeImage(0);
|
|
const s = useCollageStore.getState();
|
|
expect(s.images).toEqual([]);
|
|
expect(s.phase).toBe("upload");
|
|
});
|
|
|
|
it("addImages triggers async preview loading for HEIC files", async () => {
|
|
// Override the mocked needsServerPreview to return true for this test
|
|
const imagePreview = await import("@/lib/image-preview");
|
|
const needsMock = vi.mocked(imagePreview.needsServerPreview);
|
|
const fetchPreviewMock = vi.mocked(imagePreview.fetchDecodedPreview);
|
|
needsMock.mockReturnValueOnce(true);
|
|
fetchPreviewMock.mockResolvedValueOnce({
|
|
url: "blob:preview-decoded",
|
|
originalWidth: null,
|
|
originalHeight: null,
|
|
});
|
|
|
|
const heicFile = new File(["heic-data"], "photo.heic", { type: "image/heic" });
|
|
useCollageStore.getState().addImages([heicFile]);
|
|
|
|
// The image should initially have previewLoading = true
|
|
expect(useCollageStore.getState().images[0].previewLoading).toBe(true);
|
|
|
|
// Wait for the async fetchDecodedPreview to complete
|
|
await vi.waitFor(() => {
|
|
const img = useCollageStore.getState().images[0];
|
|
expect(img.previewLoading).toBe(false);
|
|
});
|
|
|
|
// The preview blob URL should be set
|
|
expect(useCollageStore.getState().images[0].previewBlobUrl).toBe("blob:preview-decoded");
|
|
});
|
|
|
|
it("addImages preview callback handles image removed before resolve", async () => {
|
|
const imagePreview = await import("@/lib/image-preview");
|
|
const needsMock = vi.mocked(imagePreview.needsServerPreview);
|
|
const fetchPreviewMock = vi.mocked(imagePreview.fetchDecodedPreview);
|
|
needsMock.mockReturnValueOnce(true);
|
|
|
|
let resolvePreview!: (v: import("@/lib/image-preview").DecodedPreview | null) => void;
|
|
fetchPreviewMock.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolvePreview = resolve;
|
|
}),
|
|
);
|
|
|
|
const heicFile = new File(["data"], "gone.heic", { type: "image/heic" });
|
|
useCollageStore.getState().addImages([heicFile]);
|
|
|
|
// Remove the image before the preview resolves
|
|
useCollageStore.getState().removeImage(0);
|
|
|
|
// Now resolve the preview -- should not crash (idx === -1 path)
|
|
resolvePreview({ url: "blob:late-preview", originalWidth: null, originalHeight: null });
|
|
|
|
// Give the promise chain time to settle
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// Store should still be in reset state
|
|
expect(useCollageStore.getState().images).toEqual([]);
|
|
});
|
|
|
|
it("addImages preview callback handles null URL from fetchDecodedPreview", async () => {
|
|
const imagePreview = await import("@/lib/image-preview");
|
|
const needsMock = vi.mocked(imagePreview.needsServerPreview);
|
|
const fetchPreviewMock = vi.mocked(imagePreview.fetchDecodedPreview);
|
|
needsMock.mockReturnValueOnce(true);
|
|
fetchPreviewMock.mockResolvedValueOnce(null);
|
|
|
|
const heicFile = new File(["data"], "fail.heic", { type: "image/heic" });
|
|
useCollageStore.getState().addImages([heicFile]);
|
|
|
|
// Wait for the async preview to resolve with null
|
|
await vi.waitFor(() => {
|
|
const img = useCollageStore.getState().images[0];
|
|
expect(img.previewLoading).toBe(false);
|
|
});
|
|
|
|
// previewBlobUrl should NOT be set when URL is null
|
|
expect(useCollageStore.getState().images[0].previewBlobUrl).toBeUndefined();
|
|
});
|
|
|
|
it("clearImages revokes all blob URLs and resets to upload phase", () => {
|
|
const file1 = new File(["a"], "a.png", { type: "image/png" });
|
|
const file2 = new File(["b"], "b.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file1, file2]);
|
|
revokeObjectURL.mockClear();
|
|
|
|
useCollageStore.getState().clearImages();
|
|
|
|
const s = useCollageStore.getState();
|
|
expect(s.images).toEqual([]);
|
|
expect(s.phase).toBe("upload");
|
|
expect(s.cellAssignments).toEqual([]);
|
|
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("setTemplateId changes template and rebuilds assignments", () => {
|
|
const file1 = new File(["a"], "a.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file1]);
|
|
useCollageStore.getState().setTemplateId("3-h-equal");
|
|
|
|
const s = useCollageStore.getState();
|
|
expect(s.templateId).toBe("3-h-equal");
|
|
expect(s.cellAssignments).toHaveLength(3);
|
|
expect(s.cellAssignments[0]).toBe(0);
|
|
expect(s.cellAssignments[1]).toBe(-1);
|
|
expect(s.cellAssignments[2]).toBe(-1);
|
|
expect(s.cellTransforms).toEqual({});
|
|
});
|
|
|
|
it("setTemplateId ignores unknown template IDs", () => {
|
|
useCollageStore.getState().setTemplateId("nonexistent");
|
|
expect(useCollageStore.getState().templateId).toBe("2-h-equal");
|
|
});
|
|
|
|
it("setCellAssignment updates a single cell", () => {
|
|
const file1 = new File(["a"], "a.png", { type: "image/png" });
|
|
const file2 = new File(["b"], "b.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file1, file2]);
|
|
|
|
useCollageStore.getState().setCellAssignment(0, 1);
|
|
expect(useCollageStore.getState().cellAssignments[0]).toBe(1);
|
|
expect(useCollageStore.getState().resultUrl).toBeNull();
|
|
});
|
|
|
|
it("swapCells swaps assignments and transforms between two cells", () => {
|
|
const file1 = new File(["a"], "a.png", { type: "image/png" });
|
|
const file2 = new File(["b"], "b.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file1, file2]);
|
|
|
|
useCollageStore.getState().setCellTransform(0, { zoom: 2 });
|
|
|
|
useCollageStore.getState().swapCells(0, 1);
|
|
|
|
const s = useCollageStore.getState();
|
|
expect(s.cellAssignments[0]).toBe(1);
|
|
expect(s.cellAssignments[1]).toBe(0);
|
|
expect(s.cellTransforms[1]?.zoom).toBe(2);
|
|
});
|
|
|
|
it("setCellTransform creates and merges transforms", () => {
|
|
useCollageStore.getState().setCellTransform(0, { panX: 50 });
|
|
expect(useCollageStore.getState().cellTransforms[0].panX).toBe(50);
|
|
expect(useCollageStore.getState().cellTransforms[0].zoom).toBe(1);
|
|
|
|
useCollageStore.getState().setCellTransform(0, { zoom: 2.5 });
|
|
expect(useCollageStore.getState().cellTransforms[0].panX).toBe(50);
|
|
expect(useCollageStore.getState().cellTransforms[0].zoom).toBe(2.5);
|
|
});
|
|
|
|
it("resetCellTransform removes transform for a cell", () => {
|
|
useCollageStore.getState().setCellTransform(0, { zoom: 2 });
|
|
expect(useCollageStore.getState().cellTransforms[0]).toBeDefined();
|
|
|
|
useCollageStore.getState().resetCellTransform(0);
|
|
expect(useCollageStore.getState().cellTransforms[0]).toBeUndefined();
|
|
});
|
|
|
|
it("setGap updates gap and clears resultUrl", () => {
|
|
useCollageStore.setState({ resultUrl: "blob:old" });
|
|
useCollageStore.getState().setGap(16);
|
|
expect(useCollageStore.getState().gap).toBe(16);
|
|
expect(useCollageStore.getState().resultUrl).toBeNull();
|
|
});
|
|
|
|
it("setCornerRadius updates corner radius", () => {
|
|
useCollageStore.getState().setCornerRadius(12);
|
|
expect(useCollageStore.getState().cornerRadius).toBe(12);
|
|
});
|
|
|
|
it("setBackgroundColor updates backgroundColor", () => {
|
|
useCollageStore.getState().setBackgroundColor("#FF0000");
|
|
expect(useCollageStore.getState().backgroundColor).toBe("#FF0000");
|
|
});
|
|
|
|
it("setBgPreset updates preset and color for named presets", () => {
|
|
useCollageStore.getState().setBgPreset("black");
|
|
expect(useCollageStore.getState().bgPreset).toBe("black");
|
|
expect(useCollageStore.getState().backgroundColor).toBe("#000000");
|
|
|
|
useCollageStore.getState().setBgPreset("transparent");
|
|
expect(useCollageStore.getState().backgroundColor).toBe("transparent");
|
|
});
|
|
|
|
it("setBgPreset with custom does not overwrite backgroundColor", () => {
|
|
useCollageStore.getState().setBackgroundColor("#123456");
|
|
useCollageStore.getState().setBgPreset("custom");
|
|
expect(useCollageStore.getState().bgPreset).toBe("custom");
|
|
expect(useCollageStore.getState().backgroundColor).toBe("#123456");
|
|
});
|
|
|
|
it("setAspectRatio updates aspect ratio", () => {
|
|
useCollageStore.getState().setAspectRatio("16:9");
|
|
expect(useCollageStore.getState().aspectRatio).toBe("16:9");
|
|
});
|
|
|
|
it("setOutputFormat updates output format", () => {
|
|
useCollageStore.getState().setOutputFormat("webp");
|
|
expect(useCollageStore.getState().outputFormat).toBe("webp");
|
|
});
|
|
|
|
it("setQuality updates quality", () => {
|
|
useCollageStore.getState().setQuality(75);
|
|
expect(useCollageStore.getState().quality).toBe(75);
|
|
});
|
|
|
|
it("setSelectedCell updates selected cell", () => {
|
|
useCollageStore.getState().setSelectedCell(1);
|
|
expect(useCollageStore.getState().selectedCell).toBe(1);
|
|
useCollageStore.getState().setSelectedCell(null);
|
|
expect(useCollageStore.getState().selectedCell).toBeNull();
|
|
});
|
|
|
|
it("setPhase updates phase", () => {
|
|
useCollageStore.getState().setPhase("processing");
|
|
expect(useCollageStore.getState().phase).toBe("processing");
|
|
});
|
|
|
|
it("setProgress updates progress", () => {
|
|
useCollageStore.getState().setProgress(42);
|
|
expect(useCollageStore.getState().progress).toBe(42);
|
|
});
|
|
|
|
it("setResult sets result fields and transitions to result phase", () => {
|
|
useCollageStore.getState().setResult("blob:result", 5000, 10000, "job-123");
|
|
const s = useCollageStore.getState();
|
|
expect(s.resultUrl).toBe("blob:result");
|
|
expect(s.resultSize).toBe(5000);
|
|
expect(s.originalSize).toBe(10000);
|
|
expect(s.jobId).toBe("job-123");
|
|
expect(s.phase).toBe("result");
|
|
expect(s.error).toBeNull();
|
|
});
|
|
|
|
it("setError sets error and returns to editing phase", () => {
|
|
useCollageStore.getState().setPhase("processing");
|
|
useCollageStore.getState().setError("Something went wrong");
|
|
const s = useCollageStore.getState();
|
|
expect(s.error).toBe("Something went wrong");
|
|
expect(s.phase).toBe("editing");
|
|
});
|
|
|
|
it("reset revokes all blob URLs and restores initial state", () => {
|
|
const file1 = new File(["a"], "a.png", { type: "image/png" });
|
|
useCollageStore.getState().addImages([file1]);
|
|
useCollageStore.getState().setGap(20);
|
|
useCollageStore.getState().setCornerRadius(10);
|
|
useCollageStore.getState().setOutputFormat("jpeg");
|
|
revokeObjectURL.mockClear();
|
|
|
|
useCollageStore.getState().reset();
|
|
|
|
expect(revokeObjectURL).toHaveBeenCalled();
|
|
const s = useCollageStore.getState();
|
|
expect(s.images).toEqual([]);
|
|
expect(s.gap).toBe(8);
|
|
expect(s.cornerRadius).toBe(0);
|
|
expect(s.outputFormat).toBe("png");
|
|
expect(s.phase).toBe("upload");
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// SettingsStore
|
|
// ==========================================================================
|
|
|
|
// Mock the api module to prevent real network calls
|
|
vi.mock("@/lib/api", () => ({
|
|
apiGet: vi.fn(),
|
|
apiPost: vi.fn(),
|
|
apiPut: vi.fn(),
|
|
apiDelete: vi.fn(),
|
|
formatHeaders: vi.fn(() => new Headers()),
|
|
}));
|
|
|
|
import { apiGet } from "@/lib/api";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
|
|
const mockApiGet = vi.mocked(apiGet);
|
|
|
|
describe("useSettingsStore", () => {
|
|
beforeEach(() => {
|
|
useSettingsStore.setState({
|
|
disabledTools: [],
|
|
experimentalEnabled: false,
|
|
defaultToolView: "sidebar",
|
|
defaultTheme: "light",
|
|
loaded: false,
|
|
loadError: false,
|
|
});
|
|
localStorage.removeItem("snapotter-theme-user-set");
|
|
mockApiGet.mockReset();
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useSettingsStore.getState();
|
|
expect(s.disabledTools).toEqual([]);
|
|
expect(s.experimentalEnabled).toBe(false);
|
|
expect(s.defaultToolView).toBe("sidebar");
|
|
expect(s.defaultTheme).toBe("light");
|
|
expect(s.loaded).toBe(false);
|
|
});
|
|
|
|
it("fetch loads settings from API", async () => {
|
|
mockApiGet.mockResolvedValueOnce({
|
|
settings: {
|
|
disabledTools: JSON.stringify(["resize", "crop"]),
|
|
enableExperimentalTools: "true",
|
|
defaultToolView: "fullscreen",
|
|
defaultTheme: "dark",
|
|
},
|
|
});
|
|
|
|
await useSettingsStore.getState().fetch();
|
|
|
|
const s = useSettingsStore.getState();
|
|
expect(s.disabledTools).toEqual(["resize", "crop"]);
|
|
expect(s.experimentalEnabled).toBe(true);
|
|
expect(s.defaultToolView).toBe("fullscreen");
|
|
expect(s.defaultTheme).toBe("dark");
|
|
expect(s.loaded).toBe(true);
|
|
});
|
|
|
|
it("fetch applies server default theme when no user preference", async () => {
|
|
mockApiGet.mockResolvedValueOnce({
|
|
settings: { defaultTheme: "dark" },
|
|
});
|
|
|
|
await useSettingsStore.getState().fetch();
|
|
|
|
const theme = useThemeStore.getState();
|
|
expect(theme.theme).toBe("dark");
|
|
expect(theme.resolvedTheme).toBe("dark");
|
|
});
|
|
|
|
it("fetch does not override user theme preference", async () => {
|
|
useThemeStore.getState().setTheme("light");
|
|
|
|
mockApiGet.mockResolvedValueOnce({
|
|
settings: { defaultTheme: "dark" },
|
|
});
|
|
|
|
await useSettingsStore.getState().fetch();
|
|
|
|
const theme = useThemeStore.getState();
|
|
expect(theme.theme).toBe("light");
|
|
});
|
|
|
|
it("fetch defaults defaultTheme to light for invalid values", async () => {
|
|
mockApiGet.mockResolvedValueOnce({
|
|
settings: { defaultTheme: "invalid" },
|
|
});
|
|
|
|
await useSettingsStore.getState().fetch();
|
|
expect(useSettingsStore.getState().defaultTheme).toBe("light");
|
|
});
|
|
|
|
it("fetch skips when already loaded without error", async () => {
|
|
useSettingsStore.setState({ loaded: true, loadError: false });
|
|
await useSettingsStore.getState().fetch();
|
|
expect(mockApiGet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetch handles empty disabledTools gracefully", async () => {
|
|
mockApiGet.mockResolvedValueOnce({
|
|
settings: {},
|
|
});
|
|
|
|
await useSettingsStore.getState().fetch();
|
|
|
|
const s = useSettingsStore.getState();
|
|
expect(s.disabledTools).toEqual([]);
|
|
expect(s.experimentalEnabled).toBe(false);
|
|
expect(s.defaultToolView).toBe("sidebar");
|
|
expect(s.defaultTheme).toBe("light");
|
|
expect(s.loaded).toBe(true);
|
|
});
|
|
|
|
it("fetch defaults defaultToolView to sidebar for unknown values", async () => {
|
|
mockApiGet.mockResolvedValueOnce({
|
|
settings: {
|
|
defaultToolView: "unknown-value",
|
|
},
|
|
});
|
|
|
|
await useSettingsStore.getState().fetch();
|
|
expect(useSettingsStore.getState().defaultToolView).toBe("sidebar");
|
|
});
|
|
|
|
it("fetch sets loaded=true and loadError=true on error", async () => {
|
|
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
await useSettingsStore.getState().fetch();
|
|
|
|
expect(useSettingsStore.getState().loaded).toBe(true);
|
|
expect(useSettingsStore.getState().loadError).toBe(true);
|
|
expect(useSettingsStore.getState().disabledTools).toEqual([]);
|
|
});
|
|
|
|
it("fetch retries when loadError is true", async () => {
|
|
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
|
|
await useSettingsStore.getState().fetch();
|
|
expect(useSettingsStore.getState().loadError).toBe(true);
|
|
|
|
mockApiGet.mockResolvedValueOnce({
|
|
settings: { disabledTools: JSON.stringify(["resize"]) },
|
|
});
|
|
await useSettingsStore.getState().fetch();
|
|
|
|
expect(useSettingsStore.getState().loadError).toBe(false);
|
|
expect(useSettingsStore.getState().disabledTools).toEqual(["resize"]);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// FeaturesStore
|
|
// ==========================================================================
|
|
|
|
vi.mock("@snapotter/shared", async (importOriginal) => {
|
|
const actual: Record<string, unknown> = await importOriginal();
|
|
return {
|
|
...actual,
|
|
TOOL_BUNDLE_MAP: {
|
|
"remove-bg": "ai-rembg",
|
|
upscale: "ai-esrgan",
|
|
},
|
|
};
|
|
});
|
|
|
|
import { apiPost } from "@/lib/api";
|
|
import { useFeaturesStore } from "@/stores/features-store";
|
|
|
|
const mockApiPost = vi.mocked(apiPost);
|
|
|
|
describe("useFeaturesStore", () => {
|
|
beforeEach(() => {
|
|
mockApiGet.mockReset();
|
|
mockApiPost.mockReset();
|
|
fetchMock.mockReset();
|
|
useFeaturesStore.setState({
|
|
bundles: [],
|
|
loaded: false,
|
|
loadError: false,
|
|
installing: {},
|
|
errors: {},
|
|
queued: [],
|
|
installAllActive: false,
|
|
startTimes: {},
|
|
});
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useFeaturesStore.getState();
|
|
expect(s.bundles).toEqual([]);
|
|
expect(s.loaded).toBe(false);
|
|
expect(s.installing).toEqual({});
|
|
expect(s.errors).toEqual({});
|
|
expect(s.queued).toEqual([]);
|
|
expect(s.installAllActive).toBe(false);
|
|
expect(s.startTimes).toEqual({});
|
|
});
|
|
|
|
it("fetch loads bundles from API", async () => {
|
|
const bundles = [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "installed" as const,
|
|
installedVersion: "1.0.0",
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
];
|
|
mockApiGet.mockResolvedValueOnce({ bundles });
|
|
|
|
await useFeaturesStore.getState().fetch();
|
|
|
|
const s = useFeaturesStore.getState();
|
|
expect(s.bundles).toEqual(bundles);
|
|
expect(s.loaded).toBe(true);
|
|
});
|
|
|
|
it("fetch skips when already loaded without error", async () => {
|
|
useFeaturesStore.setState({ loaded: true, loadError: false });
|
|
await useFeaturesStore.getState().fetch();
|
|
expect(mockApiGet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetch sets loaded=true and loadError=true on error", async () => {
|
|
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
|
|
await useFeaturesStore.getState().fetch();
|
|
expect(useFeaturesStore.getState().loaded).toBe(true);
|
|
expect(useFeaturesStore.getState().loadError).toBe(true);
|
|
expect(useFeaturesStore.getState().bundles).toEqual([]);
|
|
});
|
|
|
|
it("fetch retries when loadError is true", async () => {
|
|
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
|
|
await useFeaturesStore.getState().fetch();
|
|
expect(useFeaturesStore.getState().loadError).toBe(true);
|
|
|
|
const bundles = [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "installed" as const,
|
|
installedVersion: "1.0.0",
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
];
|
|
mockApiGet.mockResolvedValueOnce({ bundles });
|
|
await useFeaturesStore.getState().fetch();
|
|
|
|
expect(useFeaturesStore.getState().loadError).toBe(false);
|
|
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
|
|
});
|
|
|
|
it("isToolInstalled returns true for tools whose bundle is installed", () => {
|
|
useFeaturesStore.setState({
|
|
bundles: [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "installed",
|
|
installedVersion: "1.0.0",
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
],
|
|
});
|
|
expect(useFeaturesStore.getState().isToolInstalled("remove-bg")).toBe(true);
|
|
});
|
|
|
|
it("isToolInstalled returns false for tools whose bundle is not installed", () => {
|
|
useFeaturesStore.setState({
|
|
bundles: [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "not_installed",
|
|
installedVersion: null,
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
],
|
|
});
|
|
expect(useFeaturesStore.getState().isToolInstalled("remove-bg")).toBe(false);
|
|
});
|
|
|
|
it("isToolInstalled returns true for tools without a bundle mapping", () => {
|
|
expect(useFeaturesStore.getState().isToolInstalled("resize")).toBe(true);
|
|
});
|
|
|
|
it("getBundleForTool returns the bundle for a mapped tool", () => {
|
|
const bundle = {
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "installed" as const,
|
|
installedVersion: "1.0.0",
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
};
|
|
useFeaturesStore.setState({ bundles: [bundle] });
|
|
expect(useFeaturesStore.getState().getBundleForTool("remove-bg")).toEqual(bundle);
|
|
});
|
|
|
|
it("getBundleForTool returns null for unmapped tools", () => {
|
|
expect(useFeaturesStore.getState().getBundleForTool("resize")).toBeNull();
|
|
});
|
|
|
|
it("clearError removes a specific bundle error", () => {
|
|
useFeaturesStore.setState({
|
|
errors: { "ai-rembg": "Failed", "ai-esrgan": "Also failed" },
|
|
});
|
|
useFeaturesStore.getState().clearError("ai-rembg");
|
|
expect(useFeaturesStore.getState().errors).toEqual({ "ai-esrgan": "Also failed" });
|
|
});
|
|
|
|
it("uninstallBundle calls API and refreshes bundles", async () => {
|
|
mockApiPost.mockResolvedValueOnce({});
|
|
mockApiGet.mockResolvedValueOnce({ bundles: [] });
|
|
|
|
await useFeaturesStore.getState().uninstallBundle("ai-rembg");
|
|
|
|
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/uninstall", {});
|
|
});
|
|
|
|
it("uninstallBundle sets error on failure", async () => {
|
|
mockApiPost.mockRejectedValueOnce(new Error("Uninstall failed"));
|
|
|
|
await useFeaturesStore.getState().uninstallBundle("ai-rembg");
|
|
|
|
expect(useFeaturesStore.getState().errors["ai-rembg"]).toBe("Uninstall failed");
|
|
});
|
|
|
|
it("installBundle sets initial progress and calls API", async () => {
|
|
// Mock EventSource globally
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockReturnValue({
|
|
onmessage: null,
|
|
onerror: null,
|
|
close: mockClose,
|
|
}),
|
|
);
|
|
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-123" });
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeDefined();
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].percent).toBeGreaterThanOrEqual(5);
|
|
expect(useFeaturesStore.getState().startTimes["ai-rembg"]).toBeDefined();
|
|
});
|
|
|
|
it("installBundle sets error on API failure", async () => {
|
|
mockApiPost.mockRejectedValueOnce(new Error("Server error"));
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
|
|
expect(useFeaturesStore.getState().errors["ai-rembg"]).toBe("Server error");
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeUndefined();
|
|
});
|
|
|
|
it("installBundle sets generic error for non-Error throws", async () => {
|
|
mockApiPost.mockRejectedValueOnce("string error");
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
|
|
expect(useFeaturesStore.getState().errors["ai-rembg"]).toBe("Failed to start installation");
|
|
});
|
|
|
|
it("installBundle clears previous error for that bundle", async () => {
|
|
useFeaturesStore.setState({
|
|
errors: { "ai-rembg": "Old error" },
|
|
});
|
|
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockReturnValue({
|
|
onmessage: null,
|
|
onerror: null,
|
|
close: mockClose,
|
|
}),
|
|
);
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-456" });
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
|
|
expect(useFeaturesStore.getState().errors["ai-rembg"]).toBeUndefined();
|
|
});
|
|
|
|
it("uninstallBundle sets generic error for non-Error throws", async () => {
|
|
mockApiPost.mockRejectedValueOnce(42);
|
|
|
|
await useFeaturesStore.getState().uninstallBundle("ai-rembg");
|
|
|
|
expect(useFeaturesStore.getState().errors["ai-rembg"]).toBe("Uninstall failed");
|
|
});
|
|
|
|
it("getBundleForTool returns null when bundle not found in bundles list", () => {
|
|
useFeaturesStore.setState({ bundles: [] });
|
|
expect(useFeaturesStore.getState().getBundleForTool("remove-bg")).toBeNull();
|
|
});
|
|
|
|
it("isToolInstalled returns false when bundle exists but is in error state", () => {
|
|
useFeaturesStore.setState({
|
|
bundles: [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "error",
|
|
installedVersion: null,
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: "Install failed",
|
|
},
|
|
],
|
|
});
|
|
expect(useFeaturesStore.getState().isToolInstalled("remove-bg")).toBe(false);
|
|
});
|
|
|
|
it("isToolInstalled returns false when bundle is installing", () => {
|
|
useFeaturesStore.setState({
|
|
bundles: [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "installing",
|
|
installedVersion: null,
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: { percent: 50, stage: "Downloading..." },
|
|
error: null,
|
|
},
|
|
],
|
|
});
|
|
expect(useFeaturesStore.getState().isToolInstalled("remove-bg")).toBe(false);
|
|
});
|
|
|
|
it("clearError is a no-op for nonexistent bundle", () => {
|
|
useFeaturesStore.setState({ errors: { "ai-rembg": "Error" } });
|
|
useFeaturesStore.getState().clearError("nonexistent");
|
|
expect(useFeaturesStore.getState().errors).toEqual({ "ai-rembg": "Error" });
|
|
});
|
|
|
|
it("refresh updates bundles from API", async () => {
|
|
const bundles = [
|
|
{
|
|
id: "ai-esrgan",
|
|
name: "AI Upscaler",
|
|
description: "Upscale images",
|
|
status: "installed" as const,
|
|
installedVersion: "2.0.0",
|
|
estimatedSize: "1GB",
|
|
enablesTools: ["upscale"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
];
|
|
mockApiGet.mockResolvedValueOnce({ bundles });
|
|
|
|
await useFeaturesStore.getState().refresh();
|
|
|
|
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
|
|
expect(useFeaturesStore.getState().loaded).toBe(true);
|
|
});
|
|
|
|
it("refresh silently ignores API errors", async () => {
|
|
useFeaturesStore.setState({
|
|
bundles: [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "installed",
|
|
installedVersion: "1.0.0",
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
],
|
|
loaded: true,
|
|
});
|
|
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
await useFeaturesStore.getState().refresh();
|
|
|
|
// Bundles should remain unchanged on error
|
|
expect(useFeaturesStore.getState().bundles).toHaveLength(1);
|
|
});
|
|
|
|
it("fetch recovers active installs on load", async () => {
|
|
const bundles = [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "installing" as const,
|
|
installedVersion: null,
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: { percent: 30, stage: "Downloading models..." },
|
|
error: null,
|
|
},
|
|
];
|
|
mockApiGet.mockResolvedValueOnce({ bundles });
|
|
|
|
await useFeaturesStore.getState().fetch();
|
|
|
|
// The recovering logic should have set installing state for the active bundle
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeDefined();
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].percent).toBe(30);
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].stage).toBe("Downloading models...");
|
|
});
|
|
|
|
it("fetch recovers active installs with default progress when none given", async () => {
|
|
const bundles = [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "installing" as const,
|
|
installedVersion: null,
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
];
|
|
mockApiGet.mockResolvedValueOnce({ bundles });
|
|
|
|
await useFeaturesStore.getState().fetch();
|
|
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeDefined();
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].percent).toBe(0);
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].stage).toBe("Resuming...");
|
|
});
|
|
|
|
it("reinstallBundle calls uninstall then install", async () => {
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockReturnValue({
|
|
onmessage: null,
|
|
onerror: null,
|
|
close: mockClose,
|
|
}),
|
|
);
|
|
|
|
// uninstall call
|
|
mockApiPost.mockResolvedValueOnce({});
|
|
// refresh after uninstall
|
|
mockApiGet.mockResolvedValueOnce({ bundles: [] });
|
|
// install call
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-reinstall" });
|
|
|
|
await useFeaturesStore.getState().reinstallBundle("ai-rembg");
|
|
|
|
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/uninstall", {});
|
|
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/install", {});
|
|
});
|
|
|
|
// -- EventSource progress handling ----------------------------------------
|
|
|
|
it("listenToProgress handles complete phase from EventSource", async () => {
|
|
let esOnMessage: ((event: MessageEvent) => void) | null = null;
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockImplementation(() => {
|
|
const es = {
|
|
onmessage: null as ((event: MessageEvent) => void) | null,
|
|
onerror: null as (() => void) | null,
|
|
close: mockClose,
|
|
};
|
|
// Capture for later triggering
|
|
setTimeout(() => {
|
|
esOnMessage = es.onmessage;
|
|
}, 0);
|
|
return es;
|
|
}),
|
|
);
|
|
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-complete" });
|
|
// refresh after complete
|
|
mockApiGet.mockResolvedValueOnce({ bundles: [] });
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
// Wait for setTimeout to capture the onmessage handler
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// Simulate complete event
|
|
if (esOnMessage) {
|
|
esOnMessage({
|
|
data: JSON.stringify({ phase: "complete", percent: 100, stage: "Done" }),
|
|
} as MessageEvent);
|
|
}
|
|
expect(mockClose).toHaveBeenCalled();
|
|
// Installing should be cleared
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeUndefined();
|
|
});
|
|
|
|
it("listenToProgress handles failed phase from EventSource", async () => {
|
|
let esOnMessage: ((event: MessageEvent) => void) | null = null;
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockImplementation(() => {
|
|
const es = {
|
|
onmessage: null as ((event: MessageEvent) => void) | null,
|
|
onerror: null as (() => void) | null,
|
|
close: mockClose,
|
|
};
|
|
setTimeout(() => {
|
|
esOnMessage = es.onmessage;
|
|
}, 0);
|
|
return es;
|
|
}),
|
|
);
|
|
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-fail" });
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// Simulate failed event
|
|
if (esOnMessage) {
|
|
esOnMessage({
|
|
data: JSON.stringify({ phase: "failed", percent: 0, stage: "", error: "Disk full" }),
|
|
} as MessageEvent);
|
|
}
|
|
expect(mockClose).toHaveBeenCalled();
|
|
expect(useFeaturesStore.getState().errors["ai-rembg"]).toBe("Disk full");
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeUndefined();
|
|
});
|
|
|
|
it("listenToProgress handles progress updates from EventSource", async () => {
|
|
let esOnMessage: ((event: MessageEvent) => void) | null = null;
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockImplementation(() => {
|
|
const es = {
|
|
onmessage: null as ((event: MessageEvent) => void) | null,
|
|
onerror: null as (() => void) | null,
|
|
close: mockClose,
|
|
};
|
|
setTimeout(() => {
|
|
esOnMessage = es.onmessage;
|
|
}, 0);
|
|
return es;
|
|
}),
|
|
);
|
|
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-progress" });
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// Simulate progress update
|
|
if (esOnMessage) {
|
|
esOnMessage({
|
|
data: JSON.stringify({ phase: "processing", percent: 50, stage: "Downloading..." }),
|
|
} as MessageEvent);
|
|
}
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].percent).toBe(50);
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].stage).toBe("Downloading...");
|
|
});
|
|
|
|
it("listenToProgress uses max of current and new percent", async () => {
|
|
let esOnMessage: ((event: MessageEvent) => void) | null = null;
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockImplementation(() => {
|
|
const es = {
|
|
onmessage: null as ((event: MessageEvent) => void) | null,
|
|
onerror: null as (() => void) | null,
|
|
close: mockClose,
|
|
};
|
|
setTimeout(() => {
|
|
esOnMessage = es.onmessage;
|
|
}, 0);
|
|
return es;
|
|
}),
|
|
);
|
|
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-max" });
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// Send a high percent first
|
|
if (esOnMessage) {
|
|
esOnMessage({
|
|
data: JSON.stringify({ phase: "processing", percent: 70, stage: "Step 1" }),
|
|
} as MessageEvent);
|
|
}
|
|
// Then a lower percent (should keep the higher one)
|
|
if (esOnMessage) {
|
|
esOnMessage({
|
|
data: JSON.stringify({ phase: "processing", percent: 40, stage: "Step 2" }),
|
|
} as MessageEvent);
|
|
}
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].percent).toBe(70);
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"].stage).toBe("Step 2");
|
|
});
|
|
|
|
it("EventSource onerror closes and falls back to polling", async () => {
|
|
let esOnError: (() => void) | null = null;
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockImplementation(() => {
|
|
const es = {
|
|
onmessage: null as ((event: MessageEvent) => void) | null,
|
|
onerror: null as (() => void) | null,
|
|
close: mockClose,
|
|
};
|
|
setTimeout(() => {
|
|
esOnError = es.onerror;
|
|
}, 0);
|
|
return es;
|
|
}),
|
|
);
|
|
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-err" });
|
|
|
|
await useFeaturesStore.getState().installBundle("ai-rembg");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// Trigger onerror
|
|
if (esOnError) {
|
|
esOnError();
|
|
}
|
|
expect(mockClose).toHaveBeenCalled();
|
|
// Bundle should still be in installing state (polling takes over)
|
|
expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeDefined();
|
|
});
|
|
|
|
// -- installAll -----------------------------------------------------------
|
|
|
|
it("installAll processes all not-installed bundles sequentially", async () => {
|
|
const bundles = [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI Background Remover",
|
|
description: "Remove backgrounds",
|
|
status: "not_installed" as const,
|
|
installedVersion: null,
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
{
|
|
id: "ai-esrgan",
|
|
name: "AI Upscaler",
|
|
description: "Upscale images",
|
|
status: "installed" as const,
|
|
installedVersion: "1.0",
|
|
estimatedSize: "1GB",
|
|
enablesTools: ["upscale"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
];
|
|
useFeaturesStore.setState({ bundles, loaded: true });
|
|
|
|
// For the install call, we need to immediately resolve so installAll loop progresses
|
|
let esOnMessage: ((event: MessageEvent) => void) | null = null;
|
|
const mockClose = vi.fn();
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockImplementation(() => {
|
|
const es = {
|
|
onmessage: null as ((event: MessageEvent) => void) | null,
|
|
onerror: null as (() => void) | null,
|
|
close: mockClose,
|
|
};
|
|
setTimeout(() => {
|
|
esOnMessage = es.onmessage;
|
|
// Immediately complete the install
|
|
if (esOnMessage) {
|
|
esOnMessage({
|
|
data: JSON.stringify({ phase: "complete", percent: 100, stage: "Done" }),
|
|
} as MessageEvent);
|
|
}
|
|
}, 0);
|
|
return es;
|
|
}),
|
|
);
|
|
|
|
// install call for ai-rembg
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-all-1" });
|
|
// refresh after completion
|
|
mockApiGet.mockResolvedValue({ bundles });
|
|
|
|
await useFeaturesStore.getState().installAll();
|
|
|
|
expect(useFeaturesStore.getState().installAllActive).toBe(false);
|
|
expect(useFeaturesStore.getState().queued).toEqual([]);
|
|
// Only not-installed bundle should have been installed
|
|
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/install", {});
|
|
});
|
|
|
|
it("installAll skips bundles that are already installed", async () => {
|
|
const bundles = [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI BG",
|
|
description: "Remove backgrounds",
|
|
status: "installed" as const,
|
|
installedVersion: "1.0",
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
];
|
|
useFeaturesStore.setState({ bundles, loaded: true });
|
|
|
|
await useFeaturesStore.getState().installAll();
|
|
|
|
expect(useFeaturesStore.getState().installAllActive).toBe(false);
|
|
expect(mockApiPost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("installAll clears stale errors for pending bundles", async () => {
|
|
const bundles = [
|
|
{
|
|
id: "ai-rembg",
|
|
name: "AI BG",
|
|
description: "Remove backgrounds",
|
|
status: "not_installed" as const,
|
|
installedVersion: null,
|
|
estimatedSize: "500MB",
|
|
enablesTools: ["remove-bg"],
|
|
progress: null,
|
|
error: null,
|
|
},
|
|
];
|
|
useFeaturesStore.setState({
|
|
bundles,
|
|
loaded: true,
|
|
errors: { "ai-rembg": "Previous error" },
|
|
});
|
|
|
|
// Setup fast-completing EventSource
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
vi.fn().mockImplementation(() => {
|
|
const es = {
|
|
onmessage: null as ((event: MessageEvent) => void) | null,
|
|
onerror: null as (() => void) | null,
|
|
close: vi.fn(),
|
|
};
|
|
setTimeout(() => {
|
|
if (es.onmessage) {
|
|
es.onmessage({
|
|
data: JSON.stringify({ phase: "complete", percent: 100, stage: "Done" }),
|
|
} as MessageEvent);
|
|
}
|
|
}, 0);
|
|
return es;
|
|
}),
|
|
);
|
|
|
|
mockApiPost.mockResolvedValueOnce({ jobId: "job-clear" });
|
|
mockApiGet.mockResolvedValue({
|
|
bundles: bundles.map((b) => ({ ...b, status: "installed" as const })),
|
|
});
|
|
|
|
await useFeaturesStore.getState().installAll();
|
|
|
|
// Error should have been cleared at the start of installAll
|
|
expect(useFeaturesStore.getState().errors["ai-rembg"]).toBeUndefined();
|
|
}, 15000);
|
|
});
|
|
|
|
// ==========================================================================
|
|
// AnalyticsStore
|
|
// ==========================================================================
|
|
|
|
// Mock the analytics lib to avoid importing Sentry/PostHog
|
|
vi.mock("@/lib/analytics", () => ({
|
|
setAnalyticsConsent: vi.fn(),
|
|
initAnalytics: vi.fn(),
|
|
identify: vi.fn(),
|
|
trackEvent: vi.fn(),
|
|
trackPageView: vi.fn(),
|
|
}));
|
|
|
|
import { setAnalyticsConsent } from "@/lib/analytics";
|
|
import { apiPut } from "@/lib/api";
|
|
import { useAnalyticsStore } from "@/stores/analytics-store";
|
|
|
|
const mockApiPut = vi.mocked(apiPut);
|
|
const mockSetAnalyticsConsent = vi.mocked(setAnalyticsConsent);
|
|
|
|
describe("useAnalyticsStore", () => {
|
|
beforeEach(() => {
|
|
fetchMock.mockReset();
|
|
mockApiPut.mockReset();
|
|
mockSetAnalyticsConsent.mockClear();
|
|
storageMap.clear();
|
|
useAnalyticsStore.setState({
|
|
config: null,
|
|
consent: {
|
|
analyticsEnabled: null,
|
|
analyticsConsentShownAt: null,
|
|
analyticsConsentRemindAt: null,
|
|
},
|
|
configLoaded: false,
|
|
});
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useAnalyticsStore.getState();
|
|
expect(s.config).toBeNull();
|
|
expect(s.consent.analyticsEnabled).toBeNull();
|
|
expect(s.consent.analyticsConsentShownAt).toBeNull();
|
|
expect(s.consent.analyticsConsentRemindAt).toBeNull();
|
|
expect(s.configLoaded).toBe(false);
|
|
});
|
|
|
|
it("fetchConfig loads config from API", async () => {
|
|
const config = {
|
|
enabled: true,
|
|
posthogApiKey: "phc_test",
|
|
posthogHost: "https://ph.example.com",
|
|
sentryDsn: "https://sentry.example.com",
|
|
sampleRate: 0.5,
|
|
instanceId: "inst-123",
|
|
};
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(config),
|
|
});
|
|
|
|
await useAnalyticsStore.getState().fetchConfig();
|
|
|
|
const s = useAnalyticsStore.getState();
|
|
expect(s.config).toEqual(config);
|
|
expect(s.configLoaded).toBe(true);
|
|
});
|
|
|
|
it("fetchConfig skips when already loaded", async () => {
|
|
useAnalyticsStore.setState({ configLoaded: true });
|
|
await useAnalyticsStore.getState().fetchConfig();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetchConfig sets configLoaded=true on error", async () => {
|
|
fetchMock.mockRejectedValueOnce(new Error("Network error"));
|
|
await useAnalyticsStore.getState().fetchConfig();
|
|
expect(useAnalyticsStore.getState().configLoaded).toBe(true);
|
|
expect(useAnalyticsStore.getState().config).toBeNull();
|
|
});
|
|
|
|
it("setConsent updates consent and calls setAnalyticsConsent", () => {
|
|
const consent = {
|
|
analyticsEnabled: true,
|
|
analyticsConsentShownAt: 1000,
|
|
analyticsConsentRemindAt: null,
|
|
};
|
|
useAnalyticsStore.getState().setConsent(consent);
|
|
|
|
expect(useAnalyticsStore.getState().consent).toEqual(consent);
|
|
expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("acceptAnalytics calls API and sets consent to true", async () => {
|
|
mockApiPut.mockResolvedValueOnce({});
|
|
|
|
await useAnalyticsStore.getState().acceptAnalytics();
|
|
|
|
const s = useAnalyticsStore.getState();
|
|
expect(s.consent.analyticsEnabled).toBe(true);
|
|
expect(s.consent.analyticsConsentShownAt).toBeTypeOf("number");
|
|
expect(s.consent.analyticsConsentRemindAt).toBeNull();
|
|
expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("acceptAnalytics falls back to localStorage on API error", async () => {
|
|
mockApiPut.mockRejectedValueOnce(new Error("Server error"));
|
|
|
|
await useAnalyticsStore.getState().acceptAnalytics();
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("snapotter-analytics-consent", "true");
|
|
expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true);
|
|
});
|
|
|
|
it("declineAnalytics calls API and sets consent to false", async () => {
|
|
mockApiPut.mockResolvedValueOnce({});
|
|
|
|
await useAnalyticsStore.getState().declineAnalytics();
|
|
|
|
const s = useAnalyticsStore.getState();
|
|
expect(s.consent.analyticsEnabled).toBe(false);
|
|
expect(s.consent.analyticsConsentShownAt).toBeTypeOf("number");
|
|
expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("declineAnalytics falls back to localStorage on API error", async () => {
|
|
mockApiPut.mockRejectedValueOnce(new Error("Server error"));
|
|
|
|
await useAnalyticsStore.getState().declineAnalytics();
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("snapotter-analytics-consent", "false");
|
|
});
|
|
|
|
it("remindLater calls API and sets remind-at 7 days in the future", async () => {
|
|
mockApiPut.mockResolvedValueOnce({});
|
|
|
|
const before = Date.now();
|
|
await useAnalyticsStore.getState().remindLater();
|
|
const after = Date.now();
|
|
|
|
const s = useAnalyticsStore.getState();
|
|
expect(s.consent.analyticsEnabled).toBeNull();
|
|
expect(s.consent.analyticsConsentRemindAt).toBeTypeOf("number");
|
|
// Remind-at should be approximately 7 days from now
|
|
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
|
expect(s.consent.analyticsConsentRemindAt!).toBeGreaterThanOrEqual(before + sevenDays);
|
|
expect(s.consent.analyticsConsentRemindAt!).toBeLessThanOrEqual(after + sevenDays);
|
|
expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("remindLater falls back to localStorage on API error", async () => {
|
|
mockApiPut.mockRejectedValueOnce(new Error("Server error"));
|
|
|
|
await useAnalyticsStore.getState().remindLater();
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("snapotter-analytics-consent", "remind");
|
|
});
|
|
|
|
it("toggleAnalytics enables analytics", async () => {
|
|
mockApiPut.mockResolvedValueOnce({});
|
|
|
|
await useAnalyticsStore.getState().toggleAnalytics(true);
|
|
|
|
expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true);
|
|
expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("toggleAnalytics disables analytics", async () => {
|
|
mockApiPut.mockResolvedValueOnce({});
|
|
|
|
await useAnalyticsStore.getState().toggleAnalytics(false);
|
|
|
|
expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(false);
|
|
expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("toggleAnalytics falls back to localStorage on API error", async () => {
|
|
mockApiPut.mockRejectedValueOnce(new Error("Server error"));
|
|
|
|
await useAnalyticsStore.getState().toggleAnalytics(true);
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("snapotter-analytics-consent", "true");
|
|
expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true);
|
|
});
|
|
});
|