mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
328 lines
10 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|