198 lines · 7.0 KB · TypeScript
RawDownload
import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { compressImageForStash, MAX_STASH_IMAGE_DATA_URL_CHARS } from "./stashImageCompression"; /** * jsdom has no real canvas/codec, so the re-encode path is exercised with * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a * payload whose size scales with quality, mirroring how a real JPEG encoder * shrinks as quality drops — enough to verify the ladder logic and budget * enforcement without pulling in a native canvas. */ const originalCreateImageBitmap = globalThis.createImageBitmap;const originalOffscreenCanvas = globalThis.OffscreenCanvas; function makeFile(sizeBytes: number, type = "image/png"): File {  return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });} /** * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed * blob for WebP requests, which is how a real browser signals it cannot * encode that format. */function stubCanvasPipeline(  sizeForQuality: (quality: number) => number,  options?: { supportsWebp?: boolean },) {  const supportsWebp = options?.supportsWebp ?? true;  const close = vi.fn();  const fillRect = vi.fn();  vi.stubGlobal(    "createImageBitmap",    vi.fn(async () => ({ width: 4000, height: 3000, close })),  );  vi.stubGlobal(    "OffscreenCanvas",    class {      constructor(        public width: number,        public height: number,      ) {}      getContext() {        return {          fillStyle: "",          fillRect,          drawImage: vi.fn(),        };      }      async convertToBlob({ type, quality }: { type: string; quality: number }) {        const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type;        return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType });      }    },  );  return { close, fillRect };} afterEach(() => {  vi.unstubAllGlobals();  globalThis.createImageBitmap = originalCreateImageBitmap;  globalThis.OffscreenCanvas = originalOffscreenCanvas;}); describe("compressImageForStash", () => {  it("stores a small image verbatim without re-encoding", async () => {    const bitmapSpy = vi.fn();    vi.stubGlobal("createImageBitmap", bitmapSpy);     const result = await compressImageForStash(makeFile(1024));     expect(result.ok).toBe(true);    expect(result.ok && result.image.recompressed).toBe(false);    expect(result.ok && result.image.mimeType).toBe("image/png");    expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true);    // Untouched payloads must not pay for a decode.    expect(bitmapSpy).not.toHaveBeenCalled();  });   it("re-encodes an oversized image to WebP within the budget", async () => {    // Comfortably under budget at the very first quality step.    const { close, fillRect } = stubCanvasPipeline(() => 120_000);     const result = await compressImageForStash(makeFile(4_000_000));     expect(result.ok).toBe(true);    expect(result.ok && result.image.recompressed).toBe(true);    expect(result.ok && result.image.mimeType).toBe("image/webp");    expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);    // sizeBytes should describe the re-encoded payload, not the 4MB original.    expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000);    // WebP keeps alpha, so no white matte should be painted.    expect(fillRect).not.toHaveBeenCalled();    expect(close).toHaveBeenCalled();  });   it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => {    const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false });     const result = await compressImageForStash(makeFile(4_000_000));     expect(result.ok && result.image.recompressed).toBe(true);    expect(result.ok && result.image.mimeType).toBe("image/jpeg");    // JPEG has no alpha, so transparent regions must be matted white.    expect(fillRect).toHaveBeenCalled();  });   it("steps quality down until the encoded image fits", async () => {    // Only the lowest quality step (0.68) lands under the budget.    const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000));     const result = await compressImageForStash(makeFile(9_000_000));     expect(result.ok && result.image.recompressed).toBe(true);    expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);    expect(close).toHaveBeenCalled();  });   it("reports too-large when even the smallest encoding overflows the budget", async () => {    const { close } = stubCanvasPipeline(() => 8_000_000);     const result = await compressImageForStash(makeFile(9_000_000));     expect(result).toEqual({ ok: false, reason: "too-large" });    // The bitmap must still be released on the give-up path.    expect(close).toHaveBeenCalled();  });   it("reports too-large for an oversized image when the browser cannot re-encode", async () => {    vi.stubGlobal("createImageBitmap", undefined);    vi.stubGlobal("OffscreenCanvas", undefined);     expect(await compressImageForStash(makeFile(4_000_000))).toEqual({      ok: false,      reason: "too-large",    });  });   it("reports unreadable when the image fails to decode", async () => {    vi.stubGlobal(      "createImageBitmap",      vi.fn(async () => {        throw new Error("corrupt image");      }),    );    vi.stubGlobal(      "OffscreenCanvas",      class {        getContext() {          return null;        }      },    );     expect(await compressImageForStash(makeFile(4_000_000))).toEqual({      ok: false,      reason: "unreadable",    });  });   it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => {    // A small-but-heavy source (e.g. a dense PNG): only a real downscale can    // get it under budget, since quality alone is stubbed to never suffice.    let smallestRequested = Number.POSITIVE_INFINITY;    const close = vi.fn();    vi.stubGlobal(      "createImageBitmap",      vi.fn(async () => ({ width: 800, height: 600, close })),    );    vi.stubGlobal(      "OffscreenCanvas",      class {        constructor(          public width: number,          public height: number,        ) {          smallestRequested = Math.min(smallestRequested, width);        }        getContext() {          return { fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() };        }        async convertToBlob({ type }: { type: string; quality: number }) {          // Only a genuinely downscaled pass fits the budget.          const size = smallestRequested < 800 ? 100_000 : 5_000_000;          return new Blob([new Uint8Array(size)], { type });        }      },    );     const result = await compressImageForStash(makeFile(4_000_000));     expect(result.ok).toBe(true);    // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling    // that would never go below an 800px source.    expect(smallestRequested).toBeLessThan(800);  });});