mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: add OOM and downscaling test coverage across AI features
Add 12 tests for background-removal downscaling (resize gate, portrait orientation, mask upscale) and OOM model fallback (retry with u2net, progress callback, no-retry guards, cascading failure). Add OOM propagation tests to face-detection, noise-removal, red-eye-removal, and OCR -- the four AI features that were missing them.
This commit is contained in:
@@ -299,4 +299,189 @@ describe("removeBackground", () => {
|
|||||||
expect(options.onProgress).toBeUndefined();
|
expect(options.onProgress).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("image downscaling", () => {
|
||||||
|
it("does not call resize when image is within limit", async () => {
|
||||||
|
const resizeFn = vi.fn().mockReturnThis();
|
||||||
|
vi.mocked(sharp).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
png: vi.fn().mockReturnThis(),
|
||||||
|
resize: resizeFn,
|
||||||
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||||
|
metadata: vi.fn().mockResolvedValue({ width: 1024, height: 768 }),
|
||||||
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||||
|
|
||||||
|
expect(resizeFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls resize when longest edge exceeds 2048px", async () => {
|
||||||
|
const resizeFn = vi.fn().mockReturnThis();
|
||||||
|
vi.mocked(sharp).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
png: vi.fn().mockReturnThis(),
|
||||||
|
resize: resizeFn,
|
||||||
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||||
|
metadata: vi.fn().mockResolvedValue({ width: 4000, height: 3000 }),
|
||||||
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||||
|
|
||||||
|
expect(resizeFn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ width: 2048, fit: "inside", withoutEnlargement: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constrains by height when portrait orientation", async () => {
|
||||||
|
const resizeFn = vi.fn().mockReturnThis();
|
||||||
|
vi.mocked(sharp).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
png: vi.fn().mockReturnThis(),
|
||||||
|
resize: resizeFn,
|
||||||
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||||
|
metadata: vi.fn().mockResolvedValue({ width: 2000, height: 4000 }),
|
||||||
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||||
|
|
||||||
|
expect(resizeFn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ height: 2048, fit: "inside" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upscales mask back to original dimensions after processing", async () => {
|
||||||
|
const callCount = 0;
|
||||||
|
const resizeFn = vi.fn().mockReturnThis();
|
||||||
|
vi.mocked(sharp).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
png: vi.fn().mockReturnThis(),
|
||||||
|
resize: resizeFn,
|
||||||
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||||
|
metadata: vi.fn().mockResolvedValue({ width: 5000, height: 3000 }),
|
||||||
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||||
|
|
||||||
|
const upscaleCall = resizeFn.mock.calls.find(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
c[0] && typeof c[0] === "object" && (c[0] as Record<string, unknown>).width === 5000,
|
||||||
|
);
|
||||||
|
expect(upscaleCall).toBeDefined();
|
||||||
|
expect(upscaleCall![0]).toMatchObject({ width: 5000, height: 3000, fit: "fill" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not upscale mask when image was not downscaled", async () => {
|
||||||
|
const resizeFn = vi.fn().mockReturnThis();
|
||||||
|
vi.mocked(sharp).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
png: vi.fn().mockReturnThis(),
|
||||||
|
resize: resizeFn,
|
||||||
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||||
|
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||||
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||||
|
|
||||||
|
expect(resizeFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OOM fallback", () => {
|
||||||
|
it("retries with u2net when OOM is detected", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress)
|
||||||
|
.mockRejectedValueOnce(new Error("Process killed (out of memory)"))
|
||||||
|
.mockResolvedValueOnce({ stdout: '{"success": true}', stderr: "" });
|
||||||
|
|
||||||
|
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { model: "birefnet-general" });
|
||||||
|
|
||||||
|
expect(runPythonWithProgress).toHaveBeenCalledTimes(2);
|
||||||
|
const fallbackArgs = vi.mocked(runPythonWithProgress).mock.calls[1][1];
|
||||||
|
const fallbackOpts = JSON.parse(fallbackArgs[2]);
|
||||||
|
expect(fallbackOpts.model).toBe("u2net");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires progress callback on fallback retry", async () => {
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
vi.mocked(runPythonWithProgress)
|
||||||
|
.mockRejectedValueOnce(new Error("Process killed (out of memory)"))
|
||||||
|
.mockResolvedValueOnce({ stdout: '{"success": true}', stderr: "" });
|
||||||
|
|
||||||
|
await removeBackground(
|
||||||
|
FAKE_INPUT,
|
||||||
|
FAKE_OUTPUT_DIR,
|
||||||
|
{ model: "birefnet-general" },
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onProgress).toHaveBeenCalledWith(5, expect.stringContaining("u2net"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry when already using u2net", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||||
|
new Error("Process killed (out of memory)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { model: "u2net" }),
|
||||||
|
).rejects.toThrow("out of memory");
|
||||||
|
|
||||||
|
expect(runPythonWithProgress).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry on non-OOM errors", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress).mockRejectedValue(new Error("Python script timed out"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { model: "birefnet-general" }),
|
||||||
|
).rejects.toThrow("timed out");
|
||||||
|
|
||||||
|
expect(runPythonWithProgress).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 300000ms timeout for the u2net fallback attempt", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress)
|
||||||
|
.mockRejectedValueOnce(new Error("Process killed (out of memory)"))
|
||||||
|
.mockResolvedValueOnce({ stdout: '{"success": true}', stderr: "" });
|
||||||
|
|
||||||
|
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { model: "birefnet-general" });
|
||||||
|
|
||||||
|
const fallbackOptions = vi.mocked(runPythonWithProgress).mock.calls[1][2];
|
||||||
|
expect(fallbackOptions.timeout).toBe(300000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates error if fallback also fails", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress)
|
||||||
|
.mockRejectedValueOnce(new Error("Process killed (out of memory)"))
|
||||||
|
.mockRejectedValueOnce(new Error("Process killed (out of memory)"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { model: "birefnet-general" }),
|
||||||
|
).rejects.toThrow("out of memory");
|
||||||
|
|
||||||
|
expect(runPythonWithProgress).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries with fallback when no model is specified (default)", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress)
|
||||||
|
.mockRejectedValueOnce(new Error("Process killed (out of memory)"))
|
||||||
|
.mockResolvedValueOnce({ stdout: '{"success": true}', stderr: "" });
|
||||||
|
|
||||||
|
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||||
|
|
||||||
|
expect(runPythonWithProgress).toHaveBeenCalledTimes(2);
|
||||||
|
const fallbackArgs = vi.mocked(runPythonWithProgress).mock.calls[1][1];
|
||||||
|
expect(JSON.parse(fallbackArgs[2]).model).toBe("u2net");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -167,6 +167,14 @@ describe("blurFaces", () => {
|
|||||||
|
|
||||||
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("timed out");
|
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("timed out");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagates OOM errors from bridge", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||||
|
new Error("Process killed (out of memory)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("onProgress forwarding", () => {
|
describe("onProgress forwarding", () => {
|
||||||
@@ -282,6 +290,14 @@ describe("detectFaces", () => {
|
|||||||
|
|
||||||
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow("segmentation fault");
|
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow("segmentation fault");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagates OOM errors from bridge", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||||
|
new Error("Process killed (out of memory)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow("out of memory");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("onProgress forwarding", () => {
|
describe("onProgress forwarding", () => {
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ describe("noiseRemoval", () => {
|
|||||||
|
|
||||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("timed out");
|
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("timed out");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagates OOM errors from bridge", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||||
|
new Error("Process killed (out of memory)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("onProgress forwarding", () => {
|
describe("onProgress forwarding", () => {
|
||||||
|
|||||||
@@ -235,6 +235,14 @@ describe("extractText", () => {
|
|||||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("timed out");
|
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("timed out");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagates OOM errors from bridge", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||||
|
new Error("Process killed (out of memory)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||||
|
});
|
||||||
|
|
||||||
it("propagates parseStdoutJson errors", async () => {
|
it("propagates parseStdoutJson errors", async () => {
|
||||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||||
throw new Error("No JSON response from Python script");
|
throw new Error("No JSON response from Python script");
|
||||||
|
|||||||
@@ -237,6 +237,14 @@ describe("removeRedEye", () => {
|
|||||||
|
|
||||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagates OOM errors from bridge", async () => {
|
||||||
|
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||||
|
new Error("Process killed (out of memory)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("onProgress forwarding", () => {
|
describe("onProgress forwarding", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user