Files
SnapOtter/tests/unit/shared/features.test.ts
T
SnapOtterandGitHub 32c1192d63 fix(passport-photo): require the face-detection bundle, not just background-removal (#329)
* fix(passport-photo): require the face-detection bundle, not just background-removal

Passport Photo runs face-landmark detection (face_landmarks.py, gated to the
face-detection bundle) before background removal (background-removal bundle),
but it was only declared under and guarded against background-removal. A user
who installed only Background Removal passed every JS-side check, then hit a
late "feature_not_installed" from the Python dispatcher gate when the analyze
step ran face landmarks, and the UI never told them Face Detection was needed.

- shared: add TOOL_EXTRA_BUNDLES + getRequiredBundlesForTool so a tool can
  declare more than one required bundle (passport-photo needs background-removal
  and face-detection). enablesTools is untouched, so the one-tool-per-bundle
  invariant still holds.
- api: isToolInstalled() now checks every required bundle; add
  getFirstMissingBundleForTool() so the analyze and base routes, pipeline (both
  guards) and batch report the bundle the user actually still needs.
- web: the proactive install prompt (tool-page) and features-store treat a tool
  as installed only when all required bundles are present, and point the prompt
  at the first missing one (sequential install, no new UI).

Refs #327

* test(passport-photo): deterministic integration coverage for the two-bundle guard

Boots the real API with an isolated DATA_DIR and controls installed.json to
prove the HTTP route behavior end-to-end:
- nothing installed -> 501 naming background-removal
- only background-removal installed -> 501 naming face-detection (issue #327)
- both installed -> guard passes (not 501)
- base route reports face-detection too

Refs #327
2026-06-22 23:31:18 +08:00

153 lines
5.2 KiB
TypeScript

import {
FEATURE_BUNDLES,
getBundleForTool,
getRequiredBundlesForTool,
getToolsForBundle,
PYTHON_SIDECAR_TOOLS,
TOOL_BUNDLE_MAP,
TOOL_EXTRA_BUNDLES,
} from "@snapotter/shared";
import { describe, expect, it } from "vitest";
describe("Feature bundles", () => {
it("every PYTHON_SIDECAR_TOOL maps to exactly one bundle", () => {
for (const toolId of PYTHON_SIDECAR_TOOLS) {
const bundle = getBundleForTool(toolId);
expect(bundle, `${toolId} has no bundle`).toBeDefined();
}
});
it("getBundleForTool returns null for non-AI tools", () => {
expect(getBundleForTool("resize")).toBeNull();
expect(getBundleForTool("crop")).toBeNull();
});
it("getToolsForBundle returns correct tools", () => {
const tools = getToolsForBundle("background-removal");
expect(tools).toContain("remove-background");
expect(tools).toContain("passport-photo");
expect(tools).not.toContain("upscale");
});
it("all 7 bundles are defined", () => {
expect(Object.keys(FEATURE_BUNDLES)).toHaveLength(7);
expect(FEATURE_BUNDLES["background-removal"]).toBeDefined();
expect(FEATURE_BUNDLES["face-detection"]).toBeDefined();
expect(FEATURE_BUNDLES["object-eraser-colorize"]).toBeDefined();
expect(FEATURE_BUNDLES["upscale-enhance"]).toBeDefined();
expect(FEATURE_BUNDLES["photo-restoration"]).toBeDefined();
expect(FEATURE_BUNDLES.ocr).toBeDefined();
expect(FEATURE_BUNDLES.transcription).toBeDefined();
});
it("TOOL_BUNDLE_MAP covers all sidecar tools", () => {
const mappedTools = Object.keys(TOOL_BUNDLE_MAP);
for (const toolId of PYTHON_SIDECAR_TOOLS) {
expect(mappedTools, `${toolId} missing from TOOL_BUNDLE_MAP`).toContain(toolId);
}
});
});
describe("Feature bundle edge cases", () => {
it("no duplicate tools across bundles", () => {
const allTools: string[] = [];
for (const bundle of Object.values(FEATURE_BUNDLES)) {
for (const tool of bundle.enablesTools) {
expect(allTools, `Tool ${tool} appears in multiple bundles`).not.toContain(tool);
allTools.push(tool);
}
}
});
it("every bundle has a non-empty estimated size", () => {
for (const bundle of Object.values(FEATURE_BUNDLES)) {
expect(bundle.estimatedSize.length).toBeGreaterThan(0);
}
});
it("getToolsForBundle returns empty array for unknown bundle", () => {
expect(getToolsForBundle("nonexistent")).toEqual([]);
});
it("getBundleForTool returns null for unknown tool", () => {
expect(getBundleForTool("nonexistent-tool")).toBeNull();
});
it("TOOL_BUNDLE_MAP has no undefined values", () => {
for (const [tool, bundle] of Object.entries(TOOL_BUNDLE_MAP)) {
expect(bundle, `Tool ${tool} has undefined bundle`).toBeDefined();
expect(
FEATURE_BUNDLES[bundle],
`Bundle ${bundle} for tool ${tool} not in FEATURE_BUNDLES`,
).toBeDefined();
}
});
it("every bundle id matches its key in FEATURE_BUNDLES", () => {
for (const [key, bundle] of Object.entries(FEATURE_BUNDLES)) {
expect(bundle.id).toBe(key);
}
});
it("every bundle has a non-empty name and description", () => {
for (const bundle of Object.values(FEATURE_BUNDLES)) {
expect(bundle.name.length).toBeGreaterThan(0);
expect(bundle.description.length).toBeGreaterThan(0);
}
});
it("every bundle has at least one tool", () => {
for (const [id, bundle] of Object.entries(FEATURE_BUNDLES)) {
expect(bundle.enablesTools.length, `Bundle ${id} has no tools`).toBeGreaterThan(0);
}
});
});
describe("getRequiredBundlesForTool", () => {
it("returns the primary bundle for a single-bundle tool", () => {
expect(getRequiredBundlesForTool("remove-background")).toEqual(["background-removal"]);
});
it("returns [] for non-AI tools", () => {
expect(getRequiredBundlesForTool("resize")).toEqual([]);
expect(getRequiredBundlesForTool("nonexistent-tool")).toEqual([]);
});
it("includes the primary bundle plus extras for cross-bundle tools", () => {
// Passport Photo runs face-landmark detection (face-detection) on top of
// background removal (its primary bundle).
expect(getRequiredBundlesForTool("passport-photo")).toEqual([
"background-removal",
"face-detection",
]);
});
it("lists the primary bundle first", () => {
for (const toolId of PYTHON_SIDECAR_TOOLS) {
const required = getRequiredBundlesForTool(toolId);
if (required.length > 0) {
expect(required[0]).toBe(TOOL_BUNDLE_MAP[toolId]);
}
}
});
it("never lists a bundle twice", () => {
for (const toolId of PYTHON_SIDECAR_TOOLS) {
const required = getRequiredBundlesForTool(toolId);
expect(new Set(required).size).toBe(required.length);
}
});
});
describe("TOOL_EXTRA_BUNDLES", () => {
it("references only real bundles and never the tool's own primary bundle", () => {
for (const [toolId, extras] of Object.entries(TOOL_EXTRA_BUNDLES)) {
const primary = TOOL_BUNDLE_MAP[toolId];
for (const bundleId of extras) {
expect(FEATURE_BUNDLES[bundleId], `Unknown extra bundle ${bundleId}`).toBeDefined();
expect(bundleId, `${toolId} lists its primary bundle as an extra`).not.toBe(primary);
}
}
});
});