// @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 = 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(); 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("blob:preview-decoded"); 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: string | 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("blob:late-preview"); // 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 = 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 }); await useFeaturesStore.getState().installAll(); // Error should have been cleared at the start of installAll expect(useFeaturesStore.getState().errors["ai-rembg"]).toBeUndefined(); }); }); // ========================================================================== // 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); }); });