mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Replace content-aware-crop with ai-canvas-expand in TOOLS[], AI_TOOL_IDS,
and FEATURE_BUNDLES (matching the already-updated tool-registry.tsx and
feature-manifest.json from commit c6a5d3f)
- Fix trailing syntax error in features.ts (extra closing brace)
- Add ai-canvas-expand-settings mock to tool-registry test files
- Update watermark-image tests to expect 400 (validation rejection) instead
of 422 (processing failure) for corrupted image buffers, matching the
actual route behavior where validateImageBuffer catches them first
536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock all lazy-loaded tool settings components so we don't pull in the
|
|
// entire React component tree. Each dynamic import returns a minimal stub.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock("@/components/tools/resize-settings", () => ({
|
|
ResizeSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/crop-settings", () => ({
|
|
CropSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/rotate-settings", () => ({
|
|
RotateSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/convert-settings", () => ({
|
|
ConvertSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/compress-settings", () => ({
|
|
CompressSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/optimize-for-web-settings", () => ({
|
|
OptimizeForWebSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/strip-metadata-settings", () => ({
|
|
StripMetadataSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/edit-metadata-settings", () => ({
|
|
EditMetadataSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/color-settings", () => ({
|
|
ColorSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/sharpening-settings", () => ({
|
|
SharpeningSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/watermark-text-settings", () => ({
|
|
WatermarkTextSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/watermark-image-settings", () => ({
|
|
WatermarkImageSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/text-overlay-settings", () => ({
|
|
TextOverlaySettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/compose-settings", () => ({
|
|
ComposeSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/info-settings", () => ({
|
|
InfoSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/compare-settings", () => ({
|
|
CompareSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/find-duplicates-settings", () => ({
|
|
FindDuplicatesSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/find-duplicates-results", () => ({
|
|
FindDuplicatesResults: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/color-palette-settings", () => ({
|
|
ColorPaletteSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/qr-generate-settings", () => ({
|
|
QrGenerateSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/qr-generate-preview", () => ({
|
|
QrGeneratePreview: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/barcode-read-settings", () => ({
|
|
BarcodeReadSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/image-to-base64-settings", () => ({
|
|
ImageToBase64Settings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/image-to-base64-results", () => ({
|
|
ImageToBase64Results: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/collage-settings", () => ({
|
|
CollageSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/collage-preview", () => ({
|
|
CollagePreview: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/stitch-settings", () => ({
|
|
StitchSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/split-settings", () => ({
|
|
SplitSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/split-canvas", () => ({
|
|
SplitCanvas: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/border-settings", () => ({
|
|
BorderSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/svg-to-raster-settings", () => ({
|
|
SvgToRasterSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/vectorize-settings", () => ({
|
|
VectorizeSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/gif-tools-settings", () => ({
|
|
GifToolsSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/bulk-rename-settings", () => ({
|
|
BulkRenameSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/favicon-settings", () => ({
|
|
FaviconSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/image-to-pdf-settings", () => ({
|
|
ImageToPdfSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/pdf-to-image-settings", () => ({
|
|
PdfToImageSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/pdf-to-image-preview", () => ({
|
|
PdfToImagePreview: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/replace-color-settings", () => ({
|
|
ReplaceColorSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/remove-bg-settings", () => ({
|
|
RemoveBgSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/upscale-settings", () => ({
|
|
UpscaleSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/ocr-settings", () => ({
|
|
OcrSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/blur-faces-settings", () => ({
|
|
BlurFacesSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/enhance-faces-settings", () => ({
|
|
EnhanceFacesSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/erase-object-settings", () => ({
|
|
EraseObjectSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/smart-crop-settings", () => ({
|
|
SmartCropSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/image-enhancement-settings", () => ({
|
|
ImageEnhancementSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/colorize-settings", () => ({
|
|
ColorizeSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/noise-removal-settings", () => ({
|
|
NoiseRemovalSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/passport-photo-settings", () => ({
|
|
PassportPhotoSettings: () => null,
|
|
PassportPhotoPreview: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/red-eye-removal-settings", () => ({
|
|
RedEyeRemovalSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/restore-photo-settings", () => ({
|
|
RestorePhotoSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/transparency-fixer-settings", () => ({
|
|
TransparencyFixerSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/content-aware-resize-settings", () => ({
|
|
ContentAwareResizeSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/beautify-settings", () => ({
|
|
BeautifySettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/meme-generator-settings", () => ({
|
|
MemeGeneratorSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/meme-generator-preview", () => ({
|
|
MemeGeneratorPreview: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/color-blindness-settings", () => ({
|
|
ColorBlindnessSettings: () => null,
|
|
}));
|
|
vi.mock("@/components/tools/ai-canvas-expand-settings", () => ({
|
|
AiCanvasExpandSettings: () => null,
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import after mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { TOOLS } from "@snapotter/shared";
|
|
import type { DisplayMode } from "@/lib/tool-registry";
|
|
import { getToolRegistryEntry, toolRegistry } from "@/lib/tool-registry";
|
|
|
|
// ==========================================================================
|
|
// toolRegistry (Map)
|
|
// ==========================================================================
|
|
|
|
describe("toolRegistry", () => {
|
|
it("is a Map with entries", () => {
|
|
expect(toolRegistry).toBeInstanceOf(Map);
|
|
expect(toolRegistry.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("contains all expected essential tool IDs", () => {
|
|
const essentials = [
|
|
"resize",
|
|
"crop",
|
|
"rotate",
|
|
"convert",
|
|
"compress",
|
|
"strip-metadata",
|
|
"edit-metadata",
|
|
];
|
|
for (const id of essentials) {
|
|
expect(toolRegistry.has(id), `missing tool: ${id}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("contains AI tool IDs", () => {
|
|
const aiTools = [
|
|
"remove-background",
|
|
"upscale",
|
|
"ocr",
|
|
"blur-faces",
|
|
"enhance-faces",
|
|
"erase-object",
|
|
"smart-crop",
|
|
"image-enhancement",
|
|
"colorize",
|
|
"noise-removal",
|
|
"passport-photo",
|
|
"red-eye-removal",
|
|
"restore-photo",
|
|
"content-aware-resize",
|
|
];
|
|
for (const id of aiTools) {
|
|
expect(toolRegistry.has(id), `missing AI tool: ${id}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("contains layout and composition tools", () => {
|
|
const layoutTools = ["collage", "stitch", "split", "border"];
|
|
for (const id of layoutTools) {
|
|
expect(toolRegistry.has(id), `missing layout tool: ${id}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("contains utility tools", () => {
|
|
const utilityTools = [
|
|
"info",
|
|
"compare",
|
|
"find-duplicates",
|
|
"color-palette",
|
|
"qr-generate",
|
|
"barcode-read",
|
|
"image-to-base64",
|
|
];
|
|
for (const id of utilityTools) {
|
|
expect(toolRegistry.has(id), `missing utility tool: ${id}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("contains format and conversion tools", () => {
|
|
const formatTools = [
|
|
"svg-to-raster",
|
|
"vectorize",
|
|
"gif-tools",
|
|
"bulk-rename",
|
|
"favicon",
|
|
"image-to-pdf",
|
|
"pdf-to-image",
|
|
"optimize-for-web",
|
|
];
|
|
for (const id of formatTools) {
|
|
expect(toolRegistry.has(id), `missing format tool: ${id}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("every entry has a valid displayMode", () => {
|
|
const validModes: DisplayMode[] = [
|
|
"side-by-side",
|
|
"before-after",
|
|
"live-preview",
|
|
"no-comparison",
|
|
"interactive-crop",
|
|
"interactive-eraser",
|
|
"interactive-split",
|
|
"no-dropzone",
|
|
"custom-results",
|
|
];
|
|
for (const [toolId, entry] of toolRegistry) {
|
|
expect(validModes, `invalid displayMode for ${toolId}`).toContain(entry.displayMode);
|
|
}
|
|
});
|
|
|
|
it("every entry has a Settings component", () => {
|
|
for (const [toolId, entry] of toolRegistry) {
|
|
expect(entry.Settings, `missing Settings for ${toolId}`).toBeDefined();
|
|
expect(["function", "object"]).toContain(typeof entry.Settings);
|
|
}
|
|
});
|
|
|
|
it("tools with custom-results display mode have a ResultsPanel", () => {
|
|
for (const [toolId, entry] of toolRegistry) {
|
|
if (entry.displayMode === "custom-results") {
|
|
expect(entry.ResultsPanel, `missing ResultsPanel for ${toolId}`).toBeDefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("tools with no-dropzone display mode have a ResultsPanel", () => {
|
|
const selfContainedTools = new Set(["meme-generator"]);
|
|
for (const [toolId, entry] of toolRegistry) {
|
|
if (entry.displayMode === "no-dropzone" && !selfContainedTools.has(toolId)) {
|
|
expect(entry.ResultsPanel, `missing ResultsPanel for ${toolId}`).toBeDefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rotate has livePreview enabled", () => {
|
|
const rotate = toolRegistry.get("rotate");
|
|
expect(rotate?.livePreview).toBe(true);
|
|
});
|
|
|
|
it("adjust-colors has livePreview enabled", () => {
|
|
const adjustColors = toolRegistry.get("adjust-colors");
|
|
expect(adjustColors?.livePreview).toBe(true);
|
|
});
|
|
|
|
it("border has livePreview enabled", () => {
|
|
const border = toolRegistry.get("border");
|
|
expect(border?.livePreview).toBe(true);
|
|
});
|
|
|
|
it("beautify has livePreview enabled", () => {
|
|
const beautify = toolRegistry.get("beautify");
|
|
expect(beautify).toBeDefined();
|
|
expect(beautify?.livePreview).toBe(true);
|
|
expect(beautify?.displayMode).toBe("live-preview");
|
|
});
|
|
|
|
it("meme-generator has no-dropzone display mode with ResultsPanel", () => {
|
|
const meme = toolRegistry.get("meme-generator");
|
|
expect(meme).toBeDefined();
|
|
expect(meme?.displayMode).toBe("no-dropzone");
|
|
expect(meme?.ResultsPanel).toBeDefined();
|
|
expect(meme?.Settings).toBeDefined();
|
|
});
|
|
|
|
it("color-blindness uses before-after display mode", () => {
|
|
const cb = toolRegistry.get("color-blindness");
|
|
expect(cb).toBeDefined();
|
|
expect(cb?.displayMode).toBe("before-after");
|
|
expect(cb?.Settings).toBeDefined();
|
|
});
|
|
|
|
it("transparency-fixer uses before-after display mode", () => {
|
|
const tf = toolRegistry.get("transparency-fixer");
|
|
expect(tf).toBeDefined();
|
|
expect(tf?.displayMode).toBe("before-after");
|
|
expect(tf?.Settings).toBeDefined();
|
|
});
|
|
|
|
it("crop uses interactive-crop display mode", () => {
|
|
const crop = toolRegistry.get("crop");
|
|
expect(crop?.displayMode).toBe("interactive-crop");
|
|
});
|
|
|
|
it("erase-object uses interactive-eraser display mode", () => {
|
|
const eraseObject = toolRegistry.get("erase-object");
|
|
expect(eraseObject?.displayMode).toBe("interactive-eraser");
|
|
});
|
|
|
|
it("split uses interactive-split display mode", () => {
|
|
const split = toolRegistry.get("split");
|
|
expect(split?.displayMode).toBe("interactive-split");
|
|
});
|
|
|
|
it("every tool in shared TOOLS[] has a matching registry entry", () => {
|
|
const missing: string[] = [];
|
|
for (const tool of TOOLS) {
|
|
if (!toolRegistry.has(tool.id)) {
|
|
missing.push(tool.id);
|
|
}
|
|
}
|
|
expect(
|
|
missing,
|
|
`Tools defined in TOOLS[] but missing from toolRegistry: ${missing.join(", ")}`,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// getToolRegistryEntry
|
|
// ==========================================================================
|
|
|
|
describe("getToolRegistryEntry", () => {
|
|
it("returns the entry for a known tool", () => {
|
|
const entry = getToolRegistryEntry("resize");
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.displayMode).toBe("side-by-side");
|
|
});
|
|
|
|
it("returns undefined for an unknown tool", () => {
|
|
expect(getToolRegistryEntry("nonexistent-tool")).toBeUndefined();
|
|
});
|
|
|
|
it("returns the correct entry for compress", () => {
|
|
const entry = getToolRegistryEntry("compress");
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.displayMode).toBe("before-after");
|
|
});
|
|
|
|
it("returns entry with ResultsPanel for find-duplicates", () => {
|
|
const entry = getToolRegistryEntry("find-duplicates");
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.displayMode).toBe("custom-results");
|
|
expect(entry?.ResultsPanel).toBeDefined();
|
|
});
|
|
|
|
it("returns entry with ResultsPanel for qr-generate", () => {
|
|
const entry = getToolRegistryEntry("qr-generate");
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.displayMode).toBe("no-dropzone");
|
|
expect(entry?.ResultsPanel).toBeDefined();
|
|
});
|
|
|
|
it("returns entry for content-aware-resize with side-by-side display (regression #131)", () => {
|
|
const entry = getToolRegistryEntry("content-aware-resize");
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.displayMode).toBe("side-by-side");
|
|
expect(entry?.Settings).toBeDefined();
|
|
});
|
|
|
|
it("returns entry for beautify with live-preview display", () => {
|
|
const entry = getToolRegistryEntry("beautify");
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.displayMode).toBe("live-preview");
|
|
expect(entry?.livePreview).toBe(true);
|
|
});
|
|
|
|
it("returns entry for meme-generator with ResultsPanel", () => {
|
|
const entry = getToolRegistryEntry("meme-generator");
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.displayMode).toBe("no-dropzone");
|
|
expect(entry?.ResultsPanel).toBeDefined();
|
|
});
|
|
|
|
it("returns entry for color-blindness", () => {
|
|
const entry = getToolRegistryEntry("color-blindness");
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.displayMode).toBe("before-after");
|
|
});
|
|
|
|
it("no duplicate tool IDs in registry", () => {
|
|
const ids = [...toolRegistry.keys()];
|
|
const unique = new Set(ids);
|
|
expect(ids.length).toBe(unique.size);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Wrapper components (lines 305-324)
|
|
// ==========================================================================
|
|
|
|
import { render } from "@testing-library/react";
|
|
import React from "react";
|
|
|
|
function renderSettings(Settings: React.ComponentType<Record<string, unknown>>, props = {}) {
|
|
return render(
|
|
React.createElement(React.Suspense, { fallback: null }, React.createElement(Settings, props)),
|
|
);
|
|
}
|
|
|
|
describe("CropSettingsWrapper", () => {
|
|
it("renders null when cropProps is undefined", () => {
|
|
const entry = getToolRegistryEntry("crop");
|
|
expect(entry).toBeDefined();
|
|
const { container } = renderSettings(entry?.Settings as never);
|
|
expect(container.innerHTML).toBe("");
|
|
});
|
|
|
|
it("renders CropSettings when cropProps is provided", () => {
|
|
const entry = getToolRegistryEntry("crop");
|
|
expect(entry).toBeDefined();
|
|
const cropProps = {
|
|
cropState: {
|
|
crop: { x: 0, y: 0, width: 50, height: 50, unit: "%" },
|
|
aspect: undefined,
|
|
showGrid: true,
|
|
imgDimensions: { width: 200, height: 150 },
|
|
},
|
|
onCropChange: vi.fn(),
|
|
onAspectChange: vi.fn(),
|
|
onGridToggle: vi.fn(),
|
|
};
|
|
const { container } = renderSettings(entry?.Settings as never, { cropProps });
|
|
expect(container).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("EraseObjectSettingsWrapper", () => {
|
|
it("renders null when eraserProps is undefined", () => {
|
|
const entry = getToolRegistryEntry("erase-object");
|
|
expect(entry).toBeDefined();
|
|
const { container } = renderSettings(entry?.Settings as never);
|
|
expect(container.innerHTML).toBe("");
|
|
});
|
|
|
|
it("renders EraseObjectSettings when eraserProps is provided", () => {
|
|
const entry = getToolRegistryEntry("erase-object");
|
|
expect(entry).toBeDefined();
|
|
const eraserProps = {
|
|
eraserRef: React.createRef(),
|
|
hasStrokes: false,
|
|
brushSize: 20,
|
|
onBrushSizeChange: vi.fn(),
|
|
};
|
|
const { container } = renderSettings(entry?.Settings as never, { eraserProps });
|
|
expect(container).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("makeColorSettingsComponent", () => {
|
|
it("adjust-colors Settings renders without throwing", () => {
|
|
const entry = getToolRegistryEntry("adjust-colors");
|
|
expect(entry).toBeDefined();
|
|
const { container } = renderSettings(entry?.Settings as never, {
|
|
onPreviewFilter: vi.fn(),
|
|
});
|
|
expect(container).toBeDefined();
|
|
});
|
|
});
|