mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix(api): correct format/filename/container handling across tool routes Found during a comprehensive QA sweep exercising every tool against its full accepted-format matrix: - watermark-image, compose: preserve the requested output format and a matching download filename/extension instead of always emitting the source format - compose: crop oversized overlays to the visible base area instead of crashing Sharp's composite, and reject only overlays fully outside the base image instead of any oversized one - compare, vectorize: switch to the shared image input handler so filenames and formats like .svgz/.tga/RAW survive validation instead of being rejected pre-processing - tool-factory, images-to-video: normalize frames through Sharp before handing them to FFmpeg, fixing GIF/AVIF/RAW image-to-video jobs that previously failed or hung - media-tool, replace-audio, embed-subtitles: fix legacy container MIME/codec handling for MPEG sources and subtitle remux cases - files: expand download MIME mapping for text/data/document/video/audio outputs that were falling back to a generic content type - convert-document/presentation/spreadsheet: same-format conversions now return the original validated file instead of erroring or producing corrupt tiny output Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): dropzone a11y, stale localStorage getter, dead code - dropzone: stop making the whole drop-zone section clickable/focusable. A section acting as an interactive element around a real upload button is a nested-interactive-element anti-pattern that confuses screen readers; drag-and-drop doesn't need focus semantics, only the button fallback does. Keeps that button semantic and keyboard-reachable. Updates the two e2e call sites that clicked the section directly. - api, use-auth: read through window.localStorage via the existing API storage helper instead of the bare global, which resolves to Node's experimental localStorage getter under Vitest and threw - find-duplicates-settings, info-settings, login-page: remove dead code (unused zip-download handler, a stale mount-only effect dependency that left cached info stuck at reused indices, an unused response variable) Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(i18n): pt-BR, zh-CN, zh-TW were silently falling back to English The locale loader looked up dynamic-import exports by the raw locale code (mod["pt-BR"], mod["zh-CN"], mod["zh-TW"]), but those three modules export camelCased bindings (ptBR, zhCN, zhTW) since identifiers can't contain hyphens. The lookup returned undefined and every consumer silently fell back to English for these three locales. Replaces the generic lookup with explicit per-locale loaders so the mapping can't drift out of sync again. Also updates the dropzone helper copy across all 21 locales to match the drag-only dropzone wording from the previous commit. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(docs): clear build warnings in the VitePress site - config.mts: add an onwarn handler for the @vueuse INVALID_ANNOTATION warnings emitted during the docs build - deployment.md: the caddyfile code fence language isn't a shiki grammar VitePress ships with, so it warned on every build; use txt instead Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * test(qa): update QA harness for the drag-only dropzone and regen metadata - api-sweep, qa-helpers, verify-ai: add JSON-body tools, multi-input secondary fixtures, async polling for slow valid jobs, 501 FEATURE_NOT_INSTALLED skip handling, and safer per-tool settings - input-preview, pipeline-ui specs: update upload flow for the drag-only dropzone surface - add tests/fixtures/data/valid/chart.json, a valid chart fixture the updated helpers route to - regenerate tools-meta.json against current TOOLS[] Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(security): close a login timing side-channel, harden zip-slip tests Found during a black-box security sweep of the real auth-enabled production container: a nonexistent username returned 401 in ~3-10ms, while a wrong password for a real user took ~35-42ms, because scrypt verification only ran when a user row existed. That timing gap lets an attacker enumerate valid usernames without ever guessing a password. Now runs verification against a cached dummy hash on the unknown-user path too, so both cases cost the same regardless of outcome. extract-zip already had a relative-traversal regression test (../evil.txt), but its absolute-path rejection branches (name.startsWith("/") / startsWith("\\")) had none. Added the three missing cases: deep relative traversal, absolute Unix path, and Windows-style absolute path. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * test(qa): add UI-driven AI bundle install scripts QA_PROMPT.md's Phase 2 requires installing AI models the way a user does -- through the UI, on demand from HuggingFace -- and treats the curl-based admin install endpoint as fallback-only. Nothing in the harness actually drove that flow; tests/qa/seed-ai-models.sh installs via docker exec + pip, which is further from a real user than even the API fallback. install-ai-bundles-ui.mts logs in, opens Settings > AI Features, screenshots the pre-install state, clicks Install All, and screenshots progress -- then exits, since installs continue server-side once triggered. verify-ai-install-complete.mts polls bundle status, screenshots the completed state, and runs one real tool per installed bundle to prove the freshly-downloaded model actually executes. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(qa): correct the apiToolPath import in the AI verify script Dynamic import of the package name failed under tsx's module resolution from apps/api's node_modules context; use the same relative-path import api-sweep.mts already uses successfully. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): correct AI bundle size estimates shown before install Measured real downloads during GPU-node QA verification: photo-restoration pulls ~4.4GB (was advertised as 800MB-1GB, off by 4-5x) and ocr pulls ~5.5GB (was advertised as 3-4GB). Both estimates only accounted for model weights, not the pip dependencies (torch/paddle) that come down with them. Updated to reflect actual total download size, since that's what a user deciding whether they have the disk/bandwidth actually needs to know. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): make desktop Settings reachable when auth is disabled AvatarDropdown (the only desktop entry point to Settings) was gated behind `!isMobile && authEnabled`. With AUTH_ENABLED=false the synthetic anonymous admin user should have full Settings access per how auth.ts documents this mode -- and the mobile bottom nav already worked this way, showing Settings unconditionally. Desktop just had a stray extra gate the component doesn't need: AvatarDropdown already resolves its own username internally (falling back to "admin") and reads authEnabled itself where it actually matters (hiding the Logout button). Removed the outer gate; verified end-to-end against a fresh AUTH_ENABLED=false instance -- avatar now renders, Settings opens, shows the anonymous/Admin identity correctly. Also documents (not changes) a related finding in install_feature.py: detect_arch() always resolves amd64 hosts to the GPU-bundled archive variant regardless of actual GPU presence, since no CPU-only amd64 archive is published to the bundle repo yet. Left as a code comment rather than a behavior change, since requesting an unpublished archive key would hard-fail installs entirely -- worse than the current oversized-but-working download. Full detail in the QA report. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(ai): stop logging expected dispatcher reloads as crashes After each AI bundle install the Python dispatcher reloads because the venv changed, and after every app shutdown it's SIGTERMed. Both took the close handler's `code !== 0` branch (SIGTERM makes the exit code null), so they were counted as crashes -- producing an alarming "crash" line in the logs and a pointless ~1s recovery backoff after each of 7 installs. A `stopping` flag set in shutdown() lets the close handler tell an intentional stop apart from a real crash. The request-timeout kill path deliberately does not set it, so a genuinely hung script still records a crash and the 5-in-60s permanent-disable threshold is untouched. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(api): return a clean message when content-aware resize times out Carving a very high-resolution image down to a tiny target could exceed the caire subprocess timeout, and the raw error forwarded to the user was caire's terminal output -- ANSI color codes and progress-spinner control characters -- instead of anything actionable. Now: the timeout path throws a clear "timed out; try a smaller image or larger target" message (keeping the raw stderr as `cause` for server logs); friendlyError() strips ANSI/control chars centrally so any subprocess dump surfaced through the shared sanitizer is plain text; and the content-aware-resize route (a custom route that bypassed the sanitizer) now routes its error paths through friendlyError like every other tool. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(ai): stop bundle installs from exhausting host disk Installing an AI bundle on a tight-disk host could push the root filesystem to zero bytes free after the preflight check had already passed. Two root causes: - move_tree used copytree+rmtree, so during the move the extracted payload existed in both staging and the venv at once -- a full transient doubling on disk. Rewrote it to rename entries (a cheap metadata op on the same filesystem, no copy), falling back to a copy only across filesystems. - the preflight budget used the manifest's extractedSize verbatim, which is 0 for several archives, collapsing the estimate to just the compressed size. Added a conservative fallback (3x compressed) so a missing value can't under-reserve. Also added a real-on-disk re-check immediately before the first destructive venv write (measuring the actual extracted payload and whether the move needs extra space for a cross-filesystem copy), which also now covers the offline-import path that previously skipped the disk check entirely; wrapped the moves so an out-of-space failure returns a clean actionable error instead of a traceback; and made the disk check resolve the nearest existing ancestor so it never throws on a not-yet-created venv path. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * feat(web): show the real per-arch AI bundle download size The bundle cards and install prompt showed a hardcoded, architecture-blind estimatedSize string. That's misleading: amd64 hosts always pull the CUDA-inclusive archive (there's no CPU-only amd64 variant published), so a bundle labelled "1-2 GB" can actually download several times that, while arm64 pulls a much smaller archive for the same label. The manifest already carries the real per-arch compressedSize (and extractedSize where measured), so surface those: a new optional downloadBytes/installedBytes on FeatureBundleState, populated in getFeatureStates() for this host's arch (resolver mirrors install_feature.py detect_arch), shown by the UI when present with estimatedSize kept as the fallback label. Also nudged upscale-enhance's fallback string (4-5 -> 5-6 GB) to match its real compressed size, consistent with the earlier photo-restoration/ocr fixes. Fields are optional so demo/mock and existing tests stay compiling; the manifest's extractedSize is 0 for a few archives, which now surfaces as null rather than a bogus 0. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): move the AI install queue to the server so it survives tab close Installing multiple bundles could silently lose all but the first. The server rejected a concurrent install with 409, so the client worked around it by queueing the rest in browser-local state and only POSTing each once it saw the previous finish. A single POSTed install is durable (the installer child is detached from the request), but a queued one had zero server footprint -- close the tab mid-queue and those installs vanished with no error, while the UI still showed them "Queued". The client "mutex" didn't even serialize: the queued bundles' local waits all resolved at once and raced into concurrent POSTs that 409'd each other. Now the queue lives on the server (a small in-memory FIFO leaf module). The install endpoint enqueues instead of 409-ing and returns 202 {jobId, queued}; a pump starts the next bundle when the current one's child exits (and after an offline import releases the lock), all behind the existing venv + file locks, which are unchanged. The client just POSTs every bundle immediately and reflects the server-reported queued/installing status; Install All fires all POSTs and lets the server serialize them, keeping the one-shot retry-on-failure. Adds "queued" to FeatureStatus (the bundle card already rendered that state) and surfaces it from getFeatureStates. In-memory is deliberate: it matches the existing contract (survives a tab close, not a server restart, which already clears the lock on boot). Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(qa): don't log env-derived credentials in the AI-install script CodeQL flagged clear-text logging of sensitive information: the login status line interpolated the QA base URL and username (both read from the process environment) into a console.log. Replaced with a static message. QA helper only, but it's a real hygiene issue and cleared the high-severity code-scanning alert on the PR. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
2759 lines
92 KiB
TypeScript
2759 lines
92 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 triggers background refresh when already loaded without error", async () => {
|
|
const bundles = [{ id: "ocr", name: "OCR", status: "installed" }];
|
|
mockApiGet.mockResolvedValueOnce({ bundles });
|
|
useFeaturesStore.setState({ loaded: true, loadError: false });
|
|
await useFeaturesStore.getState().fetch();
|
|
expect(mockApiGet).toHaveBeenCalledWith("/v1/features");
|
|
});
|
|
|
|
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 POSTs an install for each not-installed bundle", 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();
|
|
|
|
// The install POST fires immediately (the server serializes installs now);
|
|
// only the not-installed bundle is POSTed.
|
|
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/install", {});
|
|
expect(mockApiPost).not.toHaveBeenCalledWith("/v1/admin/features/ai-esrgan/install", {});
|
|
// installAllActive clears once the (mock) EventSource reports completion.
|
|
await vi.waitFor(() => {
|
|
expect(useFeaturesStore.getState().installAllActive).toBe(false);
|
|
});
|
|
expect(useFeaturesStore.getState().queued).toEqual([]);
|
|
});
|
|
|
|
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
|
|
// ==========================================================================
|
|
|
|
import { useAnalyticsStore } from "@/stores/analytics-store";
|
|
|
|
describe("useAnalyticsStore", () => {
|
|
beforeEach(() => {
|
|
fetchMock.mockReset();
|
|
useAnalyticsStore.setState({
|
|
config: null,
|
|
configLoaded: false,
|
|
});
|
|
});
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useAnalyticsStore.getState();
|
|
expect(s.config).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 refetches even when already loaded so opt-out converges", async () => {
|
|
useAnalyticsStore.setState({ configLoaded: true });
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ enabled: false }),
|
|
});
|
|
await useAnalyticsStore.getState().fetchConfig();
|
|
// No one-shot guard: an already-open tab must pick up an instance-wide opt-out.
|
|
expect(fetchMock).toHaveBeenCalledWith("/api/v1/config/analytics");
|
|
expect(useAnalyticsStore.getState().config).toEqual({ enabled: false });
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|