From 60eb3abaa72f46851b43bd42ce1aa658447186a2 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Tue, 28 Apr 2026 02:27:04 +0800 Subject: [PATCH] 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. --- tests/unit/ai/background-removal.test.ts | 185 +++++++++++++++++++++++ tests/unit/ai/face-detection.test.ts | 16 ++ tests/unit/ai/noise-removal.test.ts | 8 + tests/unit/ai/ocr.test.ts | 8 + tests/unit/ai/red-eye-removal.test.ts | 8 + 5 files changed, 225 insertions(+) diff --git a/tests/unit/ai/background-removal.test.ts b/tests/unit/ai/background-removal.test.ts index 3566aef2..65c9f442 100644 --- a/tests/unit/ai/background-removal.test.ts +++ b/tests/unit/ai/background-removal.test.ts @@ -299,4 +299,189 @@ describe("removeBackground", () => { 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, + ); + + 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, + ); + + 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, + ); + + 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, + ); + + 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).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, + ); + + 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"); + }); + }); }); diff --git a/tests/unit/ai/face-detection.test.ts b/tests/unit/ai/face-detection.test.ts index b3fbc5fa..0500f839 100644 --- a/tests/unit/ai/face-detection.test.ts +++ b/tests/unit/ai/face-detection.test.ts @@ -167,6 +167,14 @@ describe("blurFaces", () => { 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", () => { @@ -282,6 +290,14 @@ describe("detectFaces", () => { 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", () => { diff --git a/tests/unit/ai/noise-removal.test.ts b/tests/unit/ai/noise-removal.test.ts index 4153ac25..66af4a87 100644 --- a/tests/unit/ai/noise-removal.test.ts +++ b/tests/unit/ai/noise-removal.test.ts @@ -225,6 +225,14 @@ describe("noiseRemoval", () => { 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", () => { diff --git a/tests/unit/ai/ocr.test.ts b/tests/unit/ai/ocr.test.ts index d39d272c..8a456627 100644 --- a/tests/unit/ai/ocr.test.ts +++ b/tests/unit/ai/ocr.test.ts @@ -235,6 +235,14 @@ describe("extractText", () => { 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 () => { vi.mocked(parseStdoutJson).mockImplementation(() => { throw new Error("No JSON response from Python script"); diff --git a/tests/unit/ai/red-eye-removal.test.ts b/tests/unit/ai/red-eye-removal.test.ts index 9b55cbf2..27d9b74d 100644 --- a/tests/unit/ai/red-eye-removal.test.ts +++ b/tests/unit/ai/red-eye-removal.test.ts @@ -237,6 +237,14 @@ describe("removeRedEye", () => { 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", () => {