Files
SnapOtter/tests/unit/web/lib-helpers.test.ts
T
ashim-hq babca4cf97 test: comprehensive test coverage expansion (+965 tests)
Add 42 new test files covering all untested tool routes, image engine
internals, AI sidecar bridge, Zustand stores, and cross-format
compatibility. Expand e2e-docker suite with 7 spec files covering all
48 tools against a real Docker container.

Unit tests:
- Image engine: format detection, MIME mapping, metadata parsing, pipeline
- AI bridge: sidecar lifecycle, all 11 tool functions (mocked)
- Web stores: 14 Zustand stores (collage, settings, features, analytics, etc.)
- API helpers: format decoders, page range, file validation

Integration tests:
- 25 tool routes that had zero dedicated tests
- Cross-format matrix: 17 input formats x 3 tools
- Edge cases: zero-byte files, corrupted headers, path traversal, XSS, SQL injection
- Concurrent request handling and pipeline edge cases

E2E-Docker (Playwright against real container):
- 7 spec files: essential, adjustment, conversion, creative, utility, AI, pipeline
- Custom buildMultipart helper for multi-file tool uploads
- AI tools gracefully skip when sidecar not installed

Fixtures:
- Organized test media: formats/ (18 formats) + content/ (17 content types)
- Reduced from 3.1 GB unorganized samples to 33 MB structured fixtures

Bug fix:
- color-adjustments: gamma exposure used invalid single-param gamma() for
  positive values; fixed to use two-param gamma(gammaIn, gammaOut) form
2026-04-23 17:12:02 +08:00

