Files
SnapOtter/packages/image-engine/tests/operations.test.ts
T
Matt Van HornandGitHub 9052da27f3 fix: adopt sharp 0.35.2+ by centralizing the FormatEnum key type (#362)
Centralizes the sharp format key type into a single `SharpFormat` alias derived from `toFormat()`'s signature (exported from @snapotter/image-engine, imported by the API consumers), replacing the duplicated `keyof FormatEnum` definitions. Adds convert/compress format round-trip tests covering webp/avif/png/jpeg. Part of #325.
2026-07-01 21:50:29 +08:00

430 lines
13 KiB
TypeScript

import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
import { processImage } from "../src/engine.js";
import { detectFormat } from "../src/formats/detect.js";
import { brightness } from "../src/operations/brightness.js";
import { colorChannels } from "../src/operations/color-channels.js";
import { compress } from "../src/operations/compress.js";
import { contrast } from "../src/operations/contrast.js";
import { convert } from "../src/operations/convert.js";
import { crop } from "../src/operations/crop.js";
import { flip } from "../src/operations/flip.js";
import { grayscale } from "../src/operations/grayscale.js";
import { invert } from "../src/operations/invert.js";
import { resize } from "../src/operations/resize.js";
import { rotate } from "../src/operations/rotate.js";
import { saturation } from "../src/operations/saturation.js";
import { sepia } from "../src/operations/sepia.js";
import { stripMetadata } from "../src/operations/strip-metadata.js";
import type { OutputFormat, Sharp, SharpFormat } from "../src/types.js";
import { getImageInfo } from "../src/utils/metadata.js";
import { extToMime, formatToExt, formatToMime, mimeToExt } from "../src/utils/mime.js";
// Generate a 100x100 red PNG buffer for testing
let testBuffer: Buffer;
let testImage: () => Sharp;
const SHARP_FORMAT_CASES = [
{ sharpFormat: "webp", operationFormat: "webp", expectedFormat: "webp" },
{ sharpFormat: "avif", operationFormat: "avif", expectedFormat: "avif" },
{ sharpFormat: "png", operationFormat: "png", expectedFormat: "png" },
{ sharpFormat: "jpeg", operationFormat: "jpg", expectedFormat: "jpeg" },
] as const satisfies ReadonlyArray<{
sharpFormat: SharpFormat;
operationFormat: OutputFormat;
expectedFormat: string;
}>;
function tinyTestImage(): Sharp {
return sharp({
create: {
width: 4,
height: 4,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
});
}
async function expectOutputFormat(buffer: Buffer, expectedFormat: string): Promise<void> {
const meta = await sharp(buffer).metadata();
const format = meta.format === "heif" ? "avif" : meta.format;
expect(format).toBe(expectedFormat);
}
beforeAll(async () => {
testBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
testImage = () => sharp(testBuffer);
});
describe("resize", () => {
it("should resize to 50x50", async () => {
const result = await resize(testImage(), { width: 50, height: 50 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
it("should resize by percentage", async () => {
const result = await resize(testImage(), { percentage: 50 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
it("should throw on zero width", async () => {
await expect(resize(testImage(), { width: 0 })).rejects.toThrow();
});
it("should throw when no dimensions provided", async () => {
await expect(resize(testImage(), {})).rejects.toThrow();
});
});
describe("crop", () => {
it("should crop 25x25 at (10,10)", async () => {
const result = await crop(testImage(), {
left: 10,
top: 10,
width: 25,
height: 25,
});
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(25);
expect(meta.height).toBe(25);
});
it("should throw on out-of-bounds crop", async () => {
await expect(crop(testImage(), { left: 90, top: 90, width: 20, height: 20 })).rejects.toThrow();
});
it("should throw on zero dimensions", async () => {
await expect(crop(testImage(), { left: 0, top: 0, width: 0, height: 10 })).rejects.toThrow();
});
});
describe("rotate", () => {
it("should rotate 90 degrees and swap dimensions", async () => {
// Create a non-square image to verify dimension swap
const rectBuffer = await sharp({
create: {
width: 100,
height: 50,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const result = await rotate(sharp(rectBuffer), { angle: 90 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(100);
});
it("should rotate non-90 angle with background", async () => {
const result = await rotate(testImage(), {
angle: 45,
background: "#FF0000",
});
const meta = await result.metadata();
expect(meta.width).toBeGreaterThan(0);
expect(meta.height).toBeGreaterThan(0);
});
});
describe("flip", () => {
it("should flip horizontally without error", async () => {
const result = await flip(testImage(), { horizontal: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should flip vertically without error", async () => {
const result = await flip(testImage(), { vertical: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should flip both directions", async () => {
const result = await flip(testImage(), { horizontal: true, vertical: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw when neither direction specified", async () => {
await expect(flip(testImage(), {})).rejects.toThrow();
});
});
describe("convert", () => {
it("should convert to webp", async () => {
const result = await convert(testImage(), { format: "webp" });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.format).toBe("webp");
});
it("should convert to jpg with quality", async () => {
const result = await convert(testImage(), { format: "jpg", quality: 80 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.format).toBe("jpeg");
});
it("should throw on invalid quality", async () => {
await expect(convert(testImage(), { format: "png", quality: 0 })).rejects.toThrow();
});
});
describe("compress", () => {
it("should compress at quality 50 and produce smaller output", async () => {
// Use a larger image for better compression ratio visibility
const largeBuffer = await sharp({
create: {
width: 500,
height: 500,
channels: 3,
background: { r: 255, g: 128, b: 0 },
},
})
.jpeg({ quality: 100 })
.toBuffer();
const result = await compress(sharp(largeBuffer), {
quality: 50,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThan(largeBuffer.length);
});
it("should throw on invalid quality", async () => {
await expect(compress(testImage(), { quality: 0 })).rejects.toThrow();
});
it("should compress to target size", async () => {
const largeBuffer = await sharp({
create: {
width: 500,
height: 500,
channels: 3,
background: { r: 255, g: 128, b: 0 },
},
})
.jpeg({ quality: 100 })
.toBuffer();
const targetSize = Math.round(largeBuffer.length * 0.5);
const result = await compress(sharp(largeBuffer), {
targetSizeBytes: targetSize,
format: "jpg",
});
const buf = await result.toBuffer();
// Should be reasonably close to target (within 50% tolerance for small images)
expect(buf.length).toBeLessThan(largeBuffer.length);
});
});
describe("sharp format compatibility", () => {
it.each(SHARP_FORMAT_CASES)("convert accepts $sharpFormat", async ({
operationFormat,
expectedFormat,
}) => {
const result = await convert(tinyTestImage(), { format: operationFormat });
const buf = await result.toBuffer();
await expectOutputFormat(buf, expectedFormat);
});
it.each(SHARP_FORMAT_CASES)("compress accepts $sharpFormat", async ({
operationFormat,
expectedFormat,
}) => {
const result = await compress(tinyTestImage(), { quality: 80, format: operationFormat });
const buf = await result.toBuffer();
await expectOutputFormat(buf, expectedFormat);
});
});
describe("grayscale", () => {
it("should convert to grayscale", async () => {
const result = await grayscale(testImage());
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
// Grayscale PNG may still report channels as 3 or 1 depending on output
expect(buf.length).toBeGreaterThan(0);
// The image should have no color variation
expect(meta.width).toBe(100);
});
});
describe("sepia", () => {
it("should apply sepia tone without error", async () => {
const result = await sepia(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("invert", () => {
it("should invert colors without error", async () => {
const result = await invert(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("brightness", () => {
it("should adjust brightness +50 without error", async () => {
const result = await brightness(testImage(), { value: 50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(brightness(testImage(), { value: 150 })).rejects.toThrow();
});
});
describe("contrast", () => {
it("should adjust contrast +50 without error", async () => {
const result = await contrast(testImage(), { value: 50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(contrast(testImage(), { value: -150 })).rejects.toThrow();
});
});
describe("saturation", () => {
it("should adjust saturation -50 without error", async () => {
const result = await saturation(testImage(), { value: -50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(saturation(testImage(), { value: 200 })).rejects.toThrow();
});
});
describe("colorChannels", () => {
it("should adjust color channels without error", async () => {
const result = await colorChannels(testImage(), {
red: 150,
green: 100,
blue: 50,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range values", async () => {
await expect(colorChannels(testImage(), { red: 250, green: 100, blue: 100 })).rejects.toThrow();
});
});
describe("stripMetadata", () => {
it("should strip metadata without error", async () => {
const result = await stripMetadata(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("processImage (engine)", () => {
it("should apply multiple operations in sequence", async () => {
const result = await processImage(
testBuffer,
[
{ type: "resize", options: { width: 50, height: 50 } },
{ type: "grayscale", options: {} },
],
"png",
);
expect(result.info.width).toBe(50);
expect(result.info.height).toBe(50);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("should throw on unknown operation", async () => {
await expect(processImage(testBuffer, [{ type: "unknown-op", options: {} }])).rejects.toThrow(
"Unknown operation",
);
});
it("should convert output format", async () => {
const result = await processImage(testBuffer, [], "webp");
expect(result.info.format).toBe("webp");
});
});
describe("detectFormat", () => {
it("should detect PNG format", async () => {
const format = await detectFormat(testBuffer);
expect(format).toBe("png");
});
it("should detect JPEG format", async () => {
const jpegBuffer = await sharp(testBuffer).jpeg().toBuffer();
const format = await detectFormat(jpegBuffer);
expect(format).toBe("jpeg");
});
});
describe("getImageInfo", () => {
it("should return correct image info", async () => {
const info = await getImageInfo(testBuffer);
expect(info.width).toBe(100);
expect(info.height).toBe(100);
expect(info.format).toBe("png");
expect(info.channels).toBe(3);
expect(info.size).toBeGreaterThan(0);
expect(info.hasAlpha).toBe(false);
});
});
describe("mime utilities", () => {
it("should map extension to MIME type", () => {
expect(extToMime("jpg")).toBe("image/jpeg");
expect(extToMime("png")).toBe("image/png");
expect(extToMime("webp")).toBe("image/webp");
expect(extToMime(".jpg")).toBe("image/jpeg");
expect(extToMime("unknown")).toBe("application/octet-stream");
});
it("should map MIME type to extension", () => {
expect(mimeToExt("image/jpeg")).toBe("jpg");
expect(mimeToExt("image/png")).toBe("png");
expect(mimeToExt("application/unknown")).toBe("bin");
});
it("should map format to MIME type", () => {
expect(formatToMime("jpeg")).toBe("image/jpeg");
expect(formatToMime("png")).toBe("image/png");
});
it("should map format to extension", () => {
expect(formatToExt("jpeg")).toBe("jpg");
expect(formatToExt("png")).toBe("png");
});
});