328 lines
10 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Global mocks
// ---------------------------------------------------------------------------
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
get length() {
return 0;
},
key: vi.fn(() => null),
});
// ==========================================================================
// formatFileSize (download.ts)
// ==========================================================================
import { formatFileSize } from "@/lib/download";
describe("formatFileSize", () => {
it("formats bytes under 1 MB as KB", () => {
expect(formatFileSize(512)).toBe("1 KB");
expect(formatFileSize(1024)).toBe("1 KB");
expect(formatFileSize(10240)).toBe("10 KB");
expect(formatFileSize(512000)).toBe("500 KB");
});
it("formats bytes at or above 1 MB as MB", () => {
expect(formatFileSize(1048576)).toBe("1.0 MB");
expect(formatFileSize(1572864)).toBe("1.5 MB");
expect(formatFileSize(10485760)).toBe("10.0 MB");
});
it("handles zero bytes", () => {
expect(formatFileSize(0)).toBe("0 KB");
});
it("formats values just below 1 MB threshold", () => {
expect(formatFileSize(1048575)).toBe("1024 KB");
});
});
// ==========================================================================
// formatExifValue / exifStr (metadata-utils.ts)
// ==========================================================================
import { exifStr, formatExifValue, SKIP_KEYS } from "@/lib/metadata-utils";
describe("formatExifValue", () => {
it("returns 'N/A' for null", () => {
expect(formatExifValue("any", null)).toBe("N/A");
});
it("returns 'N/A' for undefined", () => {
expect(formatExifValue("any", undefined)).toBe("N/A");
});
it("returns string values as-is", () => {
expect(formatExifValue("Make", "Canon")).toBe("Canon");
});
it("formats ExposureTime as fraction", () => {
expect(formatExifValue("ExposureTime", 0.004)).toBe("1/250s");
expect(formatExifValue("ExposureTime", 0.0125)).toBe("1/80s");
});
it("formats ExposureTime >= 1 as plain number", () => {
expect(formatExifValue("ExposureTime", 2)).toBe("2");
});
it("formats FNumber with f/ prefix", () => {
expect(formatExifValue("FNumber", 2.8)).toBe("f/2.8");
});
it("formats FocalLength with mm suffix", () => {
expect(formatExifValue("FocalLength", 50)).toBe("50mm");
expect(formatExifValue("FocalLengthIn35mmFormat", 85)).toBe("85mm");
});
it("returns plain number string for other numeric keys", () => {
expect(formatExifValue("ISO", 400)).toBe("400");
});
it("joins short arrays with commas", () => {
expect(formatExifValue("Keywords", ["nature", "sunset"])).toBe("nature, sunset");
});
it("summarizes long arrays", () => {
const arr = [1, 2, 3, 4, 5, 6, 7];
expect(formatExifValue("SomeField", arr)).toBe("[7 values]");
});
it("handles arrays of exactly 6 items", () => {
const arr = [1, 2, 3, 4, 5, 6];
expect(formatExifValue("SomeField", arr)).toBe("1, 2, 3, 4, 5, 6");
});
it("stringifies other types", () => {
expect(formatExifValue("unknown", true)).toBe("true");
expect(formatExifValue("unknown", { nested: 1 })).toBe("[object Object]");
});
});
describe("exifStr", () => {
it("returns string value from exif object", () => {
expect(exifStr({ Make: "Nikon" }, "Make")).toBe("Nikon");
});
it("returns stringified number from exif object", () => {
expect(exifStr({ ISO: 800 }, "ISO")).toBe("800");
});
it("returns empty string for missing key", () => {
expect(exifStr({ Make: "Canon" }, "Model")).toBe("");
});
it("returns empty string for null exif", () => {
expect(exifStr(null, "Make")).toBe("");
});
it("returns empty string for undefined exif", () => {
expect(exifStr(undefined, "Make")).toBe("");
});
it("returns empty string for non-string/number values", () => {
expect(exifStr({ Keywords: ["a", "b"] }, "Keywords")).toBe("");
});
});
describe("SKIP_KEYS", () => {
it("contains expected internal keys", () => {
expect(SKIP_KEYS.has("ExifToolVersion")).toBe(true);
expect(SKIP_KEYS.has("FileName")).toBe(true);
expect(SKIP_KEYS.has("ThumbnailImage")).toBe(true);
expect(SKIP_KEYS.has("MakerNote")).toBe(true);
});
it("does not contain display keys", () => {
expect(SKIP_KEYS.has("Make")).toBe(false);
expect(SKIP_KEYS.has("Model")).toBe(false);
expect(SKIP_KEYS.has("ISO")).toBe(false);
});
});
// ==========================================================================
// getSuggestedTools (suggested-tools.ts)
// ==========================================================================
import { getSuggestedTools } from "@/lib/suggested-tools";
describe("getSuggestedTools", () => {
it("returns suggestions for a known tool", () => {
const suggestions = getSuggestedTools("resize");
expect(suggestions).toContain("compress");
expect(suggestions).toContain("convert");
expect(suggestions.length).toBeGreaterThan(0);
});
it("returns suggestions for compress", () => {
const suggestions = getSuggestedTools("compress");
expect(suggestions).toContain("convert");
});
it("returns default fallback for an unknown tool", () => {
const suggestions = getSuggestedTools("nonexistent-tool");
expect(suggestions).toEqual(["resize", "compress", "convert"]);
});
it("returns suggestions for remove-background", () => {
const suggestions = getSuggestedTools("remove-background");
expect(suggestions).toContain("resize");
expect(suggestions).toContain("compress");
});
});
// ==========================================================================
// needsServerPreview (image-preview.ts)
// ==========================================================================
import { needsServerPreview } from "@/lib/image-preview";
describe("needsServerPreview", () => {
it("returns true for HEIC files", () => {
const file = new File([], "photo.heic", { type: "image/heic" });
expect(needsServerPreview(file)).toBe(true);
});
it("returns true for HEIF files", () => {
const file = new File([], "photo.heif", { type: "image/heif" });
expect(needsServerPreview(file)).toBe(true);
});
it("returns true for JPEG XL files", () => {
const file = new File([], "photo.jxl", { type: "image/jxl" });
expect(needsServerPreview(file)).toBe(true);
});
it("returns true for camera RAW formats", () => {
for (const ext of ["dng", "cr2", "nef", "arw", "orf", "rw2"]) {
const file = new File([], `photo.${ext}`);
expect(needsServerPreview(file)).toBe(true);
}
});
it("returns true for PSD files", () => {
const file = new File([], "design.psd");
expect(needsServerPreview(file)).toBe(true);
});
it("returns true for EXR files", () => {
const file = new File([], "render.exr");
expect(needsServerPreview(file)).toBe(true);
});
it("returns true for HDR files", () => {
const file = new File([], "panorama.hdr");
expect(needsServerPreview(file)).toBe(true);
});
it("returns true for TGA files", () => {
const file = new File([], "texture.tga");
expect(needsServerPreview(file)).toBe(true);
});
it("returns true for ICO files", () => {
const file = new File([], "favicon.ico");
expect(needsServerPreview(file)).toBe(true);
});
it("returns false for standard browser-supported formats", () => {
for (const ext of ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]) {
const file = new File([], `image.${ext}`);
expect(needsServerPreview(file)).toBe(false);
}
});
it("returns false for files with no extension", () => {
const file = new File([], "noext");
expect(needsServerPreview(file)).toBe(false);
});
});
// ==========================================================================
// Collage template helpers (collage-templates.ts)
// ==========================================================================
import {
COLLAGE_TEMPLATES,
getDefaultTemplate,
getTemplateById,
getTemplatesForCount,
} from "@/lib/collage-templates";
describe("getTemplatesForCount", () => {
it("returns templates matching a given image count", () => {
const twoImage = getTemplatesForCount(2);
expect(twoImage.length).toBeGreaterThan(0);
expect(twoImage.every((t) => t.imageCount === 2)).toBe(true);
});
it("returns multiple templates for counts with variants", () => {
const threeImage = getTemplatesForCount(3);
expect(threeImage.length).toBeGreaterThanOrEqual(3);
});
it("returns empty array for unsupported count", () => {
expect(getTemplatesForCount(100)).toEqual([]);
});
});
describe("getDefaultTemplate", () => {
it("returns first exact match for known count", () => {
const template = getDefaultTemplate(2);
expect(template.imageCount).toBe(2);
expect(template.id).toBe("2-h-equal");
});
it("returns nearest template for unknown count", () => {
const template = getDefaultTemplate(10);
expect(template).toBeDefined();
expect(template.cells.length).toBeGreaterThan(0);
});
it("returns a template for count 1", () => {
const template = getDefaultTemplate(1);
expect(template).toBeDefined();
});
});
describe("getTemplateById", () => {
it("finds a template by ID", () => {
const template = getTemplateById("4-grid");
expect(template).toBeDefined();
expect(template?.imageCount).toBe(4);
expect(template?.cells).toHaveLength(4);
});
it("returns undefined for unknown ID", () => {
expect(getTemplateById("nonexistent")).toBeUndefined();
});
});
describe("COLLAGE_TEMPLATES", () => {
it("has templates for counts 2 through 9", () => {
for (let count = 2; count <= 9; count++) {
const templates = getTemplatesForCount(count);
expect(templates.length).toBeGreaterThanOrEqual(1);
}
});
it("every template has cells matching or exceeding imageCount", () => {
for (const t of COLLAGE_TEMPLATES) {
expect(t.cells.length).toBeGreaterThanOrEqual(t.imageCount);
}
});
it("every template has a non-empty label", () => {
for (const t of COLLAGE_TEMPLATES) {
expect(t.label.length).toBeGreaterThan(0);
}
});
});