test: coverage campaign and mutation testing across five packages (#628)

Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
This commit is contained in:
SnapOtter
2026-07-24 17:36:57 +08:00
committed by GitHub
parent eee6f0d470
commit 301e6eb01a
129 changed files with 30900 additions and 276 deletions
@@ -0,0 +1,980 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import {
analyzeImage,
applyCorrections,
scaleCorrections,
} from "../src/operations/auto-enhance.js";
import type { CorrectionParams, EnhancementMode, Sharp } from "../src/types.js";
// ---------------------------------------------------------------------------
// Synthetic-image builders with KNOWN Sharp stats.
//
// All ground-truth numbers asserted below were captured by running the real
// auto-enhance source against these exact buffers (via tsx), so every assertion
// pins a specific value the mutation would change, not a loose range.
// ---------------------------------------------------------------------------
/** Solid RGB fill: every channel mean == its component, stdev 0, entropy 0. */
async function solidRgb(r: number, g: number, b: number): Promise<Buffer> {
return await sharp({
create: { width: 32, height: 32, channels: 3, background: { r, g, b } },
})
.png()
.toBuffer();
}
/** Genuine single-channel (grayscale) image so `isGrayscale` is true. */
async function solidGray1(v: number): Promise<Buffer> {
return await sharp({
create: { width: 32, height: 32, channels: 3, background: { r: v, g: v, b: v } },
})
.toColourspace("b-w")
.png()
.toBuffer();
}
/** Left half value `a`, right half value `b`, all channels equal. */
async function twoTone(a: number, b: number, w = 64, h = 64): Promise<Buffer> {
const buf = Buffer.alloc(w * h * 3);
const split = Math.floor(w / 2);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const v = x < split ? a : b;
const i = (y * w + x) * 3;
buf[i] = v;
buf[i + 1] = v;
buf[i + 2] = v;
}
}
return await sharp(buf, { raw: { width: w, height: h, channels: 3 } })
.png()
.toBuffer();
}
/** Low-amplitude high-frequency grayscale texture; CLAHE/sharpen/median move stdev. */
async function texturedGray(w: number, h: number, lo: number, hi: number): Promise<Buffer> {
const buf = Buffer.alloc(w * h * 3);
const span = hi - lo;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const v = lo + ((x * 37 + y * 17) % (span + 1));
const i = (y * w + x) * 3;
buf[i] = v;
buf[i + 1] = v;
buf[i + 2] = v;
}
}
return await sharp(buf, { raw: { width: w, height: h, channels: 3 } })
.png()
.toBuffer();
}
/** High-frequency colored texture with a fixed channel offset (R>G>B). */
async function texturedColor(w: number, h: number): Promise<Buffer> {
const buf = Buffer.alloc(w * h * 3);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const n = (x * 37 + y * 17) % 37;
const i = (y * w + x) * 3;
buf[i] = 150 + n;
buf[i + 1] = 110 + n;
buf[i + 2] = 90 + n;
}
}
return await sharp(buf, { raw: { width: w, height: h, channels: 3 } })
.png()
.toBuffer();
}
/** Full-range deterministic RGB noise; sharp reports entropy 6.989 for this. */
async function noiseImage(dim = 128): Promise<Buffer> {
const buf = Buffer.alloc(dim * dim * 3);
let s = 987654321;
for (let i = 0; i < buf.length; i++) {
s = (s * 1664525 + 1013904223) >>> 0;
buf[i] = s & 0xff;
}
return await sharp(buf, { raw: { width: dim, height: dim, channels: 3 } })
.png()
.toBuffer();
}
async function channelMeans(buf: Buffer): Promise<number[]> {
const stats = await sharp(buf).stats();
return stats.channels.map((c) => c.mean);
}
async function channelStdevs(buf: Buffer): Promise<number[]> {
const stats = await sharp(buf).stats();
return stats.channels.map((c) => c.stdev);
}
/** Max-min of channel means: proxy for saturation / white-balance shift. */
async function channelSpread(buf: Buffer): Promise<number> {
const means = await channelMeans(buf);
return Math.max(...means) - Math.min(...means);
}
const NO_CORR: CorrectionParams = {
brightness: 0,
contrast: 0,
temperature: 0,
saturation: 0,
sharpness: 0,
denoise: 0,
};
const ALL_OFF: Record<string, boolean> = {
contrast: false,
exposure: false,
whiteBalance: false,
saturation: false,
sharpness: false,
denoise: false,
};
/** Enable exactly the listed toggles (undefined !== false ⇒ enabled). */
function onlyEnabled(...keys: string[]): Record<string, boolean> {
const t: Record<string, boolean | undefined> = { ...ALL_OFF };
for (const k of keys) t[k] = undefined;
return t as Record<string, boolean>;
}
function run(
buf: Buffer,
corrections: CorrectionParams,
mode: EnhancementMode,
intensity: number,
toggles: Record<string, boolean>,
size?: { width: number; height: number },
): Promise<Buffer> {
return applyCorrections(sharp(buf) as Sharp, corrections, mode, intensity, toggles, size)
.png()
.toBuffer();
}
// ===========================================================================
// analyzeImage -> computeScores
// ===========================================================================
describe("analyzeImage scores", () => {
it("maps mid-gray to exposure 50 and low-info scores exactly", async () => {
const { scores } = await analyzeImage(await solidRgb(128, 128, 128));
expect(scores).toEqual({
exposure: 50,
contrast: 0,
whiteBalance: 50,
saturation: 20,
sharpness: 10,
noise: 100,
});
});
it("computes exposure as round(meanLum / 255 * 100)", async () => {
expect((await analyzeImage(await solidRgb(30, 30, 30))).scores.exposure).toBe(12);
expect((await analyzeImage(await solidRgb(230, 230, 230))).scores.exposure).toBe(90);
expect((await analyzeImage(await solidRgb(10, 10, 10))).scores.exposure).toBe(4);
});
it("weights luminance with BT.601 coefficients (not a flat channel average)", async () => {
// Flat average of (40,60,200) is 100 -> exposure 39. BT.601 gives
// 40*0.299 + 60*0.587 + 200*0.114 = 69.98 -> exposure 27.
expect((await analyzeImage(await solidRgb(40, 60, 200))).scores.exposure).toBe(27);
});
it("derives contrast from luminance stdev (round(stdev / 1.2))", async () => {
// Solid: stdev 0 -> contrast 0.
expect((await analyzeImage(await solidRgb(128, 128, 128))).scores.contrast).toBe(0);
// 0/255 two-tone: stdev 127.5 -> round(127.5/1.2) clamps to 100.
expect((await analyzeImage(await twoTone(0, 255))).scores.contrast).toBe(100);
// 110/146 two-tone: stdev 18 -> round(15) = 15.
expect((await analyzeImage(await twoTone(110, 146))).scores.contrast).toBe(15);
// 100/160 two-tone: stdev 30 -> round(25) = 25.
expect((await analyzeImage(await twoTone(100, 160))).scores.contrast).toBe(25);
});
it("scores white balance from channel-mean spread when not grayscale", async () => {
// spread 20 -> round(50 - 20*0.8) = 34.
expect((await analyzeImage(await solidRgb(100, 100, 120))).scores.whiteBalance).toBe(34);
// Neutral gray -> spread 0 -> 50.
expect((await analyzeImage(await solidRgb(100, 100, 100))).scores.whiteBalance).toBe(50);
// Large blue cast -> clamps to 0.
expect((await analyzeImage(await solidRgb(40, 60, 200))).scores.whiteBalance).toBe(0);
});
it("scores saturation from channel-mean spread (round(spread * 1.2 + 20))", async () => {
// spread 0 -> 20.
expect((await analyzeImage(await solidRgb(128, 128, 128))).scores.saturation).toBe(20);
// spread 7 -> round(7*1.2 + 20) = 28.
expect((await analyzeImage(await solidRgb(100, 100, 107))).scores.saturation).toBe(28);
// large spread clamps to 100.
expect((await analyzeImage(await solidRgb(40, 60, 200))).scores.saturation).toBe(100);
});
it("scores sharpness from luminance stdev (round(stdev * 0.8 + 10))", async () => {
// stdev 0 -> 10.
expect((await analyzeImage(await solidRgb(128, 128, 128))).scores.sharpness).toBe(10);
// stdev 18 -> round(18*0.8 + 10) = 24.
expect((await analyzeImage(await twoTone(110, 146))).scores.sharpness).toBe(24);
// stdev 30 -> round(30*0.8 + 10) = 34.
expect((await analyzeImage(await twoTone(100, 160))).scores.sharpness).toBe(34);
});
it("scores noise from entropy (round(100 - (entropy - 5) * 20))", async () => {
// entropy 0 -> 100.
expect((await analyzeImage(await solidRgb(128, 128, 128))).scores.noise).toBe(100);
// entropy 6.989 -> round(100 - (6.989-5)*20) = 60.
expect((await analyzeImage(await noiseImage())).scores.noise).toBe(60);
});
it("uses grayscale sentinels (whiteBalance 50, saturation 50) for 1-channel input", async () => {
const { scores } = await analyzeImage(await solidGray1(100));
// A 3-channel (100,100,100) gives saturation 20; the 1-channel path gives 50.
expect(scores.saturation).toBe(50);
expect(scores.whiteBalance).toBe(50);
// exposure still computed: 100/255*100 -> 39.
expect(scores.exposure).toBe(39);
});
});
// ===========================================================================
// analyzeImage -> computeCorrections / deadZoneCorrection
// ===========================================================================
describe("analyzeImage corrections", () => {
it("returns zero brightness correction inside the exposure dead zone [40,60]", async () => {
// exposure 50 -> dead zone -> 0.
expect((await analyzeImage(await solidRgb(128, 128, 128))).corrections.brightness).toBe(0);
});
it("brightens (positive) below the dead zone, scaling from the edge by 0.8", async () => {
// exposure 12 -> round((40 - 12) * 0.8) = round(22.4) = 22.
expect((await analyzeImage(await solidRgb(30, 30, 30))).corrections.brightness).toBe(22);
// exposure 4 -> round((40 - 4) * 0.8) = round(28.8) = 29.
expect((await analyzeImage(await solidRgb(10, 10, 10))).corrections.brightness).toBe(29);
});
it("uses the edge (not 50) as the reference for a 1-unit deviation", async () => {
// Grayscale exposure 39 -> round((40 - 39) * 0.8) = round(0.8) = 1, NOT
// round((50 - 39) * 0.8) = 9. This pins the dead-zone edge arithmetic.
expect((await analyzeImage(await solidGray1(100))).corrections.brightness).toBe(1);
});
it("darkens (negative) above the dead zone", async () => {
// exposure 90 -> round((60 - 90) * 0.8) = -24.
expect((await analyzeImage(await solidRgb(230, 230, 230))).corrections.brightness).toBe(-24);
});
it("computes contrast correction from the contrast dead zone (factor 0.6)", async () => {
// contrast 0 -> round((40 - 0) * 0.6) = 24.
expect((await analyzeImage(await solidRgb(128, 128, 128))).corrections.contrast).toBe(24);
// contrast 100 -> round((60 - 100) * 0.6) = -24.
expect((await analyzeImage(await twoTone(0, 255))).corrections.contrast).toBe(-24);
});
it("computes temperature correction from the white-balance dead zone (factor 0.5)", async () => {
// whiteBalance 50 -> 0.
expect((await analyzeImage(await solidRgb(128, 128, 128))).corrections.temperature).toBe(0);
// whiteBalance 0 (blue cast) -> round((40 - 0) * 0.5) = 20.
expect((await analyzeImage(await solidRgb(40, 60, 200))).corrections.temperature).toBe(20);
// whiteBalance 26 (mild cast, spread 30) -> round((40 - 26) * 0.5) = 7.
expect((await analyzeImage(await solidRgb(100, 110, 130))).corrections.temperature).toBe(7);
});
it("boosts saturation only below 40 (factor 0.6, clamped [0,30])", async () => {
// saturation 20 -> round((40 - 20) * 0.6) = 12.
expect((await analyzeImage(await solidRgb(128, 128, 128))).corrections.saturation).toBe(12);
});
it("reduces saturation only above 60 (factor 0.4, clamped [-20,0])", async () => {
// saturation 100 -> round((60 - 100) * 0.4) = -16.
expect((await analyzeImage(await solidRgb(40, 60, 200))).corrections.saturation).toBe(-16);
});
it("leaves saturation uncorrected inside [40,60]", async () => {
// saturation 56 (spread 30) -> 0.
expect((await analyzeImage(await solidRgb(100, 110, 130))).corrections.saturation).toBe(0);
});
it("sharpens only below 40 (factor 1.0, clamped [0,50])", async () => {
// sharpness 10 -> round((40 - 10) * 1.0) = 30.
expect((await analyzeImage(await solidRgb(128, 128, 128))).corrections.sharpness).toBe(30);
// sharpness 100 (>=40) -> 0.
expect((await analyzeImage(await twoTone(0, 255))).corrections.sharpness).toBe(0);
});
it("keeps denoise at 0 when noise score stays >= 35", async () => {
// noise 100 and noise 60 both leave denoise 0.
expect((await analyzeImage(await solidRgb(128, 128, 128))).corrections.denoise).toBe(0);
expect((await analyzeImage(await noiseImage())).corrections.denoise).toBe(0);
});
});
// ===========================================================================
// analyzeImage -> detectIssues (threshold boundaries)
// ===========================================================================
describe("analyzeImage issues", () => {
it("flags underexposed strictly below exposure 35", async () => {
// exposure 29 (v=75) < 35 -> flagged.
expect((await analyzeImage(await solidRgb(75, 75, 75))).issues).toContain("underexposed");
// exposure 35 (v=90) -> not flagged.
expect((await analyzeImage(await solidRgb(90, 90, 90))).issues).not.toContain("underexposed");
});
it("flags overexposed strictly above exposure 70", async () => {
// exposure 70 (v=179) -> not flagged.
expect((await analyzeImage(await solidRgb(179, 179, 179))).issues).not.toContain("overexposed");
// exposure 71 (v=180) -> flagged.
expect((await analyzeImage(await solidRgb(180, 180, 180))).issues).toContain("overexposed");
});
it("flags low-contrast strictly below contrast 35", async () => {
// contrast 15 -> flagged.
expect((await analyzeImage(await twoTone(110, 146))).issues).toContain("low-contrast");
// contrast 73 -> not flagged.
expect((await analyzeImage(await twoTone(40, 216))).issues).not.toContain("low-contrast");
});
it("flags color-cast strictly below whiteBalance 35", async () => {
// whiteBalance 35 (spread 19) -> not flagged.
expect((await analyzeImage(await solidRgb(100, 100, 119))).issues).not.toContain("color-cast");
// whiteBalance 34 (spread 20) -> flagged.
expect((await analyzeImage(await solidRgb(100, 100, 120))).issues).toContain("color-cast");
});
it("flags desaturated strictly below saturation 30", async () => {
// saturation 28 (spread 7) -> flagged.
expect((await analyzeImage(await solidRgb(100, 100, 107))).issues).toContain("desaturated");
// saturation 30 (spread 8) -> not flagged.
expect((await analyzeImage(await solidRgb(100, 100, 108))).issues).not.toContain("desaturated");
});
it("flags soft-focus strictly below sharpness 35", async () => {
// sharpness 24 -> flagged.
expect((await analyzeImage(await twoTone(110, 146))).issues).toContain("soft-focus");
// sharpness 80 -> not flagged.
expect((await analyzeImage(await twoTone(40, 216))).issues).not.toContain("soft-focus");
});
it("does not flag issues whose thresholds are not crossed", async () => {
// High-contrast neutral two-tone: only 'desaturated' should appear, proving
// the other push() conditions stay false (kills always-push mutants).
expect((await analyzeImage(await twoTone(0, 255))).issues).toEqual(["desaturated"]);
});
});
// ===========================================================================
// analyzeImage -> suggestMode
// ===========================================================================
describe("analyzeImage suggestedMode", () => {
it("suggests low-light strictly below exposure 30", async () => {
// exposure 29 (v=75) -> low-light.
expect((await analyzeImage(await solidRgb(75, 75, 75))).suggestedMode).toBe("low-light");
// exposure 30 (v=76) -> not low-light (falls through to auto).
expect((await analyzeImage(await solidRgb(76, 76, 76))).suggestedMode).toBe("auto");
});
it("suggests document only when contrast > 60 AND saturation < 30", async () => {
// contrast 100, saturation 20, exposure 50 -> document (exercises the &&,
// and proves it is not short-circuited by the low-light branch).
expect((await analyzeImage(await twoTone(0, 255))).suggestedMode).toBe("document");
});
it("falls back to auto when contrast is high but saturation is not low", async () => {
// contrast 73, saturation 20... build a high-contrast COLORED image so
// saturation >= 30 while contrast > 60, forcing the && right side false.
const buf = await (async () => {
const w = 64;
const h = 64;
const raw = Buffer.alloc(w * h * 3);
const split = w / 2;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 3;
if (x < split) {
raw[i] = 20;
raw[i + 1] = 10;
raw[i + 2] = 10;
} else {
raw[i] = 240;
raw[i + 1] = 200;
raw[i + 2] = 160;
}
}
}
return await sharp(raw, { raw: { width: w, height: h, channels: 3 } })
.png()
.toBuffer();
})();
const { scores, suggestedMode } = await analyzeImage(buf);
expect(scores.contrast).toBeGreaterThan(60);
expect(scores.saturation).toBeGreaterThanOrEqual(30);
expect(suggestedMode).toBe("auto");
});
it("returns auto for a neutral mid-gray image", async () => {
expect((await analyzeImage(await solidRgb(128, 128, 128))).suggestedMode).toBe("auto");
});
});
// ===========================================================================
// scaleCorrections (pure: exact integer outputs)
// ===========================================================================
describe("scaleCorrections", () => {
const corr: CorrectionParams = {
brightness: 20,
contrast: 10,
temperature: -8,
saturation: 12,
sharpness: 15,
denoise: 3,
};
it("is identity for mode auto at intensity 50 (scale 1.0)", () => {
expect(scaleCorrections(corr, "auto", 50)).toEqual(corr);
});
it("zeroes everything at intensity 0", () => {
// Use all-positive inputs so scaling by 0 cannot mint a signed -0 that
// toEqual would treat as distinct from 0.
const positive: CorrectionParams = {
brightness: 20,
contrast: 10,
temperature: 8,
saturation: 12,
sharpness: 15,
denoise: 3,
};
expect(scaleCorrections(positive, "auto", 0)).toEqual({
brightness: 0,
contrast: 0,
temperature: 0,
saturation: 0,
sharpness: 0,
denoise: 0,
});
});
it("scales linearly with intensity/50", () => {
// intensity 25 -> scale 0.5, each field halved and rounded.
expect(scaleCorrections({ ...corr, denoise: 2 }, "auto", 25)).toEqual({
brightness: 10,
contrast: 5,
temperature: -4,
saturation: 6,
sharpness: 8, // round(15 * 0.5) = round(7.5) = 8
denoise: 1,
});
});
it("applies the portrait preset multipliers", () => {
// portrait: br .8, ct .7, temp 1.2, sat .6, sharp .5, denoise 1.5.
expect(scaleCorrections(corr, "portrait", 50)).toEqual({
brightness: 16, // 20 * 0.8
contrast: 7, // round(10 * 0.7)
temperature: -10, // round(-8 * 1.2) = round(-9.6)
saturation: 7, // round(12 * 0.6) = round(7.2)
sharpness: 8, // round(15 * 0.5) = round(7.5)
denoise: 5, // round(3 * 1.5) = round(4.5)
});
});
it("applies the landscape preset multipliers and intensity together", () => {
// landscape: br 1.0, ct 1.3, temp 1.0, sat 1.4, sharp 1.5, denoise 0.5; intensity 100 -> scale 2.
expect(scaleCorrections(corr, "landscape", 100)).toEqual({
brightness: 40, // 20 * 1.0 * 2
contrast: 26, // 10 * 1.3 * 2
temperature: -16, // -8 * 1.0 * 2
saturation: 34, // round(12 * 1.4 * 2) = round(33.6)
sharpness: 45, // 15 * 1.5 * 2
denoise: 3, // round(3 * 0.5 * 2) = 3
});
});
it("applies the document preset (saturation multiplier 0 forces 0)", () => {
expect(scaleCorrections(corr, "document", 50)).toEqual({
brightness: 30, // 20 * 1.5
contrast: 20, // 10 * 2.0
temperature: -8, // -8 * 1.0
saturation: 0, // 12 * 0.0
sharpness: 30, // 15 * 2.0
denoise: 6, // 3 * 2.0
});
});
it("applies the low-light preset multipliers", () => {
const c: CorrectionParams = {
brightness: 10,
contrast: 10,
temperature: 10,
saturation: 10,
sharpness: 10,
denoise: 2,
};
// low-light: br 1.8, ct 1.5, temp 1.0, sat 0.8, sharp 1.2, denoise 2.0.
expect(scaleCorrections(c, "low-light", 50)).toEqual({
brightness: 18,
contrast: 15,
temperature: 10,
saturation: 8,
sharpness: 12,
denoise: 4,
});
});
it("applies the food preset multipliers", () => {
const c: CorrectionParams = {
brightness: 10,
contrast: 10,
temperature: 10,
saturation: 10,
sharpness: 10,
denoise: 2,
};
// food: br 0.8, ct 1.1, temp 1.3, sat 1.3, sharp 1.2, denoise 0.5.
expect(scaleCorrections(c, "food", 50)).toEqual({
brightness: 8,
contrast: 11,
temperature: 13,
saturation: 13,
sharpness: 12,
denoise: 1,
});
});
it("differs from auto by exactly the preset ratio where the multiplier != 1", () => {
// landscape saturation multiplier is 1.4x auto's; prove the table is wired.
const c: CorrectionParams = { ...corr, saturation: 10 };
const auto = scaleCorrections(c, "auto", 50).saturation; // 10
const landscape = scaleCorrections(c, "landscape", 50).saturation; // 14
expect(auto).toBe(10);
expect(landscape).toBe(14);
});
});
// ===========================================================================
// applyCorrections: structural invariants
// ===========================================================================
describe("applyCorrections invariants", () => {
it("passes the image through unchanged when every toggle is off", async () => {
// Non-zero corrections but all toggles false -> no operation applies.
const strong: CorrectionParams = {
brightness: 80,
contrast: 80,
temperature: 80,
saturation: 80,
sharpness: 80,
denoise: 5,
};
const out = await run(
await solidRgb(128, 128, 128),
strong,
"auto",
50,
{ ...ALL_OFF },
{
width: 64,
height: 64,
},
);
expect(await channelMeans(out)).toEqual([128, 128, 128]);
});
it("preserves image dimensions", async () => {
const out = await run(
await sharp({
create: { width: 80, height: 40, channels: 3, background: { r: 100, g: 100, b: 100 } },
})
.png()
.toBuffer(),
{ ...NO_CORR, temperature: 40 },
"auto",
50,
onlyEnabled("whiteBalance"),
{ width: 80, height: 40 },
);
const meta = await sharp(out).metadata();
expect(meta.width).toBe(80);
expect(meta.height).toBe(40);
});
it("preserves the alpha channel (4-channel input stays 4-channel)", async () => {
const rgba = await sharp({
create: {
width: 32,
height: 32,
channels: 4,
background: { r: 120, g: 120, b: 120, alpha: 0.5 },
},
})
.png()
.toBuffer();
const out = await run(
rgba,
{ ...NO_CORR, temperature: 40 },
"auto",
50,
onlyEnabled("whiteBalance"),
{
width: 32,
height: 32,
},
);
expect((await sharp(out).metadata()).channels).toBe(4);
});
});
// ===========================================================================
// applyCorrections Step 4: white balance (exact, robust signal)
// ===========================================================================
describe("applyCorrections white balance (linear per-channel)", () => {
it("warms the image: R up, G slightly up, B down for positive temperature", async () => {
// temp 40, auto, intensity 50 -> t = 0.4 -> [1.06, 1.02, 0.94] on 128.
const out = await run(
await solidRgb(128, 128, 128),
{ ...NO_CORR, temperature: 40 },
"auto",
50,
onlyEnabled("whiteBalance"),
{
width: 64,
height: 64,
},
);
expect(await channelMeans(out)).toEqual([135, 130, 120]);
});
it("cools the image: B up, R down for negative temperature", async () => {
// temp -40 -> t = -0.4 -> [0.94, 0.98, 1.06] on 128.
const out = await run(
await solidRgb(128, 128, 128),
{ ...NO_CORR, temperature: -40 },
"auto",
50,
onlyEnabled("whiteBalance"),
{
width: 64,
height: 64,
},
);
expect(await channelMeans(out)).toEqual([120, 125, 135]);
});
it("skips white balance when |scaled adjustment| <= 2", async () => {
// temp 2, auto, intensity 50 -> adj = 2, not > 2 -> no linear() -> unchanged.
const out = await run(
await solidRgb(128, 128, 128),
{ ...NO_CORR, temperature: 2 },
"auto",
50,
onlyEnabled("whiteBalance"),
{
width: 64,
height: 64,
},
);
expect(await channelMeans(out)).toEqual([128, 128, 128]);
});
it("respects the whiteBalance toggle", async () => {
const out = await run(
await solidRgb(128, 128, 128),
{ ...NO_CORR, temperature: 40 },
"auto",
50,
{ ...ALL_OFF },
{
width: 64,
height: 64,
},
);
expect(await channelMeans(out)).toEqual([128, 128, 128]);
});
});
// ===========================================================================
// applyCorrections Step 5: saturation (via modulate)
// ===========================================================================
describe("applyCorrections saturation (modulate)", () => {
it("widens channel spread for a positive saturation correction", async () => {
const cimg = await texturedColor(200, 200);
const base = await channelSpread(cimg);
const out = await run(
cimg,
{ ...NO_CORR, saturation: 30 },
"auto",
50,
onlyEnabled("saturation"),
{
width: 200,
height: 200,
},
);
expect(await channelSpread(out)).toBeGreaterThan(base + 5);
});
it("narrows channel spread for a negative saturation correction", async () => {
const cimg = await texturedColor(200, 200);
const base = await channelSpread(cimg);
const out = await run(
cimg,
{ ...NO_CORR, saturation: -30 },
"auto",
50,
onlyEnabled("saturation"),
{
width: 200,
height: 200,
},
);
expect(await channelSpread(out)).toBeLessThan(base - 5);
});
it("skips modulate when |satMul - 1| <= 0.02", async () => {
// saturation 1, auto, intensity 50 -> adj 1 -> satMul 1.01 -> skip.
const cimg = await texturedColor(200, 200);
const base = await channelSpread(cimg);
const out = await run(
cimg,
{ ...NO_CORR, saturation: 1 },
"auto",
50,
onlyEnabled("saturation"),
{
width: 200,
height: 200,
},
);
expect(await channelSpread(out)).toBeCloseTo(base, 5);
});
it("respects the saturation toggle", async () => {
const cimg = await texturedColor(200, 200);
const base = await channelSpread(cimg);
const out = await run(
cimg,
{ ...NO_CORR, saturation: 30 },
"auto",
50,
{ ...ALL_OFF },
{
width: 200,
height: 200,
},
);
expect(await channelSpread(out)).toBeCloseTo(base, 5);
});
});
// ===========================================================================
// applyCorrections Step 6: sharpen
// ===========================================================================
describe("applyCorrections sharpen", () => {
it("raises local stdev when sharpening a textured image", async () => {
const tex = await texturedGray(300, 300, 110, 146);
const base = (await channelStdevs(tex))[0];
const out = await run(
tex,
{ ...NO_CORR, sharpness: 40 },
"auto",
50,
onlyEnabled("sharpness"),
{
width: 300,
height: 300,
},
);
expect((await channelStdevs(out))[0]).toBeGreaterThan(base + 5);
});
it("skips sharpen when the scaled adjustment <= 2", async () => {
// sharpness 2, auto, intensity 50 -> adj 2, not > 2 -> skip.
const tex = await texturedGray(300, 300, 110, 146);
const base = (await channelStdevs(tex))[0];
const out = await run(tex, { ...NO_CORR, sharpness: 2 }, "auto", 50, onlyEnabled("sharpness"), {
width: 300,
height: 300,
});
expect((await channelStdevs(out))[0]).toBeCloseTo(base, 5);
});
});
// ===========================================================================
// applyCorrections denoise: median kernel selection
// ===========================================================================
describe("applyCorrections denoise (median)", () => {
it("lowers local stdev when the denoise correction is applied", async () => {
const tex = await texturedGray(300, 300, 110, 146);
const base = (await channelStdevs(tex))[0];
const out = await run(tex, { ...NO_CORR, denoise: 5 }, "auto", 50, onlyEnabled("denoise"), {
width: 300,
height: 300,
});
expect((await channelStdevs(out))[0]).toBeLessThan(base - 2);
});
it("skips median when the scaled adjustment < 2", async () => {
// denoise 1, auto, intensity 50 -> adj 1 -> skip.
const tex = await texturedGray(300, 300, 110, 146);
const base = (await channelStdevs(tex))[0];
const out = await run(tex, { ...NO_CORR, denoise: 1 }, "auto", 50, onlyEnabled("denoise"), {
width: 300,
height: 300,
});
expect((await channelStdevs(out))[0]).toBeCloseTo(base, 5);
});
it("uses a larger kernel (5) for a stronger denoise than kernel 3", async () => {
// adj 3 (denoise 3) -> kernel 3; adj 5 (denoise 5) -> kernel 5. A 5x5 median
// smooths more, so its output stdev is strictly lower than the 3x3 output.
const tex = await texturedGray(300, 300, 100, 160);
const out3 = await run(tex, { ...NO_CORR, denoise: 3 }, "auto", 50, onlyEnabled("denoise"), {
width: 300,
height: 300,
});
const out5 = await run(tex, { ...NO_CORR, denoise: 5 }, "auto", 50, onlyEnabled("denoise"), {
width: 300,
height: 300,
});
expect((await channelStdevs(out5))[0]).toBeLessThan((await channelStdevs(out3))[0]);
});
});
// ===========================================================================
// applyCorrections Step 2: normalise (histogram stretch)
// ===========================================================================
describe("applyCorrections normalise", () => {
it("stretches a low-contrast two-tone image (stdev jumps)", async () => {
// exposure toggle drives normalise; brightness 0 keeps gamma inert.
const tt = await twoTone(110, 146);
const base = (await channelStdevs(tt))[0]; // ~18
const out = await run(tt, NO_CORR, "auto", 50, onlyEnabled("exposure"), {
width: 64,
height: 64,
});
expect((await channelStdevs(out))[0]).toBeGreaterThan(base + 50);
});
it("leaves a full-range solid image untouched (nothing to stretch)", async () => {
const solid = await solidRgb(128, 128, 128);
const out = await run(solid, NO_CORR, "auto", 50, onlyEnabled("exposure"), {
width: 64,
height: 64,
});
expect(await channelMeans(out)).toEqual([128, 128, 128]);
});
});
// ===========================================================================
// applyCorrections Step 3: gamma clamp + gate
// ===========================================================================
describe("applyCorrections gamma", () => {
it("clamps gamma at the 3.0 ceiling (brightness 250 and 400 give identical output)", async () => {
// gamma = clamp(1 + adj/100, 1, 3): adj 250 -> 3.0, adj 400 -> 3.0 (clamped).
// Both must produce byte-identical pixels. Use a solid so normalise is inert.
const solid = await solidRgb(128, 128, 128);
const g250 = await run(
solid,
{ ...NO_CORR, brightness: 250 },
"auto",
50,
onlyEnabled("exposure"),
{
width: 64,
height: 64,
},
);
const g400 = await run(
solid,
{ ...NO_CORR, brightness: 400 },
"auto",
50,
onlyEnabled("exposure"),
{
width: 64,
height: 64,
},
);
expect((await channelMeans(g250))[0]).toBe((await channelMeans(g400))[0]);
// And the clamped gamma actually shifted the solid away from 128.
expect((await channelMeans(g250))[0]).not.toBe(128);
});
it("skips gamma when |scaled adjustment| <= 2 (solid stays exactly put)", async () => {
// brightness 5, intensity 10 -> adj = 5 * 0.2 = 1, not > 2 -> no gamma.
const solid = await solidRgb(128, 128, 128);
const out = await run(
solid,
{ ...NO_CORR, brightness: 5 },
"auto",
10,
onlyEnabled("exposure"),
{
width: 64,
height: 64,
},
);
expect(await channelMeans(out)).toEqual([128, 128, 128]);
});
});
// ===========================================================================
// applyCorrections Step 1: CLAHE (contrast) + MAX_CLAHE_PIXELS boundary
// ===========================================================================
describe("applyCorrections CLAHE", () => {
it("increases local stdev on a low-contrast texture with small tiles", async () => {
// Default size (undefined -> 64) yields tile 8, so CLAHE genuinely equalizes.
const tex = await texturedGray(400, 400, 110, 146);
const base = (await channelStdevs(tex))[0]; // ~10.7
const out = await run(tex, NO_CORR, "auto", 50, onlyEnabled("contrast"));
expect((await channelStdevs(out))[0]).toBeGreaterThan(base + 2);
});
it("skips CLAHE when maxSlope clamps below 2 (intensity 0)", async () => {
// maxSlope = clamp(round(1 + 0*4*1), 1, 10) = 1 -> below 2 -> skipped.
const tex = await texturedGray(400, 400, 110, 146);
const base = (await channelStdevs(tex))[0];
const out = await run(tex, NO_CORR, "auto", 0, onlyEnabled("contrast"));
expect((await channelStdevs(out))[0]).toBeCloseTo(base, 5);
});
it("applies CLAHE at exactly MAX_CLAHE_PIXELS but skips it one pixel over", async () => {
// Observe the claheApplied flag through Step 5's +0.05 saturation
// compensation (intensity 50 > 10). Same real pixels, only imageSize varies.
// 4000x4000 = 16,000,000 (<= limit) -> CLAHE applies -> spread grows.
// 4000x4001 = 16,004,000 (> limit) -> CLAHE skipped -> spread unchanged.
const cimg = await texturedColor(400, 400);
const base = await channelSpread(cimg);
const atLimit = await run(cimg, NO_CORR, "auto", 50, onlyEnabled("contrast", "saturation"), {
width: 4000,
height: 4000,
});
const overLimit = await run(cimg, NO_CORR, "auto", 50, onlyEnabled("contrast", "saturation"), {
width: 4000,
height: 4001,
});
expect(await channelSpread(atLimit)).toBeGreaterThan(base + 1);
expect(await channelSpread(overLimit)).toBeCloseTo(base, 5);
});
it("respects the contrast toggle (CLAHE off leaves texture stdev flat)", async () => {
const tex = await texturedGray(400, 400, 110, 146);
const base = (await channelStdevs(tex))[0];
const out = await run(tex, NO_CORR, "auto", 50, { ...ALL_OFF });
expect((await channelStdevs(out))[0]).toBeCloseTo(base, 5);
});
});
@@ -0,0 +1,411 @@
import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
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 { saturation } from "../src/operations/saturation.js";
import type { Sharp } from "../src/types.js";
// Mutation-killing tests for compress / color-channels / brightness / contrast /
// saturation. The existing operations.test.ts only asserts `buf.length > 0`
// (execution, not value), which lets encoder-option, arithmetic, and boundary
// mutants survive. These tests assert concrete effects: byte-size ordering
// across quality levels, exact per-channel raw bytes after recomb/linear, and
// direction + clamp + no-op behavior for the gamma-aware modulate() ops.
/** Deterministic seeded PRNG so noisy-photo bytes (and thus sizes) are stable. */
function makeRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
}
/**
* A fully random RGB image. Random pixels are incompressible, so JPEG/WebP/AVIF
* quality has a large, monotonic effect on output size (lower quality => fewer
* bytes), which is exactly what the size-ordering assertions rely on.
*/
async function noisyPhotoPng(width = 400, height = 400, seed = 987654321): Promise<Buffer> {
const rng = makeRng(seed);
const raw = Buffer.alloc(width * height * 3);
for (let i = 0; i < raw.length; i++) {
raw[i] = Math.floor(rng() * 256);
}
return sharp(raw, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
/** Solid-color PNG for exact per-channel math (recomb / linear / modulate). */
async function solidPng(r: number, g: number, b: number, size = 8): Promise<Buffer> {
return sharp({
create: { width: size, height: size, channels: 3, background: { r, g, b } },
})
.png()
.toBuffer();
}
/** First pixel's [R, G, B] after decoding a buffer back to raw. */
async function firstPixel(buffer: Buffer): Promise<[number, number, number]> {
const raw = await sharp(buffer).raw().toBuffer();
return [raw[0], raw[1], raw[2]];
}
async function outputFormat(buffer: Buffer): Promise<string> {
const meta = await sharp(buffer).metadata();
// Sharp reports AVIF as the heif container; normalize for assertions.
return meta.format === "heif" ? "avif" : (meta.format ?? "");
}
let photoPng: Buffer;
beforeAll(async () => {
photoPng = await noisyPhotoPng();
});
describe("compress: format selection", () => {
it("honors an explicit format for every encoder branch", async () => {
const src = await solidPng(120, 90, 60, 32);
for (const [format, expected] of [
["jpg", "jpeg"],
["png", "png"],
["webp", "webp"],
["avif", "avif"],
] as const) {
const out = await (await compress(sharp(src), { quality: 70, format })).toBuffer();
expect(await outputFormat(out)).toBe(expected);
}
});
it("defaults to the detected input format when none is given", async () => {
const pngOut = await (await compress(sharp(photoPng), { quality: 80 })).toBuffer();
expect(await outputFormat(pngOut)).toBe("png");
const jpegIn = await sharp(photoPng).jpeg({ quality: 95 }).toBuffer();
const jpegOut = await (await compress(sharp(jpegIn), { quality: 80 })).toBuffer();
expect(await outputFormat(jpegOut)).toBe("jpeg");
});
it("an explicit format overrides the detected input format", async () => {
// PNG in, AVIF requested out -> must not fall back to the input's png.
const out = await (await compress(sharp(photoPng), { quality: 50, format: "avif" })).toBuffer();
expect(await outputFormat(out)).toBe("avif");
});
it("falls back to PNG for inputs Sharp cannot encode (SVG)", async () => {
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40">' +
'<rect width="40" height="40" fill="rgb(30,60,90)"/></svg>',
);
expect((await sharp(svg).metadata()).format).toBe("svg");
const out = await (await compress(sharp(svg), { quality: 80 })).toBuffer();
expect(await outputFormat(out)).toBe("png");
});
});
describe("compress: quality controls output size", () => {
// Random pixels make the ordering strict and wide, so a mutated quality
// number, a hardcoded quality, or a swapped-format branch changes the bytes.
it.each([
["jpg", "jpeg"],
["webp", "webp"],
["avif", "avif"],
] as const)("lower quality yields strictly smaller %s output", async (format) => {
const low = await (await compress(sharp(photoPng), { quality: 20, format })).toBuffer();
const mid = await (await compress(sharp(photoPng), { quality: 55, format })).toBuffer();
const high = await (await compress(sharp(photoPng), { quality: 90, format })).toBuffer();
expect(low.length).toBeLessThan(mid.length);
expect(mid.length).toBeLessThan(high.length);
});
it("uses the default quality (80) when quality is omitted", async () => {
// Default 80 must sit strictly between q20 and q100 in size: proves the
// `quality ?? 80` fallback feeds the encoder (not 0/undefined/100).
const q20 = await (await compress(sharp(photoPng), { quality: 20, format: "jpg" })).toBuffer();
const q100 = await (
await compress(sharp(photoPng), { quality: 100, format: "jpg" })
).toBuffer();
const dflt = await (await compress(sharp(photoPng), { format: "jpg" })).toBuffer();
expect(dflt.length).toBeGreaterThan(q20.length);
expect(dflt.length).toBeLessThan(q100.length);
});
});
describe("compress: quality clamp boundaries", () => {
it("accepts the inclusive edges q=1 and q=100", async () => {
await expect(compress(sharp(photoPng), { quality: 1, format: "jpg" })).resolves.toBeDefined();
await expect(compress(sharp(photoPng), { quality: 100, format: "jpg" })).resolves.toBeDefined();
});
it("rejects just outside the range: q=0 and q=101", async () => {
await expect(compress(sharp(photoPng), { quality: 0, format: "jpg" })).rejects.toThrow(
/between 1 and 100/,
);
await expect(compress(sharp(photoPng), { quality: 101, format: "jpg" })).rejects.toThrow(
/between 1 and 100/,
);
});
});
describe("compress: target size", () => {
it("rejects a non-positive target and accepts the smallest positive target", async () => {
await expect(compress(sharp(photoPng), { targetSizeBytes: 0, format: "jpg" })).rejects.toThrow(
/greater than 0/,
);
await expect(compress(sharp(photoPng), { targetSizeBytes: -5, format: "jpg" })).rejects.toThrow(
/greater than 0/,
);
// target=1 is > 0, so it must NOT throw (kills a `<= 0` -> `< 0` mutant).
await expect(
compress(sharp(photoPng), { targetSizeBytes: 1, format: "jpg" }),
).resolves.toBeDefined();
});
it("hits a reachable target without downscaling", async () => {
// Target comfortably above the q=1 full-size floor: the quality search
// succeeds, dimensions stay full, and the result fits under the target.
const q1Full = (await sharp(photoPng).toFormat("jpeg", { quality: 1 }).toBuffer()).length;
const target = q1Full * 3;
const out = await (
await compress(sharp(photoPng), { targetSizeBytes: target, format: "jpg" })
).toBuffer();
const meta = await sharp(out).metadata();
expect(out.length).toBeLessThanOrEqual(target);
expect(meta.width).toBe(400);
expect(meta.height).toBe(400);
});
it("downscales when even q=1 at full size overshoots the target", async () => {
// Target below the q=1 full-size floor forces the resize fallback loop.
const q1Full = (await sharp(photoPng).toFormat("jpeg", { quality: 1 }).toBuffer()).length;
const target = Math.round(q1Full / 4);
const out = await (
await compress(sharp(photoPng), { targetSizeBytes: target, format: "jpg" })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.width).toBeLessThan(400);
expect(meta.height).toBeLessThan(400);
// The fallback should still shrink the file well below the original.
expect(out.length).toBeLessThan(photoPng.length);
});
it("a smaller target produces a smaller (or equal) file than a larger target", async () => {
const q1Full = (await sharp(photoPng).toFormat("jpeg", { quality: 1 }).toBuffer()).length;
const bigOut = await (
await compress(sharp(photoPng), { targetSizeBytes: q1Full * 6, format: "jpg" })
).toBuffer();
const smallOut = await (
await compress(sharp(photoPng), { targetSizeBytes: q1Full * 2, format: "jpg" })
).toBuffer();
expect(smallOut.length).toBeLessThanOrEqual(bigOut.length);
});
});
describe("colorChannels: exact per-channel recomb", () => {
// Distinct channel values expose any swapped matrix position or wrong divisor.
async function distinctInput(): Promise<Sharp> {
return sharp(await solidPng(10, 20, 30));
}
it("scales each channel by value/100 on the diagonal", async () => {
// red 150 -> x1.5 -> 15, green 100 -> x1.0 -> 20, blue 50 -> x0.5 -> 15.
const out = await (
await colorChannels(await distinctInput(), { red: 150, green: 100, blue: 50 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect(r).toBe(15);
expect(g).toBe(20);
expect(b).toBe(15);
});
it("red=0 zeroes only the red channel", async () => {
const out = await (
await colorChannels(await distinctInput(), { red: 0, green: 100, blue: 100 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect(r).toBe(0);
expect(g).toBe(20);
expect(b).toBe(30);
});
it("green=200 doubles only the green channel", async () => {
// green 20 -> x2.0 -> 40; red and blue unchanged (kept at x1.0).
const out = await (
await colorChannels(await distinctInput(), { red: 100, green: 200, blue: 100 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect(r).toBe(10);
expect(g).toBe(40);
expect(b).toBe(30);
});
it("red=green=blue=100 is a no-op", async () => {
const out = await (
await colorChannels(await distinctInput(), { red: 100, green: 100, blue: 100 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect([r, g, b]).toEqual([10, 20, 30]);
});
it("rejects channel values above 200 and below 0", async () => {
await expect(
colorChannels(await distinctInput(), { red: 201, green: 100, blue: 100 }),
).rejects.toThrow(/Red channel/);
await expect(
colorChannels(await distinctInput(), { red: 100, green: -1, blue: 100 }),
).rejects.toThrow(/Green channel/);
await expect(
colorChannels(await distinctInput(), { red: 100, green: 100, blue: 201 }),
).rejects.toThrow(/Blue channel/);
});
it("accepts the inclusive edges 0 and 200", async () => {
await expect(
colorChannels(await distinctInput(), { red: 0, green: 0, blue: 0 }),
).resolves.toBeDefined();
await expect(
colorChannels(await distinctInput(), { red: 200, green: 200, blue: 200 }),
).resolves.toBeDefined();
});
});
describe("brightness: direction, clamps, no-op", () => {
// modulate() is gamma-aware, so exact values aren't naive multiplies; assert
// direction relative to the source and the exact 0/255 clamp endpoints.
async function grayInput(level = 100): Promise<Sharp> {
return sharp(await solidPng(level, level, level));
}
it("+50 brightens above the source value", async () => {
const out = await (await brightness(await grayInput(100), { value: 50 })).toBuffer();
const [r] = await firstPixel(out);
expect(r).toBeGreaterThan(100);
});
it("-50 darkens below the source value", async () => {
const out = await (await brightness(await grayInput(100), { value: -50 })).toBuffer();
const [r] = await firstPixel(out);
expect(r).toBeLessThan(100);
});
it("value=0 is an exact no-op (multiplier 1.0)", async () => {
const out = await (await brightness(await grayInput(100), { value: 0 })).toBuffer();
const [r, g, b] = await firstPixel(out);
expect([r, g, b]).toEqual([100, 100, 100]);
});
it("value=-100 drives the image to black (multiplier 0)", async () => {
const out = await (await brightness(await grayInput(100), { value: -100 })).toBuffer();
const [r, g, b] = await firstPixel(out);
expect([r, g, b]).toEqual([0, 0, 0]);
});
it("value=+100 doubling clamps a bright input at 255", async () => {
// 200 * 2.0 = 400 -> clamp to 255. Confirms the +value/100 -> mult 2 mapping.
const out = await (
await brightness(sharp(await solidPng(200, 200, 200)), { value: 100 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect([r, g, b]).toEqual([255, 255, 255]);
});
it("rejects values outside -100..100 at both edges", async () => {
await expect(brightness(await grayInput(), { value: 101 })).rejects.toThrow();
await expect(brightness(await grayInput(), { value: -101 })).rejects.toThrow();
});
it("accepts the inclusive edges -100 and 100", async () => {
await expect(brightness(await grayInput(), { value: -100 })).resolves.toBeDefined();
await expect(brightness(await grayInput(), { value: 100 })).resolves.toBeDefined();
});
});
describe("contrast: exact linear transform around 128", () => {
// contrast() is a deterministic linear(slope, intercept), so assert exact
// output bytes. slope = 1 + value/100, intercept = 128 * (1 - slope).
async function twoTone(): Promise<Sharp> {
// Two pixels: 64 (below mid) and 192 (above mid).
return sharp(Buffer.from([64, 64, 64, 192, 192, 192]), {
raw: { width: 2, height: 1, channels: 3 },
});
}
it("value=+100 (slope 2) pushes values away from the midpoint and clamps", async () => {
// 64 -> 2*64-128 = 0; 192 -> 2*192-128 = 256 -> clamp 255.
const raw = await (await contrast(await twoTone(), { value: 100 })).raw().toBuffer();
expect(raw[0]).toBe(0);
expect(raw[3]).toBe(255);
});
it("value=-50 (slope 0.5) pulls values toward the midpoint", async () => {
// slope 0.5, intercept 128*(1-0.5)=64. 64 -> 96; 192 -> 160.
const raw = await (await contrast(await twoTone(), { value: -50 })).raw().toBuffer();
expect(raw[0]).toBe(96);
expect(raw[3]).toBe(160);
});
it("value=0 is an exact no-op (slope 1, intercept 0)", async () => {
const raw = await (await contrast(await twoTone(), { value: 0 })).raw().toBuffer();
expect(raw[0]).toBe(64);
expect(raw[3]).toBe(192);
});
it("the midpoint (128) is a fixed point for any slope", async () => {
// Kills intercept-formula mutants: 128*(1+slope) or a sign flip would move it.
for (const value of [100, -50, 50, -100]) {
const mid = sharp(Buffer.from([128, 128, 128]), {
raw: { width: 1, height: 1, channels: 3 },
});
const raw = await (await contrast(mid, { value })).raw().toBuffer();
expect(raw[0]).toBe(128);
}
});
it("rejects values outside -100..100 at both edges", async () => {
await expect(contrast(await twoTone(), { value: 101 })).rejects.toThrow();
await expect(contrast(await twoTone(), { value: -101 })).rejects.toThrow();
});
});
describe("saturation: desaturation, widening, no-op", () => {
async function coloredInput(): Promise<Sharp> {
return sharp(await solidPng(200, 50, 90));
}
it("value=-100 fully desaturates (R == G == B)", async () => {
const out = await (await saturation(await coloredInput(), { value: -100 })).toBuffer();
const [r, g, b] = await firstPixel(out);
expect(r).toBe(g);
expect(g).toBe(b);
});
it("value=0 is an exact no-op (multiplier 1.0)", async () => {
const before = await coloredInput();
const original = await before.clone().raw().toBuffer();
const out = await (await saturation(before, { value: 0 })).toBuffer();
const after = await sharp(out).raw().toBuffer();
expect(Buffer.compare(original, after)).toBe(0);
});
it("value=+100 widens the channel spread versus the source", async () => {
const [r0, g0, b0] = await firstPixel(await solidPng(200, 50, 90));
const sourceSpread = Math.max(r0, g0, b0) - Math.min(r0, g0, b0);
const out = await (await saturation(await coloredInput(), { value: 100 })).toBuffer();
const [r, g, b] = await firstPixel(out);
const outSpread = Math.max(r, g, b) - Math.min(r, g, b);
expect(outSpread).toBeGreaterThan(sourceSpread);
});
it("rejects values outside -100..100 at both edges", async () => {
await expect(saturation(await coloredInput(), { value: 101 })).rejects.toThrow();
await expect(saturation(await coloredInput(), { value: -101 })).rejects.toThrow();
});
it("accepts the inclusive edges -100 and 100", async () => {
await expect(saturation(await coloredInput(), { value: -100 })).resolves.toBeDefined();
await expect(saturation(await coloredInput(), { value: 100 })).resolves.toBeDefined();
});
});
@@ -0,0 +1,248 @@
import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
import { compress } from "../src/operations/compress.js";
// Targeted mutation-killing tests for src/operations/compress.ts.
//
// The target-size path runs a binary search over JPEG quality and, when the
// quality-1 floor still overshoots, a downscale loop. Every expected byte size
// and dimension below was measured against the real Sharp encoder (deterministic
// for these fixed inputs) rather than guessed: a mutation that shifts the
// converged quality by even one step changes the exact output size, and a
// mutation to the downscale loop changes the exact output dimensions. Asserting
// those exact values is what distinguishes correct code from each mutant.
// Deterministic LCG so the pixel content (and therefore every compressed size)
// is stable across runs and machines.
function seededPhoto(
width: number,
height: number,
seed: number,
freqX: number,
freqY: number,
noise: number,
): Promise<Buffer> {
const channels = 3;
const data = Buffer.alloc(width * height * channels);
let state = seed;
const rnd = (): number => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
const clamp = (v: number): number => Math.max(0, Math.min(255, v));
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * channels;
const base = Math.sin(x / freqX) * 60 + Math.cos(y / freqY) * 60 + 128;
data[idx] = clamp(base + (rnd() - 0.5) * noise);
data[idx + 1] = clamp(base * 0.8 + (rnd() - 0.5) * noise);
data[idx + 2] = clamp(base * 0.6 + (rnd() - 0.5) * noise);
}
}
return sharp(data, { raw: { width, height, channels } }).png().toBuffer();
}
async function outputInfo(
result: sharp.Sharp,
): Promise<{ size: number; width: number; height: number }> {
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
return { size: buf.length, width: meta.width ?? 0, height: meta.height ?? 0 };
}
// 500x500 photo-like fixture: JPEG quality meaningfully changes its compressed
// size across the whole 1..100 range, so the binary search actually converges.
// Measured reference points (deterministic):
// full-dim JPEG size: q1=2709, q49=44941, q50=45470, q51=45976, q69=69425, q100=295415
let photo500: Buffer;
beforeAll(async () => {
photo500 = await seededPhoto(500, 500, 123456789, 12, 9, 90);
});
describe("compress quality guard (L45)", () => {
// `if (q < 1 || q > 100)` has two operands, each with its own `-> false` mutant.
// quality=101 trips ONLY the upper bound, so the `q > 100 -> false` mutant stops
// throwing while correct throws. quality=0 trips ONLY the lower bound, so the
// `q < 1 -> false` mutant stops throwing. Both breaches are needed to kill both.
it("throws for quality just above the max (101)", async () => {
await expect(compress(sharp(photo500), { quality: 101, format: "jpg" })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("throws for quality just below the min (0)", async () => {
await expect(compress(sharp(photo500), { quality: 0, format: "jpg" })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("accepts the max boundary quality (100) without throwing", async () => {
const result = await compress(sharp(photo500), { quality: 100, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(500);
expect(info.height).toBe(500);
});
it("accepts the min boundary quality (1) without throwing", async () => {
const result = await compress(sharp(photo500), { quality: 1, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(500);
expect(info.height).toBe(500);
});
});
describe("compress target-size binary search (L64, L65, L71, L74, L76)", () => {
// The q=50 encode is exactly 45470 bytes. With `resultSize <= targetBytes`
// (correct) a target of 45470 accepts q=50; the L71 `<= -> <` mutant rejects
// it (size not strictly < target) and settles for q=49 (44941). The L74
// `low = mid + 1 -> mid - 1` mutant also fails to hold q=50. Only exact-size
// assertion (not `<= target`) separates them.
it("lands exactly on the quality whose size equals the target (kills L71 + L74)", async () => {
const target = 45470;
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
const info = await outputInfo(result);
expect(info.size).toBe(45470);
expect(info.size).toBeLessThanOrEqual(target);
expect(info.width).toBe(500);
expect(info.height).toBe(500);
});
// Multi-iteration converge where the answer sits at q=49 (44941). The L64 loop
// bound `low <= high -> low < high` and the L76 `high = mid - 1 -> mid + 1`
// mutant both diverge to a different final quality/size here.
it("converges over several iterations to the exact best quality (kills L64 bound + L76)", async () => {
const target = 45000;
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
const info = await outputInfo(result);
expect(info.size).toBe(44941);
expect(info.size).toBeLessThanOrEqual(target);
expect(info.width).toBe(500);
expect(info.height).toBe(500);
});
// Low-quality region: correct converges to q=11 (9220). The L76 `high = mid - 1
// -> mid + 1` mutation (search moves the wrong way when overshooting) lands q=9,
// a different exact size. The L65 `(low+high)/2 -> (low-high)/2` midpoint mutant
// collapses every probe to q=1 and can never reach 9220.
it("drives the search downward to a low quality and stays full-dimension (kills L65 + L76)", async () => {
const target = 10000;
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
const info = await outputInfo(result);
expect(info.size).toBe(9220);
expect(info.size).toBeLessThanOrEqual(target);
expect(info.width).toBe(500);
expect(info.height).toBe(500);
});
// Mid-range target the search reaches at full dimensions (q=32, 29271). A broken
// midpoint or update rule diverges from this exact size.
it("resolves a mid-range target to its exact converged size (kills L65 midpoint)", async () => {
const target = 30000;
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
const info = await outputInfo(result);
expect(info.size).toBe(29271);
expect(info.size).toBeLessThanOrEqual(target);
expect(info.width).toBe(500);
expect(info.height).toBe(500);
});
// Target sits one byte above the quality-1 floor (floor is 2709). Correct code
// finds q=2 at full dimensions and never scales. The L76 `high = mid - 1 ->
// mid + 1` mutant fails to find ANY full-dim quality here, which forces it into
// the downscale path and shrinks the output below 500x500. Asserting full
// dimensions therefore also guards the "search converged, not scaled" boundary.
it("hits the near-floor target at full dimensions without scaling (kills L76 direction)", async () => {
const target = 2800;
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
const info = await outputInfo(result);
expect(info.size).toBe(2709);
expect(info.size).toBeLessThanOrEqual(target);
expect(info.width).toBe(500);
expect(info.height).toBe(500);
});
});
describe("compress tolerance early-break (L73)", () => {
// Target 70000 is reachable within the 1% tolerance at q=69 (69425), so correct
// code takes the `(target - size)/target <= tolerance` break at the optimum.
// The L73 Conditional (`-> true`, break on the first accepted probe) and the
// Equality flip (`<= -> >=`) both bail out early at q=51 (45976), wasting ~35%
// of the byte budget. Asserting the exact converged size distinguishes them.
it("breaks at the in-tolerance optimum rather than the first accepted probe (kills L73)", async () => {
const target = 70000;
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
const info = await outputInfo(result);
expect(info.size).toBe(69425);
expect(info.size).toBeLessThanOrEqual(target);
// Within 1% tolerance of the target: proves the early-break path is exercised,
// not merely a full 12-iteration convergence.
expect((target - info.size) / target).toBeLessThanOrEqual(0.01);
expect(info.width).toBe(500);
expect(info.height).toBe(500);
});
});
describe("compress downscale pass (L110, L117)", () => {
// Target 1355 is below the full-dim quality-1 floor (2709), so the search fails
// full-dim and enters the downscale loop. It first finds a valid quality at the
// 211x211 pass (q=5, 1334 bytes) and returns there via `if (q !== null)`. The
// L117 mutants change that: `q === null` / `-> true` return on the FIRST pass
// (375x375) instead, and `-> false` never returns from the loop and falls
// through to the 50x50 floor. Exact output dimensions pin the correct branch.
it("returns at the first downscale pass that finds a quality (kills L117)", async () => {
const target = 1355;
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(211);
expect(info.height).toBe(211);
expect(info.size).toBeLessThanOrEqual(target);
});
// Impossibly small target on a 20x40 source forces the downscale loop to the
// dimension floor. The passes are 15x30, 11x23, then 8x17 which trips
// `newWidth < 10 || newHeight < 10` on the WIDTH axis (8 < 10, height 17 is not).
// Correct code breaks and returns the last good pass, 11x23. The Logical
// `|| -> &&` mutant does NOT break at 8x17 (both axes not < 10) and shrinks to
// 6x13; the whole-condition `-> false` mutant never breaks and shrinks to 2x4;
// the `newWidth < 10 -> false` operand mutant loses the width guard so 8x17 no
// longer breaks. All three change the exact output dimensions.
it("stops the downscale loop when the width axis hits the floor (kills L110 width operand)", async () => {
const asymmetric = await seededPhoto(20, 40, 55555, 3, 2, 120);
const result = await compress(sharp(asymmetric), { targetSizeBytes: 1, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(11);
expect(info.height).toBe(23);
// The 10px floor is respected on both axes: dimensions never drop below 10.
expect(info.width).toBeGreaterThanOrEqual(10);
expect(info.height).toBeGreaterThanOrEqual(10);
});
// Transposed source (40x20): the passes are 30x15, 23x11, then 17x8 which trips
// the SAME guard but on the HEIGHT axis (8 < 10, width 17 is not). Correct code
// returns the last good pass, 23x11. The `newHeight < 10 -> false` operand
// mutant loses the height guard, so 17x8 no longer breaks and the output shrinks
// further. The width-axis case above cannot catch this operand; only a
// height-limited source can.
it("stops the downscale loop when the height axis hits the floor (kills L110 height operand)", async () => {
const asymmetric = await seededPhoto(40, 20, 55555, 3, 2, 120);
const result = await compress(sharp(asymmetric), { targetSizeBytes: 1, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(23);
expect(info.height).toBe(11);
expect(info.width).toBeGreaterThanOrEqual(10);
expect(info.height).toBeGreaterThanOrEqual(10);
});
// A 13x13 source scales to exactly 10x10 on the first pass. With `< 10`
// (correct) that is NOT below the floor, so the loop continues and the final
// fallback returns 10x10. The L110 Equality `< -> <=` mutant treats 10 as below
// the floor, breaks on pass 1, and returns the un-scaled 13x13 instead.
it("keeps a dimension that lands exactly on 10 (kills L110 equality)", async () => {
const tiny = await seededPhoto(13, 13, 987654321, 3, 2, 120);
const result = await compress(sharp(tiny), { targetSizeBytes: 1, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(10);
expect(info.height).toBe(10);
});
});
@@ -0,0 +1,434 @@
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 { colorBlindness } from "../src/operations/color-blindness.js";
import { convert } from "../src/operations/convert.js";
import type { ColorBlindnessType } from "../src/types.js";
/**
* Build a fixed-length Buffer from an array of byte values, zero-padded to `len`.
* Used to hand-craft magic-byte headers that Sharp cannot decode, forcing
* detectFormat to fall through to its magic-byte detector.
*/
function bytes(values: number[], len?: number): Buffer {
const buf = Buffer.alloc(len ?? values.length, 0);
for (let i = 0; i < values.length; i++) {
buf[i] = values[i];
}
return buf;
}
/** Mean value per channel (rounded) of a decoded buffer. */
async function channelMeans(buffer: Buffer): Promise<number[]> {
const stats = await sharp(buffer).stats();
return stats.channels.map((c) => Math.round(c.mean));
}
let redPng: Buffer;
beforeAll(async () => {
redPng = await sharp({
create: {
width: 32,
height: 32,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
});
describe("detectFormat via Sharp metadata", () => {
// Real encodes: Sharp decodes these directly and returns metadata.format,
// exercising the sharp-metadata branch (never reaching magic bytes).
const realEncodeCases: Array<{ name: string; make: () => Promise<Buffer>; expected: string }> = [
{
name: "png",
make: () => sharp({ create: base() }).png().toBuffer(),
expected: "png",
},
{
name: "jpeg",
make: () => sharp({ create: base() }).jpeg().toBuffer(),
expected: "jpeg",
},
{
name: "webp",
make: () => sharp({ create: base() }).webp().toBuffer(),
expected: "webp",
},
{
name: "gif",
make: () => sharp({ create: base() }).gif().toBuffer(),
expected: "gif",
},
{
name: "tiff",
make: () => sharp({ create: base() }).tiff().toBuffer(),
expected: "tiff",
},
];
for (const { name, make, expected } of realEncodeCases) {
it(`detects a real ${name} encode as ${expected}`, async () => {
const buf = await make();
expect(await detectFormat(buf)).toBe(expected);
});
}
function base() {
return {
width: 8,
height: 8,
channels: 3 as const,
background: { r: 10, g: 120, b: 200 },
};
}
});
describe("detectFormat via magic bytes", () => {
// Every format the magic-byte table recognizes, with hand-built headers that
// Sharp cannot decode. Each asserts the EXACT format string, killing the
// per-entry string-literal mutants.
const magicCases: Array<{ name: string; buf: Buffer; expected: string }> = [
{ name: "png magic", buf: bytes([0x89, 0x50, 0x4e, 0x47], 16), expected: "png" },
{ name: "jpeg magic", buf: bytes([0xff, 0xd8, 0xff], 16), expected: "jpeg" },
{ name: "gif (GIF8)", buf: bytes([0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 16), expected: "gif" },
{ name: "tiff little-endian", buf: bytes([0x49, 0x49, 0x2a, 0x00], 16), expected: "tiff" },
{ name: "tiff big-endian", buf: bytes([0x4d, 0x4d, 0x00, 0x2a], 16), expected: "tiff" },
{ name: "bmp (BM)", buf: bytes([0x42, 0x4d], 16), expected: "bmp" },
{
name: "avif ftyp+avif brand",
buf: bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 16),
expected: "avif",
},
{
name: "avif ftyp+avis brand",
buf: bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x73], 16),
expected: "avif",
},
{
name: "jxl ISOBMFF container",
buf: bytes([0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], 16),
expected: "jxl",
},
{ name: "jxl raw codestream", buf: bytes([0xff, 0x0a], 16), expected: "jxl" },
{ name: "ico", buf: bytes([0x00, 0x00, 0x01, 0x00], 16), expected: "ico" },
{ name: "psd (8BPS)", buf: bytes([0x38, 0x42, 0x50, 0x53], 16), expected: "psd" },
{ name: "exr", buf: bytes([0x76, 0x2f, 0x31, 0x01], 16), expected: "exr" },
{
name: "cr3 ftyp+crx brand",
buf: bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x63, 0x72, 0x78, 0x20], 16),
expected: "cr3",
},
{
name: "raf (FUJIFILMCCD-RAW)",
buf: bytes(
[0x46, 0x55, 0x4a, 0x49, 0x46, 0x49, 0x4c, 0x4d, 0x43, 0x43, 0x44, 0x2d, 0x52, 0x41, 0x57],
24,
),
expected: "raf",
},
{ name: "x3f (FOVb)", buf: bytes([0x46, 0x4f, 0x56, 0x62], 16), expected: "x3f" },
{ name: "mrw (\\x00MRM)", buf: bytes([0x00, 0x4d, 0x52, 0x4d], 16), expected: "mrw" },
{
name: "jp2 box signature",
buf: bytes([0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a], 16),
expected: "jp2",
},
{ name: "j2k raw codestream", buf: bytes([0xff, 0x4f, 0xff, 0x51], 16), expected: "jp2" },
{ name: "dds", buf: bytes([0x44, 0x44, 0x53, 0x20], 16), expected: "dds" },
{ name: "cur", buf: bytes([0x00, 0x00, 0x02, 0x00], 16), expected: "cur" },
{ name: "dpx forward (SDPX)", buf: bytes([0x53, 0x44, 0x50, 0x58], 16), expected: "dpx" },
{ name: "dpx reverse (XPDS)", buf: bytes([0x58, 0x50, 0x44, 0x53], 16), expected: "dpx" },
{ name: "cineon", buf: bytes([0x80, 0x2a, 0x5f, 0xd7], 16), expected: "cin" },
{
name: "fits (SIMPLE)",
buf: bytes([0x53, 0x49, 0x4d, 0x50, 0x4c, 0x45], 16),
expected: "fits",
},
{
name: "eps ASCII (%!PS-Adobe)",
buf: bytes([0x25, 0x21, 0x50, 0x53, 0x2d, 0x41, 0x64, 0x6f, 0x62, 0x65], 16),
expected: "eps",
},
{ name: "eps binary (DOS)", buf: bytes([0xc5, 0xd0, 0xd3, 0xc6], 16), expected: "eps" },
{ name: "ppm P3", buf: bytes([0x50, 0x33, 0x0a], 16), expected: "ppm" },
{ name: "ppm P6", buf: bytes([0x50, 0x36, 0x0a], 16), expected: "ppm" },
{ name: "qoi (qoif)", buf: bytes([0x71, 0x6f, 0x69, 0x66], 16), expected: "qoi" },
];
for (const { name, buf, expected } of magicCases) {
it(`detects ${name} as ${expected}`, async () => {
expect(await detectFormat(buf)).toBe(expected);
});
}
describe("webp RIFF secondary verification", () => {
it("detects RIFF...WEBP as webp (length 12, correct signature)", async () => {
const buf = bytes([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50], 12);
expect(await detectFormat(buf)).toBe("webp");
});
it("returns unknown for RIFF with a non-WEBP signature at length 12", async () => {
// RIFF matches, buffer >= 12, but bytes 8..12 are "AVI " not "WEBP".
const buf = bytes([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x41, 0x56, 0x49, 0x20], 16);
expect(await detectFormat(buf)).toBe("unknown");
});
it("returns unknown when WEBP signature is one byte off (WEBX)", async () => {
const buf = bytes([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x58], 12);
expect(await detectFormat(buf)).toBe("unknown");
});
it("returns webp for a RIFF header shorter than 12 (signature check skipped)", async () => {
// Documents the exact code path: the `buffer.length >= 12` guard is false,
// so the WEBP-signature verification is skipped and RIFF alone yields webp.
expect(await detectFormat(bytes([0x52, 0x49, 0x46, 0x46], 11))).toBe("webp");
expect(await detectFormat(bytes([0x52, 0x49, 0x46, 0x46], 4))).toBe("webp");
});
});
describe("avif ftyp brand verification", () => {
it("returns unknown for ftyp box with a non-AVIF brand (mp42)", async () => {
const buf = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32], 16);
expect(await detectFormat(buf)).toBe("unknown");
});
it("returns unknown for ftyp box shorter than 12 bytes", async () => {
// ftyp present at offset 4 but only 11 bytes: the `< 12` guard hits `continue`.
const buf = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69], 11);
expect(await detectFormat(buf)).toBe("unknown");
});
it("detects avif at exactly 12 bytes", async () => {
const buf = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 12);
expect(await detectFormat(buf)).toBe("avif");
});
});
describe("cr3 ftyp brand verification", () => {
it("returns unknown for ftyp box with a crx-like-but-wrong brand", async () => {
// "crx!" instead of "crx " (trailing space): avif brand check fails, cr3
// brand check also fails, so the ftyp entries fall through to unknown.
const buf = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x63, 0x72, 0x78, 0x21], 16);
expect(await detectFormat(buf)).toBe("unknown");
});
});
describe("single-byte mismatches are not detected", () => {
// Feed a header matching every byte but one; assert it is NOT the format.
// Kills the per-byte `===`/`!==` comparison and the `offset + i` index mutants.
const mismatchCases: Array<{ name: string; buf: Buffer }> = [
{ name: "png with wrong 4th byte", buf: bytes([0x89, 0x50, 0x4e, 0x48], 16) },
{ name: "jpeg with wrong 2nd byte", buf: bytes([0xff, 0xd7, 0xff], 16) },
{ name: "gif with wrong 4th byte", buf: bytes([0x47, 0x49, 0x46, 0x37], 16) },
{ name: "tiff LE with wrong 3rd byte", buf: bytes([0x49, 0x49, 0x2b, 0x00], 16) },
{ name: "qoi with wrong 1st byte", buf: bytes([0x70, 0x6f, 0x69, 0x66], 16) },
{ name: "bmp with wrong 2nd byte", buf: bytes([0x42, 0x4e], 16) },
{ name: "psd with wrong last byte", buf: bytes([0x38, 0x42, 0x50, 0x54], 16) },
];
for (const { name, buf } of mismatchCases) {
it(`returns unknown for ${name}`, async () => {
expect(await detectFormat(buf)).toBe("unknown");
});
}
});
describe("length guard boundaries", () => {
it("detects bmp at exactly 2 bytes (offset 0 + 2 magic bytes)", async () => {
expect(await detectFormat(bytes([0x42, 0x4d], 2))).toBe("bmp");
});
it("returns unknown for a 1-byte buffer (one short of bmp's 2)", async () => {
expect(await detectFormat(bytes([0x42], 1))).toBe("unknown");
});
it("detects gif at exactly 4 bytes (length == offset + magic length)", async () => {
expect(await detectFormat(bytes([0x47, 0x49, 0x46, 0x38], 4))).toBe("gif");
});
it("returns unknown for a 3-byte GIF header (one short of 4)", async () => {
expect(await detectFormat(bytes([0x47, 0x49, 0x46], 3))).toBe("unknown");
});
it("returns unknown for a 7-byte ftyp buffer (one short of offset 4 + 4)", async () => {
expect(await detectFormat(bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79], 7))).toBe("unknown");
});
});
describe("fallback / negative cases", () => {
it("returns unknown for an empty buffer", async () => {
expect(await detectFormat(Buffer.alloc(0))).toBe("unknown");
});
it("returns unknown for a 2-byte truncated PNG header", async () => {
expect(await detectFormat(bytes([0x89, 0x50], 2))).toBe("unknown");
});
it("returns unknown for random unrecognized bytes", async () => {
expect(await detectFormat(bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88], 16))).toBe(
"unknown",
);
});
});
});
describe("convert output format and quality", () => {
it("maps the jpg alias to a jpeg encode", async () => {
const out = await (await convert(sharp(redPng), { format: "jpg" })).toBuffer();
expect((await sharp(out).metadata()).format).toBe("jpeg");
});
it("encodes png without quality changes", async () => {
const out = await (await convert(sharp(redPng), { format: "png" })).toBuffer();
expect((await sharp(out).metadata()).format).toBe("png");
});
it("encodes webp, avif, tiff, and gif to their exact formats", async () => {
const webp = await (await convert(sharp(redPng), { format: "webp" })).toBuffer();
expect((await sharp(webp).metadata()).format).toBe("webp");
const avif = await (await convert(sharp(redPng), { format: "avif" })).toBuffer();
// Sharp reports avif-encoded data as "heif".
expect((await sharp(avif).metadata()).format).toBe("heif");
const tiff = await (await convert(sharp(redPng), { format: "tiff" })).toBuffer();
expect((await sharp(tiff).metadata()).format).toBe("tiff");
const gif = await (await convert(sharp(redPng), { format: "gif" })).toBuffer();
expect((await sharp(gif).metadata()).format).toBe("gif");
});
it("applies quality: lower quality yields a smaller (or equal) jpeg than higher", async () => {
const q10 = await (await convert(sharp(redPng), { format: "jpg", quality: 10 })).toBuffer();
const q95 = await (await convert(sharp(redPng), { format: "jpg", quality: 95 })).toBuffer();
expect(q10.length).toBeLessThan(q95.length);
});
it("omitting quality does not throw and still produces the target format", async () => {
const out = await (await convert(sharp(redPng), { format: "jpg" })).toBuffer();
expect((await sharp(out).metadata()).format).toBe("jpeg");
});
it("accepts the inclusive quality bounds 1 and 100", async () => {
await expect(
(await convert(sharp(redPng), { format: "jpg", quality: 1 })).toBuffer(),
).resolves.toBeInstanceOf(Buffer);
await expect(
(await convert(sharp(redPng), { format: "jpg", quality: 100 })).toBuffer(),
).resolves.toBeInstanceOf(Buffer);
});
it("rejects quality below 1", async () => {
await expect(convert(sharp(redPng), { format: "jpg", quality: 0 })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("rejects quality above 100", async () => {
await expect(convert(sharp(redPng), { format: "jpg", quality: 101 })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("throws on an unsupported output format", async () => {
await expect(convert(sharp(redPng), { format: "bmp" as unknown as "png" })).rejects.toThrow(
"Unsupported output format: bmp",
);
});
});
describe("colorBlindness simulation matrix", () => {
it("applies achromatopsia (grayscale) so red drops and green/blue rise to equal luminance", async () => {
const out = await (await colorBlindness(sharp(redPng), { type: "achromatopsia" }))
.png()
.toBuffer();
const [r, g, b] = await channelMeans(out);
// Input is pure red (255, 0, 0). Luminance of red = 0.2126 * 255 ~= 54.
expect(r).toBeLessThan(255);
expect(g).toBeGreaterThan(0);
expect(b).toBeGreaterThan(0);
// All three channels collapse to the same luminance value.
expect(r).toBe(g);
expect(g).toBe(b);
expect(r).toBeGreaterThanOrEqual(50);
expect(r).toBeLessThanOrEqual(58);
});
it("selects the matrix by type: protanopia differs from achromatopsia", async () => {
const achroma = await channelMeans(
await (await colorBlindness(sharp(redPng), { type: "achromatopsia" })).png().toBuffer(),
);
const protan = await channelMeans(
await (await colorBlindness(sharp(redPng), { type: "protanopia" })).png().toBuffer(),
);
// Distinct matrices must yield distinct channel means for the same input.
expect(protan).not.toEqual(achroma);
// Protanopia on pure red keeps R > G > B (0.152/0.114/-0.003 * 255).
expect(protan[0]).toBeGreaterThan(protan[1]);
expect(protan[1]).toBeGreaterThanOrEqual(protan[2]);
});
it("runs every color-blindness type without error", async () => {
const types: ColorBlindnessType[] = [
"protanopia",
"deuteranopia",
"tritanopia",
"protanomaly",
"deuteranomaly",
"tritanomaly",
"achromatopsia",
"blueConeMonochromacy",
];
for (const type of types) {
const out = await (await colorBlindness(sharp(redPng), { type })).png().toBuffer();
expect(out).toBeInstanceOf(Buffer);
expect(out.length).toBeGreaterThan(0);
}
});
});
describe("processImage pipeline dispatch", () => {
it("throws with the operation name on an unknown operation type", async () => {
await expect(processImage(redPng, [{ type: "nonexistent-op", options: {} }])).rejects.toThrow(
"Unknown operation: nonexistent-op",
);
});
it("passes the input format through when no operations and no outputFormat are given", async () => {
const result = await processImage(redPng, []);
expect(result.info.format).toBe("png");
});
it("converts to the requested outputFormat", async () => {
const result = await processImage(redPng, [], "webp");
expect(result.info.format).toBe("webp");
});
it("maps the heif outputFormat alias through FORMAT_MAP to an avif encode", async () => {
const result = await processImage(redPng, [], "heif");
// FORMAT_MAP.heif -> "avif"; Sharp reports avif data as "heif".
expect(result.info.format).toBe("heif");
});
it("throws on an unsupported outputFormat", async () => {
await expect(processImage(redPng, [], "notaformat" as unknown as "png")).rejects.toThrow(
"Unsupported output format: notaformat",
);
});
it("applies a registered operation in sequence", async () => {
// grayscale collapses pure red to a single mid-gray value across channels.
const result = await processImage(redPng, [{ type: "grayscale", options: {} }]);
const [r, g, b] = await channelMeans(result.buffer);
expect(r).toBe(g);
expect(g).toBe(b);
expect(r).toBeGreaterThan(0);
expect(r).toBeLessThan(255);
});
});
@@ -0,0 +1,257 @@
import exifReader from "exif-reader";
import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
import { editMetadata } from "../src/operations/edit-metadata.js";
import type { Sharp } from "../src/types.js";
// Mutation-killing suite for src/operations/edit-metadata.ts. Every case seeds a
// JPEG with known EXIF, runs editMetadata, then re-reads the encoded output via
// sharp().metadata() + exif-reader and asserts the EXACT tag values a surviving
// mutant would change. Sibling coverage lives in edit-strip-metadata.test.ts; this
// file targets the specific survivors the mutation report still flags.
//
// Platform note (verified via scratch runs before writing these assertions):
// Sharp's withExif rebuild path preserves existing IFD2/Photo tags FNumber (number)
// and LensModel (string), and existing IFD0 numeric tags (Orientation,
// ResolutionUnit) alongside string tags (Artist, Software). Existing IFD2 date/blob
// tags (DateTimeOriginal, UserComment, ExifVersion) do NOT survive the rebuild, so
// assertions deliberately avoid those as carriers.
// A JPEG carrying rich EXIF across BOTH IFDs, chosen so every tag below reads back
// through the rebuild path: IFD0 has string + numeric tags; IFD2 has a numeric tag
// (FNumber) and a string tag (LensModel) that both survive withExif reconstruction.
let seedBuffer: Buffer;
const seedImage = (): Sharp => sharp(seedBuffer);
interface ExifSections {
hasExif: boolean;
hasIcc: boolean;
// exif-reader's typings are loose; index into the parsed sections directly.
image: Record<string, unknown>;
photo: Record<string, unknown>;
}
// Encode to JPEG and decode EXIF/ICC so assertions pin concrete field values.
async function readBack(image: Sharp): Promise<ExifSections> {
const buf = await image.jpeg().toBuffer();
const meta = await sharp(buf).metadata();
const parsed = meta.exif ? exifReader(meta.exif) : { Image: {}, Photo: {} };
return {
hasExif: !!meta.exif,
hasIcc: !!meta.icc,
image: (parsed.Image ?? {}) as Record<string, unknown>,
photo: (parsed.Photo ?? {}) as Record<string, unknown>,
};
}
beforeAll(async () => {
seedBuffer = await sharp({
create: {
width: 24,
height: 16,
channels: 3,
background: { r: 10, g: 20, b: 30 },
},
})
.withExif({
IFD0: {
Artist: "OrigArtist",
Copyright: "OrigCopyright",
ImageDescription: "OrigDesc",
Software: "OrigSoft",
// Numeric IFD0 tags that Sharp does NOT auto-regenerate, so they survive the
// rebuild only because the string||number guard admits the number branch.
// (Orientation/ResolutionUnit are auto-added regardless, so they cannot pin
// the number branch on their own.)
ImageWidth: "1024",
ImageLength: "768",
},
IFD2: {
DateTimeOriginal: "2001:01:01 01:01:01",
FNumber: "2",
LensModel: "OtterLens",
},
})
.withIccProfile("srgb")
.jpeg()
.toBuffer();
});
describe("editMetadata mutation kills", () => {
it("sanity: the seed carries IFD0 strings and IFD2 FNumber + LensModel", async () => {
const meta = await sharp(seedBuffer).metadata();
expect(meta.exif).toBeTruthy();
const parsed = exifReader(meta.exif as Buffer);
expect(parsed.Image?.Artist).toBe("OrigArtist");
expect(parsed.Image?.Software).toBe("OrigSoft");
// exif-reader returns these Photo tags as a number and a string respectively.
expect((parsed.Photo as Record<string, unknown>)?.FNumber).toBe(2);
expect((parsed.Photo as Record<string, unknown>)?.LensModel).toBe("OtterLens");
});
// Behavior lock for the edit-wins-over-removal contract (writtenTags, L55). When a tag
// is edited AND listed in fieldsToRemove, the edit must win and the non-written removal
// target must still be dropped. NOTE: the L55 ArrayDeclaration "[]" mutant is EQUIVALENT
// and not asserted here: even with writtenTags emptied, edits.IFD0 is merged into
// finalIFD0 after the removal loop, so an edited tag always re-appears with its edited
// value. writtenTags only skips a redundant loop-time write; it changes no output.
it("lets an edit win over a same-named removal while dropping non-written targets", async () => {
const { image, photo } = await readBack(
await editMetadata(seedImage(), {
artist: "EditedArtist",
dateTimeOriginal: "2019:09:09 09:09:09",
fieldsToRemove: ["Artist", "Copyright"],
}),
);
expect(image.Artist).toBe("EditedArtist");
expect((photo.DateTimeOriginal as Date).toISOString()).toBe("2019-09-09T09:09:09.000Z");
// The non-written removal target is actually gone.
expect(image.Copyright).toBeUndefined();
// An untouched existing tag carries through the rebuild.
expect(image.Software).toBe("OrigSoft");
});
// L61 hasRemovals = fieldsToRemove.length > 0 || options.clearGps -> "false".
// fieldsToRemove is non-empty with NO edits: hasRemovals true routes to the rebuild
// path, so Copyright is removed. Forcing hasRemovals false would take keepMetadata()
// and leave Copyright intact, so asserting Copyright is gone kills the mutant.
it("takes the removal rebuild path when only fieldsToRemove is set (L61)", async () => {
const { image } = await readBack(
await editMetadata(seedImage(), { fieldsToRemove: ["Copyright"] }),
);
expect(image.Copyright).toBeUndefined();
// Everything else is rebuilt from source EXIF and preserved.
expect(image.Artist).toBe("OrigArtist");
expect(image.Software).toBe("OrigSoft");
});
// Complement for L61 via the clearGps arm: clearGps true, empty fieldsToRemove, no
// edits. hasRemovals must be true (rebuild) so EXIF is reconstructed; a false mutant
// would keepMetadata(). Both branches keep the fields, but only the rebuild path is
// reached here, and the sibling assertion above already pins the observable removal.
it("treats clearGps as a removal trigger and rebuilds EXIF (L61 clearGps arm)", async () => {
const { hasExif, image, photo } = await readBack(
await editMetadata(seedImage(), { clearGps: true }),
);
expect(hasExif).toBe(true);
// Non-GPS fields from BOTH IFDs survive the rebuild verbatim.
expect(image.Artist).toBe("OrigArtist");
expect(image.Copyright).toBe("OrigCopyright");
expect(photo.FNumber).toBe(2);
expect(photo.LensModel).toBe("OtterLens");
});
// L76 if(parsed.Image) block + L80 typeof guard (string || number).
// Rebuild path over an input WITH existing IFD0. Both a STRING tag (Software) and a
// NUMERIC tag (Orientation, ResolutionUnit) must survive. A broken typeof guard that
// drops strings loses Software; one that drops numbers loses Orientation. Emptying the
// parsed.Image walk loses all of them. The `!== "number"` equality mutant drops the
// numeric tags specifically.
it("preserves existing IFD0 string AND numeric tags through rebuild (L76, L80)", async () => {
const { image } = await readBack(
await editMetadata(seedImage(), { fieldsToRemove: ["Copyright"] }),
);
// String-typed IFD0 tags survive (kills the string-operand and false-guard mutants).
expect(image.Software).toBe("OrigSoft");
expect(image.Artist).toBe("OrigArtist");
expect(image.ImageDescription).toBe("OrigDesc");
// Numeric-typed IFD0 tags survive. ImageWidth/ImageLength are NOT auto-regenerated
// by Sharp, so they carry through ONLY because the string||number guard admits the
// number branch. A `!== "number"` or false-guard mutant drops them.
expect(image.ImageWidth).toBe(1024);
expect(image.ImageLength).toBe(768);
// The removed tag is the only IFD0 casualty.
expect(image.Copyright).toBeUndefined();
});
// L80 ConditionalExpression "false": if the IFD0 typeof guard is forced false, NO
// existing IFD0 tag is copied into existingIFD0, so a non-edited tag like Software
// vanishes from the rebuilt output. Editing an unrelated field (dateTimeOriginal in
// IFD2) keeps us on the rebuild path without touching Software.
it("copies existing IFD0 tags into the rebuild rather than dropping them (L80 false)", async () => {
const { image, photo } = await readBack(
await editMetadata(seedImage(), {
dateTimeOriginal: "2020:02:02 02:02:02",
fieldsToRemove: ["Copyright"],
}),
);
// Software was neither edited nor removed; it survives only if the guard admits it.
expect(image.Software).toBe("OrigSoft");
expect(image.ImageDescription).toBe("OrigDesc");
// The IFD2 edit landed on the rebuild path.
expect((photo.DateTimeOriginal as Date).toISOString()).toBe("2020-02-02T02:02:02.000Z");
});
// L85 if(parsed.Photo) block + L86 body + L89 typeof guard (string || number).
// Rebuild path over an input WITH existing IFD2. Both a NUMERIC Photo tag (FNumber)
// and a STRING Photo tag (LensModel) must survive. Emptying the parsed.Photo walk
// (L85 false / L86 {}) loses both; the string-operand mutant loses LensModel; the
// number-operand/`!== "number"` mutant loses FNumber.
it("preserves existing IFD2 number AND string tags through rebuild (L85, L86, L89)", async () => {
const { photo } = await readBack(
await editMetadata(seedImage(), { fieldsToRemove: ["Copyright"] }),
);
// Numeric Photo tag survives (kills the number-operand and !== "number" mutants).
expect(photo.FNumber).toBe(2);
// String Photo tag survives (kills the string-operand and !== "string" mutants).
expect(photo.LensModel).toBe("OtterLens");
});
// L87 if(fieldsToRemove.includes(k)) continue. Remove an existing IFD2 tag (FNumber)
// and assert a sibling IFD2 tag (LensModel) survives. Forcing the includes() check
// false lets FNumber through (removal never happens); forcing it true drops every
// Photo tag including LensModel. Only the exact-removal behavior passes both asserts.
it("removes a named IFD2 tag while a sibling IFD2 tag survives (L87)", async () => {
const { photo } = await readBack(
await editMetadata(seedImage(), { fieldsToRemove: ["FNumber"] }),
);
expect(photo.FNumber).toBeUndefined();
expect(photo.LensModel).toBe("OtterLens");
});
// L100 finalIFD2 = { ...existingIFD2, ...edits.IFD2 } -> ObjectLiteral "{}".
// On the rebuild path, edit dateTimeOriginal (an IFD2 edit) and keep the removal
// trigger. If finalIFD2 collapses to {}, both the edited DateTimeOriginal and the
// existing FNumber vanish from IFD2. Asserting both present kills the mutant.
it("builds finalIFD2 from existing IFD2 plus edits, not an empty object (L100)", async () => {
const { photo } = await readBack(
await editMetadata(seedImage(), {
dateTimeOriginal: "2023:03:03 03:03:03",
fieldsToRemove: ["Copyright"],
}),
);
// The IFD2 edit landed...
expect((photo.DateTimeOriginal as Date).toISOString()).toBe("2023-03-03T03:03:03.000Z");
// ...and the existing IFD2 tag merged in alongside it.
expect(photo.FNumber).toBe(2);
});
// L104 if(Object.keys(finalIFD2).length > 0) exif.IFD2 = finalIFD2. The "false"/
// "<= 0" mutants skip assigning exif.IFD2 even though finalIFD2 is NON-empty, so the
// IFD2 edit would never reach withExif. Asserting the edited DateTimeOriginal is
// present on the rebuild path kills those directions. (The "true"/">= 0" direction is
// an equivalent mutant: Sharp treats exif.IFD2 = {} identically to omitting it.)
it("assigns the non-empty finalIFD2 so IFD2 edits reach the output (L104 false/<=0)", async () => {
const { photo } = await readBack(
await editMetadata(seedImage(), {
dateTimeOriginal: "2024:04:04 04:04:04",
fieldsToRemove: ["Copyright"],
}),
);
expect((photo.DateTimeOriginal as Date).toISOString()).toBe("2024-04-04T04:04:04.000Z");
});
// L103 if(Object.keys(finalIFD0).length > 0) exif.IFD0 = finalIFD0. The block being
// skipped when finalIFD0 is non-empty would drop every IFD0 tag from the rebuilt
// output. Assert an edited IFD0 tag plus preserved existing IFD0 tags are present.
it("assigns the non-empty finalIFD0 so IFD0 tags reach the output (L103)", async () => {
const { image } = await readBack(
await editMetadata(seedImage(), {
artist: "RebuiltArtist",
fieldsToRemove: ["Copyright"],
}),
);
expect(image.Artist).toBe("RebuiltArtist");
expect(image.Software).toBe("OrigSoft");
});
});
@@ -0,0 +1,316 @@
import exifReader from "exif-reader";
import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
import { editMetadata } from "../src/operations/edit-metadata.js";
import { stripMetadata } from "../src/operations/strip-metadata.js";
import type { Sharp } from "../src/types.js";
// A JPEG carrying rich, known metadata: EXIF (IFD0 strings + IFD2 date) plus an
// ICC profile. Every assertion below round-trips through sharp().metadata() and
// exif-reader so it pins the SPECIFIC value a mutation would change.
let richBuffer: Buffer;
const richImage = (): Sharp => sharp(richBuffer);
interface ExifSections {
hasExif: boolean;
hasIcc: boolean;
image: NonNullable<ReturnType<typeof exifReader>["Image"]>;
photo: NonNullable<ReturnType<typeof exifReader>["Photo"]>;
}
// Encode the pipeline to a JPEG buffer and decode its EXIF/ICC so we can assert
// against concrete field values.
async function readBack(image: Sharp): Promise<ExifSections> {
const buf = await image.jpeg().toBuffer();
const meta = await sharp(buf).metadata();
const parsed = meta.exif ? exifReader(meta.exif) : { Image: {}, Photo: {} };
return {
hasExif: !!meta.exif,
hasIcc: !!meta.icc,
image: parsed.Image ?? {},
photo: parsed.Photo ?? {},
};
}
beforeAll(async () => {
richBuffer = await sharp({
create: {
width: 24,
height: 16,
channels: 3,
background: { r: 10, g: 20, b: 30 },
},
})
.withExif({
IFD0: {
Artist: "OrigArtist",
Copyright: "OrigCopyright",
ImageDescription: "OrigDesc",
Software: "OrigSoft",
},
IFD2: {
DateTimeOriginal: "2001:01:01 01:01:01",
},
})
.withIccProfile("srgb")
.jpeg()
.toBuffer();
});
describe("editMetadata", () => {
it("sanity check: the rich fixture carries the seeded EXIF fields", async () => {
const meta = await sharp(richBuffer).metadata();
expect(meta.exif).toBeTruthy();
const parsed = exifReader(meta.exif as Buffer);
expect(parsed.Image?.Artist).toBe("OrigArtist");
expect(parsed.Image?.Copyright).toBe("OrigCopyright");
expect(parsed.Image?.Software).toBe("OrigSoft");
expect(parsed.Image?.ImageDescription).toBe("OrigDesc");
});
it("writes each IFD0 string field to its exact tag with distinct values", async () => {
const { image } = await readBack(
await editMetadata(richImage(), {
artist: "Ada Lovelace",
copyright: "(c) 2026 Otter",
imageDescription: "A river otter",
software: "SnapOtter 2.0",
}),
);
// Each option must land on its OWN tag. A swapped field-name mutant
// (e.g. artist -> Copyright) makes one of these read the wrong value.
expect(image.Artist).toBe("Ada Lovelace");
expect(image.Copyright).toBe("(c) 2026 Otter");
expect(image.ImageDescription).toBe("A river otter");
expect(image.Software).toBe("SnapOtter 2.0");
});
it("routes dateTime to IFD0.DateTime and NOT to IFD2", async () => {
const { image, photo } = await readBack(
await editMetadata(richImage(), { dateTime: "2020:02:02 03:03:03" }),
);
// exif-reader returns DateTime fields as Date objects.
expect(image.DateTime).toBeInstanceOf(Date);
expect((image.DateTime as Date).toISOString()).toBe("2020-02-02T03:03:03.000Z");
// Seeded IFD2 original date stays as-is; the edit did not leak into it.
expect((photo.DateTimeOriginal as Date).toISOString()).toBe("2001-01-01T01:01:01.000Z");
});
it("routes dateTimeOriginal to IFD2/Photo.DateTimeOriginal, not IFD0", async () => {
const { image, photo } = await readBack(
await editMetadata(richImage(), { dateTimeOriginal: "2019:09:09 09:09:09" }),
);
expect((photo.DateTimeOriginal as Date).toISOString()).toBe("2019-09-09T09:09:09.000Z");
// IFD0.DateTime was never touched, so it must be absent from Image.
expect(image.DateTime).toBeUndefined();
});
it("merges edits onto the existing EXIF, leaving untouched fields intact", async () => {
const { image } = await readBack(
await editMetadata(richImage(), { artist: "NewArtist", software: "NewSoft" }),
);
// Edited fields change...
expect(image.Artist).toBe("NewArtist");
expect(image.Software).toBe("NewSoft");
// ...omitted fields keep their original values (withExifMerge path).
expect(image.Copyright).toBe("OrigCopyright");
expect(image.ImageDescription).toBe("OrigDesc");
});
it("skips empty-string values (length 0), preserving the original field", async () => {
// artist:"" must NOT be written. With no edits and no removals the function
// takes the keepMetadata() branch, so the original Artist survives verbatim.
const { image, hasExif, hasIcc } = await readBack(
await editMetadata(richImage(), { artist: "" }),
);
expect(image.Artist).toBe("OrigArtist");
expect(hasExif).toBe(true);
expect(hasIcc).toBe(true);
});
it("keeps all metadata (EXIF + ICC) when no options are given", async () => {
const { hasExif, hasIcc, image } = await readBack(await editMetadata(richImage(), {}));
expect(hasExif).toBe(true);
expect(hasIcc).toBe(true);
// keepMetadata() copies the original EXIF through unchanged.
expect(image.Artist).toBe("OrigArtist");
});
it("keeps all metadata when default (undefined) options are used", async () => {
const { hasExif, hasIcc } = await readBack(await editMetadata(richImage()));
expect(hasExif).toBe(true);
expect(hasIcc).toBe(true);
});
it("removes a requested field via fieldsToRemove while keeping the rest", async () => {
const { image } = await readBack(
await editMetadata(richImage(), { fieldsToRemove: ["Software"] }),
);
// Removed field is gone...
expect(image.Software).toBeUndefined();
// ...but every other seeded field is rebuilt and preserved.
expect(image.Artist).toBe("OrigArtist");
expect(image.Copyright).toBe("OrigCopyright");
expect(image.ImageDescription).toBe("OrigDesc");
});
it("removes a field AND applies an edit in the same call (withExif rebuild path)", async () => {
const { image } = await readBack(
await editMetadata(richImage(), {
artist: "Combined",
fieldsToRemove: ["Copyright"],
}),
);
expect(image.Artist).toBe("Combined");
expect(image.Copyright).toBeUndefined();
// Untouched, non-removed field carries over from the source EXIF.
expect(image.Software).toBe("OrigSoft");
});
it("ignores a fieldsToRemove entry that names an actively-edited tag", async () => {
// Artist is both edited and listed for removal. The edit wins because the
// written tag is filtered out of fieldsToRemove before removal happens.
const { image } = await readBack(
await editMetadata(richImage(), {
artist: "WinsOverRemoval",
fieldsToRemove: ["Artist"],
}),
);
expect(image.Artist).toBe("WinsOverRemoval");
});
it("filters unsafe round-trip keys out of fieldsToRemove (no removal happens)", async () => {
// MakerNote is in UNSAFE_ROUND_TRIP_KEYS, so after filtering there is nothing
// to remove and no edit: the keepMetadata() branch runs and EXIF stays whole.
const { hasExif, hasIcc, image } = await readBack(
await editMetadata(richImage(), { fieldsToRemove: ["MakerNote"] }),
);
expect(hasExif).toBe(true);
expect(hasIcc).toBe(true);
expect(image.Artist).toBe("OrigArtist");
expect(image.Software).toBe("OrigSoft");
});
it("treats clearGps:true as a removal trigger, rebuilding EXIF while keeping fields", async () => {
// clearGps flips hasRemovals true even with an empty fieldsToRemove, so the
// function rebuilds EXIF from the source rather than taking keepMetadata().
const { hasExif, image } = await readBack(await editMetadata(richImage(), { clearGps: true }));
expect(hasExif).toBe(true);
// Non-GPS IFD0 fields survive the rebuild verbatim.
expect(image.Artist).toBe("OrigArtist");
expect(image.Copyright).toBe("OrigCopyright");
});
it("does not change image dimensions or format", async () => {
const buf = await (await editMetadata(richImage(), { artist: "DimCheck" })).jpeg().toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(24);
expect(meta.height).toBe(16);
expect(meta.format).toBe("jpeg");
});
});
describe("stripMetadata", () => {
it("sanity check: the rich fixture carries both EXIF and ICC", async () => {
const meta = await sharp(richBuffer).metadata();
expect(meta.exif).toBeTruthy();
expect(meta.icc).toBeTruthy();
});
it("strips both EXIF and ICC when stripAll is true", async () => {
const { hasExif, hasIcc } = await readBack(
await stripMetadata(richImage(), { stripAll: true }),
);
expect(hasExif).toBe(false);
expect(hasIcc).toBe(false);
});
it("strips everything when no options are provided (all undefined)", async () => {
const { hasExif, hasIcc } = await readBack(await stripMetadata(richImage(), {}));
expect(hasExif).toBe(false);
expect(hasIcc).toBe(false);
});
it("strips everything when called with default options", async () => {
const { hasExif, hasIcc } = await readBack(await stripMetadata(richImage()));
expect(hasExif).toBe(false);
expect(hasIcc).toBe(false);
});
it("keeps EXIF and ICC when every strip flag is explicitly false", async () => {
// strippingNothing branch: withMetadata() preserves all categories.
const { hasExif, hasIcc, image } = await readBack(
await stripMetadata(richImage(), {
stripExif: false,
stripGps: false,
stripIcc: false,
stripXmp: false,
}),
);
expect(hasExif).toBe(true);
expect(hasIcc).toBe(true);
expect(image.Artist).toBe("OrigArtist");
});
it("strips EXIF but keeps ICC when only stripExif is true", async () => {
const { hasExif, hasIcc } = await readBack(
await stripMetadata(richImage(), { stripExif: true }),
);
expect(hasExif).toBe(false);
expect(hasIcc).toBe(true);
});
it("strips EXIF but keeps ICC when only stripGps is true", async () => {
// stripGps gates the same keepExif() branch as stripExif: !stripExif is true
// but !stripGps is false, so keepExif() is skipped and EXIF drops.
const { hasExif, hasIcc } = await readBack(
await stripMetadata(richImage(), { stripGps: true }),
);
expect(hasExif).toBe(false);
expect(hasIcc).toBe(true);
});
it("keeps EXIF but strips ICC when only stripIcc is true", async () => {
const { hasExif, hasIcc } = await readBack(
await stripMetadata(richImage(), { stripIcc: true }),
);
expect(hasExif).toBe(true);
expect(hasIcc).toBe(false);
});
it("keeps both EXIF and ICC when only stripXmp is true (selective mode)", async () => {
// XMP has no keepXmp(); it is always stripped in selective mode. EXIF and ICC
// are both preserved because neither of their guards trips.
const { hasExif, hasIcc, image } = await readBack(
await stripMetadata(richImage(), { stripXmp: true }),
);
expect(hasExif).toBe(true);
expect(hasIcc).toBe(true);
expect(image.Artist).toBe("OrigArtist");
});
it("strips both EXIF and ICC when stripExif and stripIcc are both true", async () => {
const { hasExif, hasIcc } = await readBack(
await stripMetadata(richImage(), { stripExif: true, stripIcc: true }),
);
expect(hasExif).toBe(false);
expect(hasIcc).toBe(false);
});
it("keeps EXIF when stripIcc and stripXmp are true but EXIF flags are false", async () => {
// Only ICC + XMP requested for removal, so keepExif() still fires.
const { hasExif, hasIcc } = await readBack(
await stripMetadata(richImage(), { stripIcc: true, stripXmp: true }),
);
expect(hasExif).toBe(true);
expect(hasIcc).toBe(false);
});
it("does not change image dimensions or format", async () => {
const buf = await (await stripMetadata(richImage(), { stripAll: true })).jpeg().toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(24);
expect(meta.height).toBe(16);
expect(meta.format).toBe("jpeg");
});
});
@@ -0,0 +1,479 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { crop } from "../src/operations/crop.js";
import { flip } from "../src/operations/flip.js";
import { resize } from "../src/operations/resize.js";
import { rotate } from "../src/operations/rotate.js";
import type { Sharp } from "../src/types.js";
// Geometry operations have exact integer oracles (output width/height and
// pixel positions), which makes them ideal for mutation-killing assertions.
// Every dimension and sampled pixel below is verified against real Sharp output.
interface Rgb {
r: number;
g: number;
b: number;
}
/** Solid-color image with explicit dimensions, RGB, no alpha. */
function solid(width: number, height: number, color: Rgb): Sharp {
return sharp({
create: { width, height, channels: 3, background: color },
});
}
/**
* 4x4 image with four distinct quadrant colors so left/top offsets are
* observable from a single sampled pixel:
* top-left = red, top-right = green, bottom-left = blue, bottom-right = white.
* Quadrant boundary is at x=2 (left|right) and y=2 (top|bottom).
*/
function quadrant4x4(): Sharp {
const width = 4;
const height = 4;
const channels = 3;
const data = Buffer.alloc(width * height * channels);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * channels;
const left = x < 2;
const top = y < 2;
if (top && left) {
data[i] = 255; // red
} else if (top && !left) {
data[i + 1] = 255; // green
} else if (!top && left) {
data[i + 2] = 255; // blue
} else {
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255; // white
}
}
}
return sharp(data, { raw: { width, height, channels } });
}
/**
* Non-square 6x2 strip: left half (x<3) red, right half green. Both rows
* identical, so a rotation's effect on the horizontal axis is what shows up,
* distinguishing clockwise (90) from counter-clockwise (270).
*/
function strip6x2(): Sharp {
const width = 6;
const height = 2;
const channels = 3;
const data = Buffer.alloc(width * height * channels);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * channels;
const left = x < 3;
data[i] = left ? 255 : 0;
data[i + 1] = left ? 0 : 255;
}
}
return sharp(data, { raw: { width, height, channels } });
}
/** Non-square 6x2 strip, all black except a single white pixel at (0,0). */
function marker6x2(): Sharp {
const width = 6;
const height = 2;
const channels = 3;
const data = Buffer.alloc(width * height * channels);
data[0] = 255;
data[1] = 255;
data[2] = 255;
return sharp(data, { raw: { width, height, channels } });
}
interface Sampled {
rgb: [number, number, number];
width: number;
height: number;
}
/** Rasterize and read a single pixel plus the output dimensions. */
async function sample(image: Sharp, x: number, y: number): Promise<Sampled> {
const { data, info } = await image.raw().toBuffer({ resolveWithObject: true });
const i = (y * info.width + x) * info.channels;
return {
rgb: [data[i], data[i + 1], data[i + 2]],
width: info.width,
height: info.height,
};
}
/** Rasterize and read just the output dimensions. */
async function dims(image: Sharp): Promise<[number, number]> {
const { info } = await image.raw().toBuffer({ resolveWithObject: true });
return [info.width, info.height];
}
describe("resize (mutation-killing)", () => {
// Source is 100x50 (2:1 aspect) so every fit mode yields distinct output.
const RED: Rgb = { r: 255, g: 0, b: 0 };
// resize on a fresh 100x50 red source for the dimension/pixel oracles.
function resizeOn(options: Parameters<typeof resize>[1]): Promise<Sharp> {
return resize(solid(100, 50, RED), options);
}
it("fit=inside computes exact letterbox dimensions", async () => {
// 100x50 into a 40x40 box, preserving aspect, fully contained: 40x20.
const [w, h] = await dims(
await resizeOn({ width: 40, height: 40, fit: "inside", withoutEnlargement: false }),
);
expect(w).toBe(40);
expect(h).toBe(20);
});
it("fit=outside computes exact cover-box dimensions", async () => {
// 100x50 into a 40x40 box, preserving aspect, fully covering: 80x40.
const [w, h] = await dims(
await resizeOn({ width: 40, height: 40, fit: "outside", withoutEnlargement: false }),
);
expect(w).toBe(80);
expect(h).toBe(40);
});
it("fit=cover fills the exact target frame with image content", async () => {
// Cover crops to fill: exact 40x40 and the top edge is image (red), no pad.
const s = await sample(
await resizeOn({ width: 40, height: 40, fit: "cover", withoutEnlargement: false }),
20,
0,
);
expect(s.width).toBe(40);
expect(s.height).toBe(40);
expect(s.rgb).toEqual([255, 0, 0]);
});
it("fit=contain pads to the exact target frame (background at the edge)", async () => {
// Contain letterboxes: exact 40x40, top edge is background (black), center is image.
const edge = await sample(
await resizeOn({ width: 40, height: 40, fit: "contain", withoutEnlargement: false }),
20,
0,
);
expect(edge.width).toBe(40);
expect(edge.height).toBe(40);
expect(edge.rgb).toEqual([0, 0, 0]);
const center = await sample(
await resizeOn({ width: 40, height: 40, fit: "contain", withoutEnlargement: false }),
20,
20,
);
expect(center.rgb).toEqual([255, 0, 0]);
});
it("fit=fill stretches to the exact target with no padding", async () => {
// Fill distorts to exactly 40x40 and fills the frame (edge is image, not pad).
const s = await sample(
await resizeOn({ width: 40, height: 40, fit: "fill", withoutEnlargement: false }),
20,
0,
);
expect(s.width).toBe(40);
expect(s.height).toBe(40);
expect(s.rgb).toEqual([255, 0, 0]);
});
it("defaults to fit=cover when fit is omitted", async () => {
// resize passes `fit ?? "cover"`; default must behave like cover, not contain.
const s = await sample(await resizeOn({ width: 40, height: 40 }), 20, 0);
expect(s.width).toBe(40);
expect(s.height).toBe(40);
expect(s.rgb).toEqual([255, 0, 0]); // red edge = cover, black would mean contain
});
it("width-only preserves aspect and computes the exact height", async () => {
// 100x50 -> width 30 => height round(30 * 50/100) = 15.
const [w, h] = await dims(
await resizeOn({ width: 30, fit: "inside", withoutEnlargement: false }),
);
expect(w).toBe(30);
expect(h).toBe(15);
});
it("height-only preserves aspect and computes the exact width", async () => {
// 100x50 -> height 30 => width round(30 * 100/50) = 60.
const [w, h] = await dims(
await resizeOn({ height: 30, fit: "inside", withoutEnlargement: false }),
);
expect(w).toBe(60);
expect(h).toBe(30);
});
it("withoutEnlargement=true clamps an oversized target to the source size", async () => {
// Target 200x200 exceeds source 100x50; clamps to 100x50.
const [w, h] = await dims(
await resizeOn({ width: 200, height: 200, fit: "inside", withoutEnlargement: true }),
);
expect(w).toBe(100);
expect(h).toBe(50);
});
it("withoutEnlargement=false enlarges to the exact target", async () => {
// Same oversized target, but enlargement allowed: 200x100 (aspect preserved).
const [w, h] = await dims(
await resizeOn({ width: 200, height: 200, fit: "inside", withoutEnlargement: false }),
);
expect(w).toBe(200);
expect(h).toBe(100);
});
it("withoutEnlargement clamps width but not a smaller height independently", async () => {
// Width 200 (> 100) clamps to 100; height 20 (< 50) stays 20. Exercises the
// two independent `> meta.width` / `> meta.height` guards separately.
const [w, h] = await dims(
await resizeOn({ width: 200, height: 20, fit: "fill", withoutEnlargement: true }),
);
expect(w).toBe(100);
expect(h).toBe(20);
});
it("resizes by percentage with exact rounded dimensions", async () => {
// 100x50 at 50% => 50x25.
const [w, h] = await dims(await resizeOn({ percentage: 50 }));
expect(w).toBe(50);
expect(h).toBe(25);
});
it("percentage rounds each dimension and floors at 1px", async () => {
// 100x50 at 1% => round(1) x round(0.5)=1, then Math.max(1, ...) keeps >= 1.
const [w, h] = await dims(await resizeOn({ percentage: 1 }));
expect(w).toBe(1);
expect(h).toBe(1);
});
it("percentage over 100 enlarges by the exact factor", async () => {
// 100x50 at 250% => 250x125.
const [w, h] = await dims(await resizeOn({ percentage: 250 }));
expect(w).toBe(250);
expect(h).toBe(125);
});
it("rejects a zero or negative percentage", async () => {
await expect(resizeOn({ percentage: 0 })).rejects.toThrow(/percentage must be greater than 0/);
await expect(resizeOn({ percentage: -10 })).rejects.toThrow(
/percentage must be greater than 0/,
);
});
it("rejects zero and negative width or height", async () => {
await expect(resizeOn({ width: 0 })).rejects.toThrow(/width must be greater than 0/);
await expect(resizeOn({ height: -5 })).rejects.toThrow(/height must be greater than 0/);
});
it("rejects when neither width, height, nor percentage is given", async () => {
await expect(resizeOn({})).rejects.toThrow(/requires width, height, or percentage/);
});
});
describe("crop (mutation-killing)", () => {
it("extracts the exact requested region size", async () => {
const [w, h] = await dims(await crop(quadrant4x4(), { left: 1, top: 1, width: 2, height: 3 }));
expect(w).toBe(2);
expect(h).toBe(3);
});
it("honors left/top offsets (top-right quadrant is green, not swapped)", async () => {
// Extract the top-right quadrant: left=2, top=0. A swapped left/top would
// pull the bottom-left (blue) instead, so the color pins the offsets.
const s = await sample(
await crop(quadrant4x4(), { left: 2, top: 0, width: 2, height: 2 }),
0,
0,
);
expect(s.width).toBe(2);
expect(s.height).toBe(2);
expect(s.rgb).toEqual([0, 255, 0]);
});
it("honors left/top offsets in the other axis (bottom-left is blue)", async () => {
const s = await sample(
await crop(quadrant4x4(), { left: 0, top: 2, width: 2, height: 2 }),
0,
0,
);
expect(s.rgb).toEqual([0, 0, 255]);
});
it("does not swap width and height", async () => {
// Wide-but-short region from the top edge: exact 4x1, spanning both top
// quadrants. A w/h swap (1x4) would change the dimensions.
const [w, h] = await dims(await crop(quadrant4x4(), { left: 0, top: 0, width: 4, height: 1 }));
expect(w).toBe(4);
expect(h).toBe(1);
});
it("crops by percent with exact rounded region and correct offset", async () => {
// On a 4x4: left=50% -> round(2), top=0, w=50% -> 2, h=50% -> 2 == top-right (green).
const s = await sample(
await crop(quadrant4x4(), { left: 50, top: 0, width: 50, height: 50, unit: "percent" }),
0,
0,
);
expect(s.width).toBe(2);
expect(s.height).toBe(2);
expect(s.rgb).toEqual([0, 255, 0]);
});
it("percent rounding pins the arithmetic (25% of 10px rounds to 3)", async () => {
// 10x10: left=25% -> round(2.5)=3, w=50% -> 5 => 3+5=8 <= 10 (valid), size 5x5.
const [w, h] = await dims(
await crop(solid(10, 10, { r: 10, g: 20, b: 30 }), {
left: 25,
top: 25,
width: 50,
height: 50,
unit: "percent",
}),
);
expect(w).toBe(5);
expect(h).toBe(5);
});
it("rejects a crop whose width exceeds the image (right edge boundary)", async () => {
// left(2) + width(3) = 5 > 4.
await expect(crop(quadrant4x4(), { left: 2, top: 0, width: 3, height: 2 })).rejects.toThrow(
/exceeds image width/,
);
});
it("rejects a crop whose height exceeds the image (bottom edge boundary)", async () => {
// top(2) + height(3) = 5 > 4.
await expect(crop(quadrant4x4(), { left: 0, top: 2, width: 2, height: 3 })).rejects.toThrow(
/exceeds image height/,
);
});
it("accepts a crop that exactly fills to the far edge (boundary is inclusive)", async () => {
// left(2) + width(2) = 4 == 4 must NOT throw (guards use >, not >=).
const [w, h] = await dims(await crop(quadrant4x4(), { left: 2, top: 2, width: 2, height: 2 }));
expect(w).toBe(2);
expect(h).toBe(2);
});
it("rejects zero width or zero height", async () => {
await expect(crop(quadrant4x4(), { left: 0, top: 0, width: 0, height: 2 })).rejects.toThrow(
/width and height must be greater than 0/,
);
await expect(crop(quadrant4x4(), { left: 0, top: 0, width: 2, height: 0 })).rejects.toThrow(
/width and height must be greater than 0/,
);
});
it("rejects negative left or top", async () => {
await expect(crop(quadrant4x4(), { left: -1, top: 0, width: 2, height: 2 })).rejects.toThrow(
/left and top must be non-negative/,
);
await expect(crop(quadrant4x4(), { left: 0, top: -1, width: 2, height: 2 })).rejects.toThrow(
/left and top must be non-negative/,
);
});
});
describe("rotate (mutation-killing)", () => {
it("rotates 90 and swaps dimensions (2:1 -> 1:2)", async () => {
const [w, h] = await dims(await rotate(strip6x2(), { angle: 90 }));
expect(w).toBe(2);
expect(h).toBe(6);
});
it("rotates 90 clockwise: the top-left marker lands at the top-right corner", async () => {
// 6x2 -> 2x6. Clockwise sends original (0,0) to (width-1, 0) = (1, 0).
const s = await sample(await rotate(marker6x2(), { angle: 90 }), 1, 0);
expect(s.width).toBe(2);
expect(s.height).toBe(6);
expect(s.rgb).toEqual([255, 255, 255]);
});
it("rotates 270 the other way: the top-left marker lands at the bottom-left corner", async () => {
// 6x2 -> 2x6. Counter-clockwise sends original (0,0) to (0, height-1) = (0, 5).
const s = await sample(await rotate(marker6x2(), { angle: 270 }), 0, 5);
expect(s.width).toBe(2);
expect(s.height).toBe(6);
expect(s.rgb).toEqual([255, 255, 255]);
});
it("rotates 180 keeping dimensions and mirroring both axes", async () => {
// Square marker so we can check the corner move without a dim swap.
const [w, h] = await dims(await rotate(quadrant4x4(), { angle: 180 }));
expect(w).toBe(4);
expect(h).toBe(4);
// Original top-left (red) ends at bottom-right (3,3).
const s = await sample(await rotate(quadrant4x4(), { angle: 180 }), 3, 3);
expect(s.rgb).toEqual([255, 0, 0]);
});
it("applies the exact background color on an arbitrary (non-90) angle", async () => {
// 45deg on a 4x4 grows the canvas to 6x6; the (0,0) corner is background.
const s = await sample(
await rotate(solid(4, 4, { r: 255, g: 0, b: 0 }), { angle: 45, background: "#0000FF" }),
0,
0,
);
expect(s.width).toBe(6);
expect(s.height).toBe(6);
expect(s.rgb).toEqual([0, 0, 255]);
});
it("defaults the arbitrary-angle background to black when none is given", async () => {
const s = await sample(await rotate(solid(4, 4, { r: 255, g: 0, b: 0 }), { angle: 45 }), 0, 0);
expect(s.rgb).toEqual([0, 0, 0]);
});
it("treats a 0-degree rotation as a multiple of 90 (dimensions unchanged)", async () => {
// angle % 90 === 0 path. 6x2 stays 6x2.
const [w, h] = await dims(await rotate(strip6x2(), { angle: 0 }));
expect(w).toBe(6);
expect(h).toBe(2);
});
});
describe("flip (mutation-killing)", () => {
it("flips horizontally: content mirrors across X, Y unchanged", async () => {
// flop(): top-left red moves to the top-right; top-right green moves to top-left.
const movedRed = await sample(await flip(quadrant4x4(), { horizontal: true }), 3, 0);
expect(movedRed.rgb).toEqual([255, 0, 0]);
const movedGreen = await sample(await flip(quadrant4x4(), { horizontal: true }), 0, 0);
expect(movedGreen.rgb).toEqual([0, 255, 0]);
// Bottom-left blue stays on the bottom row (Y unchanged), moves to bottom-right.
const blue = await sample(await flip(quadrant4x4(), { horizontal: true }), 3, 3);
expect(blue.rgb).toEqual([0, 0, 255]);
});
it("flips vertically: content mirrors across Y, X unchanged", async () => {
// flip(): top-left red moves to the bottom-left; bottom-left blue moves to top-left.
const movedRed = await sample(await flip(quadrant4x4(), { vertical: true }), 0, 3);
expect(movedRed.rgb).toEqual([255, 0, 0]);
const movedBlue = await sample(await flip(quadrant4x4(), { vertical: true }), 0, 0);
expect(movedBlue.rgb).toEqual([0, 0, 255]);
// Top-right green stays on the right column (X unchanged), moves to bottom-right.
const green = await sample(await flip(quadrant4x4(), { vertical: true }), 3, 3);
expect(green.rgb).toEqual([0, 255, 0]);
});
it("flips both axes: top-left maps to the opposite (bottom-right) corner", async () => {
const s = await sample(await flip(quadrant4x4(), { horizontal: true, vertical: true }), 3, 3);
expect(s.rgb).toEqual([255, 0, 0]);
});
it("preserves dimensions on any flip", async () => {
const [w, h] = await dims(
await flip(solid(6, 2, { r: 255, g: 0, b: 0 }), { horizontal: true }),
);
expect(w).toBe(6);
expect(h).toBe(2);
});
it("rejects when neither horizontal nor vertical is requested", async () => {
await expect(flip(quadrant4x4(), {})).rejects.toThrow(/at least one of horizontal or vertical/);
await expect(flip(quadrant4x4(), { horizontal: false, vertical: false })).rejects.toThrow(
/at least one of horizontal or vertical/,
);
});
});
@@ -0,0 +1,546 @@
import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
import {
getImageInfo,
parseExif,
parseGps,
parseXmp,
sanitizeValue,
} from "../src/utils/metadata.js";
import { extToMime, formatToExt, formatToMime, mimeToExt } from "../src/utils/mime.js";
// ---------------------------------------------------------------------------
// Test image fixtures with KNOWN dimensions / channels / format / space so
// getImageInfo's exact numeric and string outputs can be asserted. Off-by-one
// and field-swap mutants die against these precise expectations.
// ---------------------------------------------------------------------------
let rgbPng: Buffer; // 20x10, 3 channels, no alpha, sRGB, no exif/icc/xmp
let rgbaPng: Buffer; // 5x7, 4 channels, alpha
let bwPng: Buffer; // 6x4, 1 channel, b-w colourspace
let webpBuf: Buffer; // 3x9 webp
let jpegDensity: Buffer; // 8x12 jpeg carrying density 300
let exifJpeg: Buffer; // jpeg carrying a real EXIF blob (Image + Photo sections)
beforeAll(async () => {
rgbPng = await sharp({
create: { width: 20, height: 10, channels: 3, background: { r: 1, g: 2, b: 3 } },
})
.png()
.toBuffer();
rgbaPng = await sharp({
create: { width: 5, height: 7, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 0.5 } },
})
.png()
.toBuffer();
bwPng = await sharp({
create: { width: 6, height: 4, channels: 3, background: { r: 100, g: 100, b: 100 } },
})
.toColourspace("b-w")
.png()
.toBuffer();
webpBuf = await sharp({
create: { width: 3, height: 9, channels: 3, background: { r: 1, g: 2, b: 3 } },
})
.webp()
.toBuffer();
jpegDensity = await sharp({
create: { width: 8, height: 12, channels: 3, background: { r: 1, g: 2, b: 3 } },
})
.withMetadata({ density: 300 })
.jpeg()
.toBuffer();
exifJpeg = await sharp({
create: { width: 8, height: 8, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.withExif({
IFD0: { Make: "AcmeCam", Model: "X100", Software: "SnapOtterTest" },
IFD2: { ExposureTime: "1/200" },
})
.jpeg()
.toBuffer();
});
// ===========================================================================
// getImageInfo
// ===========================================================================
describe("getImageInfo", () => {
it("returns exact width, height, format, channels, size for an RGB PNG", async () => {
const info = await getImageInfo(rgbPng);
expect(info.width).toBe(20);
expect(info.height).toBe(10);
expect(info.format).toBe("png");
expect(info.channels).toBe(3);
expect(info.hasAlpha).toBe(false);
// size must equal the exact buffer byte length (not width/height/etc.)
expect(info.size).toBe(rgbPng.length);
});
it("does not swap width and height (non-square image)", async () => {
const info = await getImageInfo(rgbPng);
// 20x10: if width/height were swapped this would read 10x20.
expect(info.width).toBe(20);
expect(info.height).toBe(10);
expect(info.width).not.toBe(info.height);
});
it("reports hasAlpha true and 4 channels for an RGBA PNG", async () => {
const info = await getImageInfo(rgbaPng);
expect(info.width).toBe(5);
expect(info.height).toBe(7);
expect(info.channels).toBe(4);
expect(info.hasAlpha).toBe(true);
});
it("reports 1 channel and b-w space for a grayscale PNG", async () => {
const info = await getImageInfo(bwPng);
expect(info.channels).toBe(1);
expect(info.hasAlpha).toBe(false);
expect(info.metadata.space).toBe("b-w");
});
it("reports sRGB space for a colour PNG", async () => {
const info = await getImageInfo(rgbPng);
expect(info.metadata.space).toBe("srgb");
});
it("returns the exact 'webp' format string", async () => {
const info = await getImageInfo(webpBuf);
expect(info.format).toBe("webp");
expect(info.width).toBe(3);
expect(info.height).toBe(9);
});
it("exposes the full metadata sub-object with correct falsy defaults for a plain PNG", async () => {
const info = await getImageInfo(rgbPng);
// No EXIF / ICC / XMP present -> the !! coercions must yield false.
expect(info.metadata.exif).toBe(false);
expect(info.metadata.icc).toBe(false);
expect(info.metadata.xmp).toBe(false);
expect(info.metadata.hasProfile).toBe(false);
expect(info.metadata.isProgressive).toBe(false);
// Absent optional fields pass through as undefined (not coerced).
expect(info.metadata.orientation).toBeUndefined();
expect(info.metadata.density).toBeUndefined();
});
it("passes through density when present", async () => {
const info = await getImageInfo(jpegDensity);
expect(info.metadata.density).toBe(300);
});
it("sets metadata.exif true when the image carries an EXIF blob", async () => {
const info = await getImageInfo(exifJpeg);
expect(info.metadata.exif).toBe(true);
expect(info.format).toBe("jpeg");
});
it("rejects on an undecodable buffer", async () => {
await expect(getImageInfo(Buffer.from("this is definitely not an image"))).rejects.toThrow();
});
});
// ===========================================================================
// sanitizeValue
// ===========================================================================
describe("sanitizeValue", () => {
it("converts a Date to an ISO string", () => {
const d = new Date("2020-01-02T03:04:05.000Z");
expect(sanitizeValue(d)).toBe("2020-01-02T03:04:05.000Z");
});
it("returns a small buffer as an array of byte values", () => {
const buf = Buffer.from([1, 2, 3, 255]);
expect(sanitizeValue(buf)).toEqual([1, 2, 3, 255]);
});
it("keeps a buffer of exactly 256 bytes as an array (boundary, not > 256)", () => {
const buf = Buffer.alloc(256, 7);
const out = sanitizeValue(buf);
expect(Array.isArray(out)).toBe(true);
expect((out as number[]).length).toBe(256);
expect((out as number[])[0]).toBe(7);
});
it("replaces a buffer larger than 256 bytes with a placeholder string", () => {
const buf = Buffer.alloc(257, 9);
expect(sanitizeValue(buf)).toBe("<binary 257 bytes>");
});
it("recursively sanitizes arrays", () => {
const d = new Date("2021-06-07T08:09:10.000Z");
expect(sanitizeValue([d, 42, Buffer.from([0, 1])])).toEqual([
"2021-06-07T08:09:10.000Z",
42,
[0, 1],
]);
});
it("recursively sanitizes nested object values", () => {
const input = {
when: new Date("2022-02-02T02:02:02.000Z"),
raw: Buffer.from([5, 6]),
count: 3,
};
expect(sanitizeValue(input)).toEqual({
when: "2022-02-02T02:02:02.000Z",
raw: [5, 6],
count: 3,
});
});
it("returns primitives unchanged", () => {
expect(sanitizeValue("hello")).toBe("hello");
expect(sanitizeValue(123)).toBe(123);
expect(sanitizeValue(true)).toBe(true);
expect(sanitizeValue(null)).toBe(null);
});
});
// ===========================================================================
// parseExif
// ===========================================================================
describe("parseExif", () => {
it("returns four empty sections for an empty buffer", () => {
expect(parseExif(Buffer.alloc(0))).toEqual({ image: {}, photo: {}, iop: {}, gps: {} });
});
it("returns empty sections (does not throw) on a malformed buffer", () => {
const result = parseExif(Buffer.from("garbage exif bytes that will not parse"));
expect(result).toEqual({ image: {}, photo: {}, iop: {}, gps: {} });
});
it("populates the image section from a real EXIF blob", async () => {
const meta = await sharp(exifJpeg).metadata();
expect(meta.exif).toBeDefined();
const result = parseExif(meta.exif as Buffer);
expect(result.image.Make).toBe("AcmeCam");
expect(result.image.Model).toBe("X100");
expect(result.image.Software).toBe("SnapOtterTest");
});
it("populates the photo section from a real EXIF blob", async () => {
const meta = await sharp(exifJpeg).metadata();
const result = parseExif(meta.exif as Buffer);
// The Photo IFD exists and is non-empty.
expect(Object.keys(result.photo).length).toBeGreaterThan(0);
expect(result.photo).toHaveProperty("ExifVersion");
});
});
// ===========================================================================
// parseGps
// ===========================================================================
describe("parseGps", () => {
it("converts DMS to decimal degrees for a northern latitude", () => {
const { latitude } = parseGps({ GPSLatitude: [10, 30, 0], GPSLatitudeRef: "N" });
expect(latitude).toBeCloseTo(10.5, 10);
});
it("negates latitude for a southern reference", () => {
const { latitude } = parseGps({ GPSLatitude: [10, 30, 0], GPSLatitudeRef: "S" });
expect(latitude).toBeCloseTo(-10.5, 10);
});
it("does not negate latitude for a non-S reference", () => {
const { latitude } = parseGps({ GPSLatitude: [10, 30, 0], GPSLatitudeRef: "N" });
expect(latitude).toBeGreaterThan(0);
});
it("converts DMS to decimal degrees for an eastern longitude", () => {
const { longitude } = parseGps({ GPSLongitude: [122, 15, 30], GPSLongitudeRef: "E" });
expect(longitude).toBeCloseTo(122.25833333333334, 10);
});
it("negates longitude for a western reference", () => {
const { longitude } = parseGps({ GPSLongitude: [122, 15, 30], GPSLongitudeRef: "W" });
expect(longitude).toBeCloseTo(-122.25833333333334, 10);
});
it("uses the minutes/60 and seconds/3600 divisors exactly", () => {
// 0 deg 6 min 36 sec = 6/60 + 36/3600 = 0.1 + 0.01 = 0.11 exactly.
const { latitude } = parseGps({ GPSLatitude: [0, 6, 36], GPSLatitudeRef: "N" });
expect(latitude).toBeCloseTo(0.11, 10);
});
it("returns null latitude when the DMS array is not length 3", () => {
expect(parseGps({ GPSLatitude: [10, 30], GPSLatitudeRef: "N" }).latitude).toBeNull();
expect(parseGps({ GPSLatitude: [10, 30, 0, 5], GPSLatitudeRef: "N" }).latitude).toBeNull();
});
it("returns null latitude when a DMS component is NaN", () => {
expect(parseGps({ GPSLatitude: [10, Number.NaN, 0], GPSLatitudeRef: "N" }).latitude).toBeNull();
});
it("returns null coordinates when GPS fields are absent", () => {
expect(parseGps({})).toEqual({ latitude: null, longitude: null, altitude: null });
});
it("reads a positive altitude with no reference", () => {
expect(parseGps({ GPSAltitude: 120.5 }).altitude).toBe(120.5);
});
it("negates altitude when GPSAltitudeRef is 1 (below sea level)", () => {
expect(parseGps({ GPSAltitude: 120.5, GPSAltitudeRef: 1 }).altitude).toBe(-120.5);
});
it("does not negate altitude when GPSAltitudeRef is 0", () => {
expect(parseGps({ GPSAltitude: 120.5, GPSAltitudeRef: 0 }).altitude).toBe(120.5);
});
it("returns null altitude when GPSAltitude is not a number", () => {
expect(parseGps({ GPSAltitude: "120" }).altitude).toBeNull();
expect(parseGps({ GPSAltitude: Number.NaN }).altitude).toBeNull();
});
it("parses latitude, longitude and altitude together", () => {
const out = parseGps({
GPSLatitude: [40, 30, 0],
GPSLatitudeRef: "N",
GPSLongitude: [74, 0, 0],
GPSLongitudeRef: "W",
GPSAltitude: 10,
GPSAltitudeRef: 0,
});
expect(out.latitude).toBeCloseTo(40.5, 10);
expect(out.longitude).toBeCloseTo(-74, 10);
expect(out.altitude).toBe(10);
});
});
// ===========================================================================
// parseXmp
// ===========================================================================
describe("parseXmp", () => {
it("extracts namespaced key/value attribute pairs", () => {
const xml = '<x:xmpmeta><rdf:Description dc:creator="Jane" tiff:Make="Nikon"/></x:xmpmeta>';
const result = parseXmp(Buffer.from(xml, "utf-8"));
expect(result["dc:creator"]).toBe("Jane");
expect(result["tiff:Make"]).toBe("Nikon");
});
it("skips xmlns: declarations", () => {
const xml = '<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Sunset"/>';
const result = parseXmp(Buffer.from(xml, "utf-8"));
expect(result).not.toHaveProperty("xmlns:dc");
expect(result["dc:title"]).toBe("Sunset");
});
it("skips rdf: attributes", () => {
const xml = '<rdf:Description rdf:about="" dc:subject="Beach"/>';
const result = parseXmp(Buffer.from(xml, "utf-8"));
expect(result).not.toHaveProperty("rdf:about");
expect(result["dc:subject"]).toBe("Beach");
});
it("returns an empty object when there are no namespaced attributes", () => {
expect(parseXmp(Buffer.from("<plain>no attributes here</plain>", "utf-8"))).toEqual({});
});
});
// ===========================================================================
// mime utilities - enumerate every mapping in both directions plus fallbacks.
// ===========================================================================
const EXT_MIME_PAIRS: ReadonlyArray<[string, string]> = [
["jpg", "image/jpeg"],
["jpeg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
["avif", "image/avif"],
["tiff", "image/tiff"],
["tif", "image/tiff"],
["gif", "image/gif"],
["bmp", "image/bmp"],
["svg", "image/svg+xml"],
["ico", "image/x-icon"],
["heif", "image/heif"],
["heic", "image/heic"],
["jxl", "image/jxl"],
["dng", "image/x-adobe-dng"],
["cr2", "image/x-canon-cr2"],
["nef", "image/x-nikon-nef"],
["arw", "image/x-sony-arw"],
["orf", "image/x-olympus-orf"],
["rw2", "image/x-panasonic-rw2"],
["cr3", "image/x-canon-cr3"],
["raf", "image/x-fuji-raf"],
["pef", "image/x-pentax-pef"],
["3fr", "image/x-hasselblad-3fr"],
["iiq", "image/x-phaseone-iiq"],
["srw", "image/x-samsung-srw"],
["x3f", "image/x-sigma-x3f"],
["rwl", "image/x-leica-rwl"],
["nrw", "image/x-nikon-nrw"],
["gpr", "image/x-gopro-gpr"],
["fff", "image/x-hasselblad-fff"],
["mrw", "image/x-minolta-mrw"],
["mef", "image/x-mamiya-mef"],
["kdc", "image/x-kodak-kdc"],
["dcr", "image/x-kodak-dcr"],
["erf", "image/x-epson-erf"],
["ptx", "image/x-pentax-ptx"],
["tga", "image/x-tga"],
["psd", "image/vnd.adobe.photoshop"],
["exr", "image/x-exr"],
["hdr", "image/vnd.radiance"],
["jp2", "image/jp2"],
["j2k", "image/jp2"],
["j2c", "image/jp2"],
["jpc", "image/jp2"],
["jpf", "image/jp2"],
["jpx", "image/jpx"],
["qoi", "image/qoi"],
["eps", "application/postscript"],
["epsf", "application/postscript"],
["dds", "image/vnd.ms-dds"],
["cur", "image/x-icon"],
["apng", "image/apng"],
["dpx", "image/x-dpx"],
["cin", "image/x-cineon"],
["fits", "image/fits"],
["fit", "image/fits"],
["fts", "image/fits"],
["ppm", "image/x-portable-pixmap"],
["pgm", "image/x-portable-graymap"],
["pbm", "image/x-portable-bitmap"],
["pnm", "image/x-portable-anymap"],
["pam", "image/x-portable-anymap"],
["pfm", "image/x-portable-floatmap"],
["svgz", "image/svg+xml"],
];
const MIME_EXT_PAIRS: ReadonlyArray<[string, string]> = [
["image/jpeg", "jpg"],
["image/png", "png"],
["image/webp", "webp"],
["image/avif", "avif"],
["image/tiff", "tiff"],
["image/gif", "gif"],
["image/bmp", "bmp"],
["image/svg+xml", "svg"],
["image/x-icon", "ico"],
["image/heif", "heif"],
["image/heic", "heic"],
["image/jxl", "jxl"],
["image/x-adobe-dng", "dng"],
["image/x-canon-cr2", "cr2"],
["image/x-nikon-nef", "nef"],
["image/x-sony-arw", "arw"],
["image/x-olympus-orf", "orf"],
["image/x-panasonic-rw2", "rw2"],
["image/x-canon-cr3", "cr3"],
["image/x-fuji-raf", "raf"],
["image/x-pentax-pef", "pef"],
["image/x-hasselblad-3fr", "3fr"],
["image/x-phaseone-iiq", "iiq"],
["image/x-samsung-srw", "srw"],
["image/x-sigma-x3f", "x3f"],
["image/x-leica-rwl", "rwl"],
["image/x-nikon-nrw", "nrw"],
["image/x-gopro-gpr", "gpr"],
["image/x-hasselblad-fff", "fff"],
["image/x-minolta-mrw", "mrw"],
["image/x-mamiya-mef", "mef"],
["image/x-kodak-kdc", "kdc"],
["image/x-kodak-dcr", "dcr"],
["image/x-epson-erf", "erf"],
["image/x-pentax-ptx", "ptx"],
["image/x-tga", "tga"],
["image/vnd.adobe.photoshop", "psd"],
["image/x-exr", "exr"],
["image/vnd.radiance", "hdr"],
["image/jp2", "jp2"],
["image/jpx", "jpx"],
["image/qoi", "qoi"],
["application/postscript", "eps"],
["image/vnd.ms-dds", "dds"],
["image/apng", "apng"],
["image/x-dpx", "dpx"],
["image/x-cineon", "cin"],
["image/fits", "fits"],
["image/x-portable-pixmap", "ppm"],
["image/x-portable-graymap", "pgm"],
["image/x-portable-bitmap", "pbm"],
["image/x-portable-anymap", "pnm"],
["image/x-portable-floatmap", "pfm"],
];
describe("extToMime", () => {
it.each(EXT_MIME_PAIRS)("maps extension %s to %s", (ext, mime) => {
expect(extToMime(ext)).toBe(mime);
});
it("strips a leading dot before lookup", () => {
expect(extToMime(".png")).toBe("image/png");
expect(extToMime(".jpg")).toBe("image/jpeg");
});
it("lowercases the extension before lookup", () => {
expect(extToMime("PNG")).toBe("image/png");
expect(extToMime("JpEg")).toBe("image/jpeg");
});
it("returns application/octet-stream for an unknown extension", () => {
expect(extToMime("zzz")).toBe("application/octet-stream");
expect(extToMime("")).toBe("application/octet-stream");
});
});
describe("mimeToExt", () => {
it.each(MIME_EXT_PAIRS)("maps MIME %s to extension %s", (mime, ext) => {
expect(mimeToExt(mime)).toBe(ext);
});
it("lowercases the MIME type before lookup", () => {
expect(mimeToExt("IMAGE/PNG")).toBe("png");
expect(mimeToExt("Image/Jpeg")).toBe("jpg");
});
it("returns bin for an unknown MIME type", () => {
expect(mimeToExt("application/x-nope")).toBe("bin");
expect(mimeToExt("")).toBe("bin");
});
});
describe("formatToMime", () => {
it("special-cases jpeg to image/jpeg", () => {
expect(formatToMime("jpeg")).toBe("image/jpeg");
});
it("lowercases the format before the jpeg special-case", () => {
expect(formatToMime("JPEG")).toBe("image/jpeg");
});
it("falls through to the ext map for non-jpeg formats", () => {
expect(formatToMime("png")).toBe("image/png");
expect(formatToMime("webp")).toBe("image/webp");
expect(formatToMime("avif")).toBe("image/avif");
expect(formatToMime("gif")).toBe("image/gif");
});
it("returns application/octet-stream for an unknown format", () => {
expect(formatToMime("madeup")).toBe("application/octet-stream");
});
});
describe("formatToExt", () => {
it("special-cases jpeg to jpg", () => {
expect(formatToExt("jpeg")).toBe("jpg");
});
it("lowercases the format before the jpeg special-case", () => {
expect(formatToExt("JPEG")).toBe("jpg");
});
it("returns the lowercased format unchanged for non-jpeg formats", () => {
expect(formatToExt("png")).toBe("png");
expect(formatToExt("WEBP")).toBe("webp");
expect(formatToExt("AVIF")).toBe("avif");
// Unknown formats are passed through lowercased (no fallback here).
expect(formatToExt("Madeup")).toBe("madeup");
});
});
+300
View File
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import { qoiDecode, qoiEncode } from "../src/formats/qoi.js";
// QOI chunk tags (top 2 bits for the range ops, full byte for RGB/RGBA).
const QOI_OP_INDEX = 0x00;
const QOI_OP_DIFF = 0x40;
const QOI_OP_LUMA = 0x80;
const QOI_OP_RUN = 0xc0;
const QOI_OP_RGB = 0xfe;
const QOI_OP_RGBA = 0xff;
const HEADER_SIZE = 14;
const END_MARKER = [0, 0, 0, 0, 0, 0, 0, 1];
// The reference index hash from the spec, replicated here so assertions pin the
// exact slot independently of the module (round-trip alone can't catch a
// symmetric mutation in a hash shared by encode+decode).
function refHash(r: number, g: number, b: number, a: number): number {
return (r * 3 + g * 5 + b * 7 + a * 11) % 64;
}
// Build a packed RGBA buffer from [r,g,b,a] tuples.
function rgba(...pixels: Array<[number, number, number, number]>): Uint8Array {
const out = new Uint8Array(pixels.length * 4);
pixels.forEach((p, i) => {
out.set(p, i * 4);
});
return out;
}
// The data section is everything between the 14-byte header and the 8-byte end marker.
function dataBytes(encoded: Uint8Array): number[] {
return Array.from(encoded.slice(HEADER_SIZE, encoded.length - END_MARKER.length));
}
function tailMarker(encoded: Uint8Array): number[] {
return Array.from(encoded.slice(encoded.length - END_MARKER.length));
}
describe("qoiEncode header", () => {
it("writes the qoif magic as the first four bytes", () => {
const out = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
// "qoif" == 0x71 0x6f 0x69 0x66
expect(Array.from(out.slice(0, 4))).toEqual([0x71, 0x6f, 0x69, 0x66]);
});
it("writes width and height as big-endian uint32", () => {
// 258 == 0x00000102, 513 == 0x00000201: catches byte-order and offset mutants.
const w = 258;
const h = 513;
const out = qoiEncode(new Uint8Array(w * h * 4), w, h, 4);
expect(Array.from(out.slice(4, 8))).toEqual([0x00, 0x00, 0x01, 0x02]);
expect(Array.from(out.slice(8, 12))).toEqual([0x00, 0x00, 0x02, 0x01]);
});
it("writes the channels byte at offset 12 and colorspace 0 at offset 13", () => {
const rgbaOut = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
expect(rgbaOut[12]).toBe(4);
expect(rgbaOut[13]).toBe(0);
const rgbOut = qoiEncode(new Uint8Array([1, 2, 3]), 1, 1, 3);
expect(rgbOut[12]).toBe(3);
expect(rgbOut[13]).toBe(0);
});
});
describe("qoiEncode end marker", () => {
it("ends with seven 0x00 bytes then a single 0x01", () => {
const out = qoiEncode(rgba([9, 8, 7, 255]), 1, 1, 4);
expect(tailMarker(out)).toEqual([0, 0, 0, 0, 0, 0, 0, 1]);
});
});
describe("qoiEncode chunk selection (tag bits of the first data byte)", () => {
// The encoder starts from prev = (0,0,0,255) and an all-zero index, so the
// first pixel's delta from black-opaque decides which chunk is emitted.
it("emits QOI_OP_DIFF for a small delta from the initial pixel", () => {
// (1,1,1): dr=dg=db=1, all within DIFF range (-2..1).
// byte = 0x40 | ((1+2)<<4) | ((1+2)<<2) | (1+2) = 0x7f.
const out = qoiEncode(rgba([1, 1, 1, 255]), 1, 1, 4);
const first = out[HEADER_SIZE];
expect(first & 0xc0).toBe(QOI_OP_DIFF);
expect(first).toBe(0x7f);
});
it("emits QOI_OP_LUMA for a delta outside DIFF but inside LUMA range", () => {
// (16,20,24): dg=20, drDg=-4, dbDg=4 -> LUMA. byte1=0x80|(20+32)=0xb4,
// byte2=((-4+8)<<4)|(4+8)=0x4c.
const out = qoiEncode(rgba([16, 20, 24, 255]), 1, 1, 4);
expect(out[HEADER_SIZE] & 0xc0).toBe(QOI_OP_LUMA);
expect(out[HEADER_SIZE]).toBe(0xb4);
expect(out[HEADER_SIZE + 1]).toBe(0x4c);
});
it("emits QOI_OP_RGB for a delta outside LUMA range with unchanged alpha", () => {
// (200,100,50): dg=100 is outside LUMA (dg<32 fails). alpha stays 255 -> RGB.
const out = qoiEncode(rgba([200, 100, 50, 255]), 1, 1, 4);
expect(out[HEADER_SIZE]).toBe(QOI_OP_RGB);
expect(Array.from(out.slice(HEADER_SIZE + 1, HEADER_SIZE + 4))).toEqual([200, 100, 50]);
});
it("emits QOI_OP_RGBA when alpha differs from the previous pixel", () => {
// alpha 128 != prevA 255 -> RGBA, regardless of how small the color delta is.
const out = qoiEncode(rgba([60, 70, 80, 128]), 1, 1, 4);
expect(out[HEADER_SIZE]).toBe(QOI_OP_RGBA);
expect(Array.from(out.slice(HEADER_SIZE + 1, HEADER_SIZE + 5))).toEqual([60, 70, 80, 128]);
});
it("emits QOI_OP_INDEX with the exact hashed slot when a color repeats", () => {
// A=(10,20,30,255), B=(11,20,30,255), then A again.
// pixel0 A -> RGB; pixel1 B -> DIFF (dr=1); pixel2 A hits the index at slot 9.
const a: [number, number, number, number] = [10, 20, 30, 255];
const b: [number, number, number, number] = [11, 20, 30, 255];
const slot = refHash(...a);
expect(slot).toBe(9);
const out = qoiEncode(rgba(a, b, a), 3, 1, 4);
const data = dataBytes(out);
// Layout: [RGB 0xfe,10,20,30] [DIFF 0x7a] [INDEX 0x09].
expect(data).toEqual([QOI_OP_RGB, 10, 20, 30, 0x7a, QOI_OP_INDEX | slot]);
// The INDEX byte's tag is 0x00 and its low 6 bits are exactly the hash slot.
const indexByte = data[data.length - 1];
expect(indexByte & 0xc0).toBe(QOI_OP_INDEX);
expect(indexByte & 0x3f).toBe(slot);
});
});
describe("qoiEncode run-length encoding", () => {
// Solid red RGBA: pixel0 differs from the initial black-opaque pixel (one RGB
// chunk), then every following pixel repeats it as runs. Runs flush at length
// 62 or at the final pixel. Asserting the exact total length pins the run
// increment and the 62 cap, which round-trip decoding would not notice.
function solidRed(count: number): Uint8Array {
const buf = new Uint8Array(count * 4);
for (let i = 0; i < count; i++) {
buf[i * 4] = 255;
buf[i * 4 + 3] = 255;
}
return buf;
}
it("encodes a single run for a small solid block", () => {
// 10 px: RGB pixel0 (4 bytes) + one RUN chunk for the other 9 px (1 byte).
const out = qoiEncode(solidRed(10), 10, 1, 4);
expect(out.length).toBe(HEADER_SIZE + 4 + 1 + END_MARKER.length);
// RUN chunk encodes run-1 = 8 in the low 6 bits.
expect(dataBytes(out)).toEqual([QOI_OP_RGB, 255, 0, 0, QOI_OP_RUN | 8]);
});
it("splits into two run chunks when the run exceeds the 62 cap", () => {
// 100 px: RGB pixel0 + RUN(62 px, run-1=61) + RUN(37 px, run-1=36).
const out = qoiEncode(solidRed(100), 100, 1, 4);
expect(out.length).toBe(HEADER_SIZE + 4 + 2 + END_MARKER.length);
expect(dataBytes(out)).toEqual([QOI_OP_RGB, 255, 0, 0, QOI_OP_RUN | 61, QOI_OP_RUN | 36]);
});
it("encodes an all-black-opaque image as a single run (matches the initial pixel)", () => {
// (0,0,0,255) equals the encoder's starting prev, so all 5 px are one run.
const out = qoiEncode(
new Uint8Array(5 * 4).map((_, i) => (i % 4 === 3 ? 255 : 0)),
5,
1,
4,
);
// No color chunk at all: just a single RUN of 5 (run-1 = 4).
expect(dataBytes(out)).toEqual([QOI_OP_RUN | 4]);
});
});
describe("qoiDecode header parsing", () => {
it("reads width, height, channels and colorspace back from the header", () => {
const out = qoiEncode(new Uint8Array(6 * 4), 3, 2, 4);
const { header } = qoiDecode(out);
expect(header).toEqual({ width: 3, height: 2, channels: 4, colorspace: 0 });
});
it("throws when the magic does not match", () => {
const bad = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
bad[0] = 0x00;
expect(() => qoiDecode(bad)).toThrow("Not a QOI file");
});
it("throws on zero width or height", () => {
const zeroW = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
new DataView(zeroW.buffer).setUint32(4, 0);
expect(() => qoiDecode(zeroW)).toThrow("Invalid QOI dimensions");
const zeroH = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
new DataView(zeroH.buffer).setUint32(8, 0);
expect(() => qoiDecode(zeroH)).toThrow("Invalid QOI dimensions");
});
it("throws on an invalid channel count", () => {
const bad = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
bad[12] = 2;
expect(() => qoiDecode(bad)).toThrow("Invalid QOI channels");
});
});
describe("qoi round-trip (encode then decode restores the exact RGBA pixels)", () => {
// Round-trip is the backbone: encode and decode are independent code paths, so
// a mutant in either one breaks byte-exact restoration for the case that
// exercises it. Decode always yields RGBA (4 channels).
function roundTrip(pixels: Uint8Array, w: number, h: number, channels: 3 | 4): Uint8Array {
const encoded = qoiEncode(pixels, w, h, channels);
return qoiDecode(encoded).pixels;
}
it("restores a 1x1 RGBA pixel", () => {
const px = rgba([123, 45, 67, 200]);
expect(Array.from(roundTrip(px, 1, 1, 4))).toEqual([123, 45, 67, 200]);
});
it("restores a DIFF-range sequence", () => {
// Each step moves channels by -2..1 relative to the previous pixel.
const px = rgba(
[100, 100, 100, 255],
[101, 99, 100, 255],
[99, 100, 101, 255],
[100, 98, 99, 255],
);
expect(Array.from(roundTrip(px, 4, 1, 4))).toEqual([
100, 100, 100, 255, 101, 99, 100, 255, 99, 100, 101, 255, 100, 98, 99, 255,
]);
});
it("restores a LUMA-range sequence", () => {
// Green moves by ~20 with red/blue tracking within the +/-8 luma window.
const px = rgba([50, 50, 50, 255], [66, 70, 74, 255], [80, 90, 98, 255]);
expect(Array.from(roundTrip(px, 3, 1, 4))).toEqual([
50, 50, 50, 255, 66, 70, 74, 255, 80, 90, 98, 255,
]);
});
it("restores an RGB-magnitude (out-of-luma) sequence", () => {
const px = rgba([10, 20, 30, 255], [200, 130, 60, 255], [5, 250, 128, 255]);
expect(Array.from(roundTrip(px, 3, 1, 4))).toEqual([
10, 20, 30, 255, 200, 130, 60, 255, 5, 250, 128, 255,
]);
});
it("restores alpha changes via the RGBA path", () => {
const px = rgba([40, 50, 60, 255], [40, 50, 60, 128], [40, 50, 60, 30]);
expect(Array.from(roundTrip(px, 3, 1, 4))).toEqual([
40, 50, 60, 255, 40, 50, 60, 128, 40, 50, 60, 30,
]);
});
it("restores INDEX hits from repeated colors", () => {
// Alternating two colors: second occurrences resolve through the index.
const c1: [number, number, number, number] = [200, 10, 20, 255];
const c2: [number, number, number, number] = [20, 200, 10, 255];
const px = rgba(c1, c2, c1, c2, c1);
expect(Array.from(roundTrip(px, 5, 1, 4))).toEqual([
200, 10, 20, 255, 20, 200, 10, 255, 200, 10, 20, 255, 20, 200, 10, 255, 200, 10, 20, 255,
]);
});
it("restores a solid-color run", () => {
const buf = new Uint8Array(70 * 4);
for (let i = 0; i < 70; i++) {
buf[i * 4] = 12;
buf[i * 4 + 1] = 34;
buf[i * 4 + 2] = 56;
buf[i * 4 + 3] = 255;
}
const decoded = roundTrip(buf, 70, 1, 4);
expect(decoded.length).toBe(70 * 4);
for (let i = 0; i < 70; i++) {
expect(Array.from(decoded.slice(i * 4, i * 4 + 4))).toEqual([12, 34, 56, 255]);
}
});
it("restores a 3-channel RGB image, filling alpha as 255", () => {
// RGB input (no alpha bytes); decode should reconstruct full opaque RGBA.
const rgb = new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255, 128, 128, 128]);
expect(Array.from(roundTrip(rgb, 4, 1, 3))).toEqual([
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 128, 128, 128, 255,
]);
});
it("restores a small 2D gradient exercising several chunk types", () => {
const w = 4;
const h = 3;
const buf = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const off = (y * w + x) * 4;
buf[off] = x * 40 + y * 5;
buf[off + 1] = y * 60 + x;
buf[off + 2] = 128 - x * 10;
buf[off + 3] = 255 - y * 20;
}
}
const decoded = roundTrip(buf, w, h, 4);
expect(Array.from(decoded)).toEqual(Array.from(buf));
});
});
@@ -0,0 +1,211 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { resize } from "../src/operations/resize.js";
import type { Sharp } from "../src/types.js";
// A non-square source (100x50) so width and height clamps can be observed
// independently: mutating one comparison in the clamp block cannot be masked
// by the other dimension.
function source(width: number, height: number): Sharp {
return sharp({
create: {
width,
height,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
});
}
// Sharp raw create-buffers carry no encoded format, so force PNG before
// re-reading metadata for exact output dimensions.
async function outputDims(image: Sharp): Promise<{ width?: number; height?: number }> {
const buf = await image.png().toBuffer();
const meta = await sharp(buf).metadata();
return { width: meta.width, height: meta.height };
}
describe("resize percentage path guard (L11)", () => {
it("resizes by percentage on a real image with known dims (guard does not early-return)", async () => {
// 100x50 @ 200% -> 200x100. Proves !metadata.width/!metadata.height was false
// and the percentage math ran with the real source dimensions.
const result = await resize(source(100, 50), { percentage: 200 });
const dims = await outputDims(result);
expect(dims.width).toBe(200);
expect(dims.height).toBe(100);
});
it("resizes down by percentage with correct per-axis scaling", async () => {
// 100x50 @ 50% -> 50x25. A single scale factor would give a square; the
// 50x25 result proves each axis is scaled from its own source dimension.
const result = await resize(source(100, 50), { percentage: 50 });
const dims = await outputDims(result);
expect(dims.width).toBe(50);
expect(dims.height).toBe(25);
});
});
describe("resize positive-dimension guards (L18 width, L21 height)", () => {
// Assert the guard's own message, not a bare throw. Sharp itself rejects
// width/height <= 0 with a different message ("Expected positive integer
// for width..."), so a bare rejects.toThrow() cannot tell the guard from
// Sharp and would let the "if (false)" / "<= -> <" mutants survive. The
// exact-message match dies the moment the guard stops running.
it("throws the width guard message on zero width but not on a valid positive width", async () => {
await expect(resize(source(100, 50), { width: 0 })).rejects.toThrow(
"Resize width must be greater than 0",
);
const dims = await outputDims(await resize(source(100, 50), { width: 40 }));
expect(dims.width).toBe(40);
expect(dims.height).toBe(20);
});
it("throws the width guard message on negative width", async () => {
await expect(resize(source(100, 50), { width: -5 })).rejects.toThrow(
"Resize width must be greater than 0",
);
});
it("throws the height guard message on zero height but not on a valid positive height", async () => {
await expect(resize(source(100, 50), { height: 0 })).rejects.toThrow(
"Resize height must be greater than 0",
);
const dims = await outputDims(await resize(source(100, 50), { height: 20 }));
expect(dims.width).toBe(40);
expect(dims.height).toBe(20);
});
it("throws the height guard message on negative height", async () => {
await expect(resize(source(100, 50), { height: -5 })).rejects.toThrow(
"Resize height must be greater than 0",
);
});
});
// The clamp block at L28-L35 mutates width/height BEFORE handing them to
// Sharp, and Sharp also receives withoutEnlargement. Under fit "cover"/"fill"
// Sharp's own withoutEnlargement clamps identically, which masks the manual
// block (mutants there survive). Under fit "contain", Sharp's withoutEnlargement
// does NOT shrink to fit (it pads to the full box), so ONLY the manual clamp
// changes the dimensions. Using "contain" makes the block observable, so the
// L28/L33/L34 mutants die on an exact-dimension mismatch.
describe("resize withoutEnlargement block execution (L28)", () => {
it("keeps output at source size when target is larger and withoutEnlargement is true", async () => {
// Manual clamp -> 100x50; a false-mutant on L28 skips it and (under contain)
// Sharp pads to the raw 200x100 box instead.
const dims = await outputDims(
await resize(source(100, 50), {
width: 200,
height: 100,
fit: "contain",
withoutEnlargement: true,
}),
);
expect(dims.width).toBe(100);
expect(dims.height).toBe(50);
});
it("enlarges to the target when withoutEnlargement is false", async () => {
const dims = await outputDims(
await resize(source(100, 50), {
width: 200,
height: 100,
fit: "contain",
withoutEnlargement: false,
}),
);
expect(dims.width).toBe(200);
expect(dims.height).toBe(100);
});
});
describe("resize clamp comparisons (L33 width, L34 height)", () => {
// Source is 100x50 for every case; withoutEnlargement + fit "contain" forces
// the manual clamp to be the only thing that can change the dimensions.
it("clamps width only when width exceeds source, leaving height untouched", async () => {
// target 200x40: width 200 > 100 -> clamps to 100; height 40 < 50 -> stays 40.
// Kills the L33 "width > meta.width" comparison: skip it and width stays 200.
const dims = await outputDims(
await resize(source(100, 50), {
width: 200,
height: 40,
fit: "contain",
withoutEnlargement: true,
}),
);
expect(dims.width).toBe(100);
expect(dims.height).toBe(40);
});
it("clamps height only when height exceeds source, leaving width untouched", async () => {
// target 80x200: width 80 < 100 -> stays 80; height 200 > 50 -> clamps to 50.
// Kills the L34 "height > meta.height" comparison: skip it and height stays 200.
const dims = await outputDims(
await resize(source(100, 50), {
width: 80,
height: 200,
fit: "contain",
withoutEnlargement: true,
}),
);
expect(dims.width).toBe(80);
expect(dims.height).toBe(50);
});
it("clamps both dimensions when both exceed source", async () => {
// target 300x300: both > source -> clamp to 100x50.
const dims = await outputDims(
await resize(source(100, 50), {
width: 300,
height: 300,
fit: "contain",
withoutEnlargement: true,
}),
);
expect(dims.width).toBe(100);
expect(dims.height).toBe(50);
});
it("clamps neither dimension when both are smaller than source (real shrink)", async () => {
// target 60x30: both < source -> no clamp, genuine downscale to 60x30.
const dims = await outputDims(
await resize(source(100, 50), {
width: 60,
height: 30,
fit: "contain",
withoutEnlargement: true,
}),
);
expect(dims.width).toBe(60);
expect(dims.height).toBe(30);
});
});
describe("resize withoutEnlargement default (L41)", () => {
// Sharp treats withoutEnlargement:undefined the same as false, so the exact
// "?? false -> && false" mutant is equivalent at the output level (both
// enlarge). These cases still pin the contract: omitted defaults to enlarge,
// explicit true clamps.
it("defaults to false and enlarges to the target when withoutEnlargement is omitted", async () => {
// No withoutEnlargement: default false -> enlarge 100x50 to 200x100.
const dims = await outputDims(
await resize(source(100, 50), { width: 200, height: 100, fit: "contain" }),
);
expect(dims.width).toBe(200);
expect(dims.height).toBe(100);
});
it("clamps to source when withoutEnlargement is explicitly true", async () => {
const dims = await outputDims(
await resize(source(100, 50), {
width: 200,
height: 100,
fit: "contain",
withoutEnlargement: true,
}),
);
expect(dims.width).toBe(100);
expect(dims.height).toBe(50);
});
});
@@ -0,0 +1,355 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { sharpenAdvanced } from "../src/operations/sharpen.js";
import type { Sharp, SharpenAdvancedOptions } from "../src/types.js";
// Mutation-killing tests for src/operations/sharpen.ts. Every expected number
// here was captured by running the real sharpenAdvanced through Sharp; nothing
// is hand-derived from the kernel maths (libvips convolve quantises and offsets
// in ways a paper calculation would miss). The oracles:
//
// - IMPULSE HISTOGRAM: a single bright pixel on a flat gray field, fed through
// high-pass. The order-independent value histogram of the raw output pins
// each kernel coefficient. Any sign/scale change to a `-s`, `s * 2`,
// `1 + s * 8`, or `1 + 4 * s` term shifts a specific histogram bucket. The
// histogram (not pixel positions) is used because libvips convolve offsets
// the response spatially; the multiset of values is stable, the layout is not.
// - EDGE OVERSHOOT: a hard step edge. Sharpening rings it below lo / above hi.
// min/max is the oracle for the adaptive gain/halo params.
// - PATCH VARIANCE: a noisy uniform patch. The median denoise pre-pass lowers
// variance; a bigger kernel lowers it more.
// - STAIRCASE / RAMP: gentle gradients where the x1 flat/jagged threshold and
// the m1 flat-area gain actually bite (they do nothing on a hard edge).
// --- oracles ---------------------------------------------------------------
function histogram(out: Buffer): Array<[number, number]> {
const counts = new Map<number, number>();
for (const v of out) counts.set(v, (counts.get(v) ?? 0) + 1);
return [...counts.entries()].sort((a, b) => a[0] - b[0]);
}
// Impulse-on-gray field pushed through a high-pass kernel; returns the raw
// output value histogram. bg is the flat background, imp the single hot pixel.
async function highPassImpulseHist(
strength: number,
kernelSize: 3 | 5,
bg: number,
imp: number,
width: number,
height: number,
): Promise<Array<[number, number]>> {
const buf = Buffer.alloc(width * height, bg);
buf[Math.floor(height / 2) * width + Math.floor(width / 2)] = imp;
const img = sharp(buf, { raw: { width, height, channels: 1 } });
const result = await sharpenAdvanced(img, { method: "high-pass", strength, kernelSize });
return histogram(await result.raw().toBuffer());
}
// Vertical mid-gray step edge (left = lo, right = hi). Sharpening overshoots
// the step; min/max carry the sign and magnitude of the sharpening.
async function edgeStats(
apply: (img: Sharp) => Sharp | Promise<Sharp>,
lo = 100,
hi = 160,
width = 60,
height = 8,
): Promise<{ min: number; max: number }> {
const buf = Buffer.alloc(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
buf[y * width + x] = x < width / 2 ? lo : hi;
}
}
const img = sharp(buf, { raw: { width, height, channels: 1 } });
const out = await (await apply(img)).raw().toBuffer();
let min = 255;
let max = 0;
for (const v of out) {
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
// Variance of a noisy uniform patch. Deterministic LCG keeps the fixture stable.
async function patchVariance(apply: (img: Sharp) => Sharp | Promise<Sharp>): Promise<number> {
const width = 32;
const height = 32;
const buf = Buffer.alloc(width * height);
let seed = 12345;
const rand = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
for (let i = 0; i < width * height; i++) {
buf[i] = Math.round(120 + (rand() - 0.5) * 80);
}
const img = sharp(buf, { raw: { width, height, channels: 1 } });
const out = await (await apply(img)).raw().toBuffer();
let sum = 0;
let sumSq = 0;
for (const v of out) {
sum += v;
sumSq += v * v;
}
const n = out.length;
const mean = sum / n;
return sumSq / n - mean * mean;
}
// A staircase of steps whose heights grow left to right, so different x1
// flat/jagged thresholds gate different steps. Used to make x1 observable.
function staircaseBuffer(width: number, height: number): Buffer {
const heights = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20];
const buf = Buffer.alloc(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const seg = Math.floor(x / 8);
const level = 100 + (heights[seg % heights.length] ?? 0) * (x % 8 < 4 ? 0 : 1);
buf[y * width + x] = Math.min(255, level);
}
}
return buf;
}
// A low-slope linear ramp: small local differences read as "flat", so the
// flat-area gain m1 and its threshold x1 dominate.
function gentleRampBuffer(width: number, height: number, lo: number, hi: number): Buffer {
const buf = Buffer.alloc(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
buf[y * width + x] = Math.round(lo + (hi - lo) * (x / (width - 1)));
}
}
return buf;
}
async function adaptiveStats(
opts: Omit<SharpenAdvancedOptions, "method">,
buf: Buffer,
width: number,
height: number,
): Promise<{ min: number; max: number }> {
const img = sharp(buf, { raw: { width, height, channels: 1 } });
const out = await (await sharpenAdvanced(img, { method: "adaptive", ...opts })).raw().toBuffer();
let min = 255;
let max = 0;
for (const v of out) {
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
// --- L78-L106: convolution kernel coefficients (impulse response) ----------
describe("sharpenAdvanced high-pass: 3x3 kernel coefficients (impulse response)", () => {
it("pins every 3x3 coefficient via the impulse histogram", async () => {
// strength 50 -> s = 0.5, kernel [0,-0.5,0, -0.5,3,-0.5, 0,-0.5,0].
// On a 9x9 field of 100 with one 140 pixel, the raw output histogram is
// exactly this. The center weight (1 + 4*0.5 = 3) produces the 220 bucket;
// the four edge weights (-0.5) produce the 80 bucket; untouched background
// stays 100. Flip a `-s` sign, drop the `1 +`, or swap `4 * s` and the
// 80 / 220 buckets move.
const hist = await highPassImpulseHist(50, 3, 100, 140, 9, 9);
expect(hist).toEqual([
[80, 12],
[100, 228],
[220, 3],
]);
});
it("moves the coefficient buckets when strength (s) changes", async () => {
// strength 20 -> s = 0.2. Center 1 + 0.8 = 1.8, edges -0.2. Different
// buckets from strength 50: proves the kernel is a function of s, so a
// constant-folded coefficient can't reproduce both.
const hist = await highPassImpulseHist(20, 3, 100, 140, 9, 9);
expect(hist).toEqual([
[92, 12],
[100, 228],
[172, 3],
]);
});
});
describe("sharpenAdvanced high-pass: 5x5 kernel coefficients (impulse response)", () => {
it("pins every 5x5 coefficient via the impulse histogram", async () => {
// strength 25 -> s = 0.25. Kernel has coefficients 0, -s (=-0.25),
// s (=0.25), s*2 (=0.5), and center 1 + s*8 (=3). On an 11x11 field of 120
// with one 160 pixel the histogram lands on distinct, non-clamped buckets:
// the -s ring (116), the s ring (123), the s*2 ring (126), the center (160),
// background (120). A mutation to any of `-s`, `s * 2`, or `1 + s * 8`
// relocates its bucket.
const hist = await highPassImpulseHist(25, 5, 120, 160, 11, 11);
expect(hist).toEqual([
[116, 36],
[120, 300],
[123, 12],
[126, 12],
[160, 3],
]);
});
});
// --- L75: kernelSize === 5 branch selection --------------------------------
describe("sharpenAdvanced high-pass: L75 kernelSize === 5 branch", () => {
it("selects a genuinely different kernel for size 5 vs size 3", async () => {
// Same strength, different kernel size: the 5x5 spreads wider and rings the
// edge differently. Exact overshoot is pinned so the === 5 branch can't be
// made unconditional without breaking one of these.
const k3 = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 80, kernelSize: 3 }),
);
const k5 = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 80, kernelSize: 5 }),
);
expect(k3).toEqual({ min: 52, max: 208 });
expect(k5).toEqual({ min: 80, max: 179 });
});
});
// --- L31 / L33: denoise pre-pass -------------------------------------------
describe("sharpenAdvanced: L31/L33 denoise pre-pass", () => {
const identityHighPass = (denoise?: SharpenAdvancedOptions["denoise"]) => (img: Sharp) =>
sharpenAdvanced(img, { method: "high-pass", strength: 0, denoise });
it("denoise 'off' is a no-op: variance stays equal to the raw patch", async () => {
// strength 0 high-pass is an identity convolution, so any variance change is
// purely the median pass. 'off' must not run it. Kills the force-true
// mutation of `if (denoise && denoise !== "off")`.
const off = await patchVariance(identityHighPass("off"));
const raw = await patchVariance((img) => img);
expect(off).toBeCloseTo(raw, 5);
});
it("omitting denoise is a no-op (undefined short-circuits the &&)", async () => {
const omitted = await patchVariance(identityHighPass(undefined));
const raw = await patchVariance((img) => img);
expect(omitted).toBeCloseTo(raw, 5);
});
it("denoise 'light' actually runs the median (variance drops)", async () => {
// Kills the force-false mutation of the L31 condition and the removal of the
// L34 median call: with them, 'light' would leave the noise intact.
const light = await patchVariance(identityHighPass("light"));
const raw = await patchVariance((img) => img);
expect(light).toBeLessThan(raw * 0.6);
expect(light).toBeCloseTo(153.67, 0);
});
it("bigger denoise kernels smooth strictly more (light > medium > strong)", async () => {
// Pins the L32 DENOISE_KERNEL lookup ordering (3 < 5 < 7). A swapped or
// constant kernel size breaks the monotonic variance drop.
const light = await patchVariance(identityHighPass("light"));
const medium = await patchVariance(identityHighPass("medium"));
const strong = await patchVariance(identityHighPass("strong"));
expect(medium).toBeLessThan(light);
expect(strong).toBeLessThan(medium);
});
});
// --- L52-56: adaptive `??` defaults ----------------------------------------
//
// Each `options.p ?? DEFAULT` is mutated to `options.p && DEFAULT`. When p is
// provided and truthy, `??` keeps p but `&&` returns DEFAULT. So passing an
// explicit truthy value whose output differs from the default's output kills the
// mutant: the `&&` variant would fall back to the default and miss the assertion.
// Paired "omitted == default" checks pin the default value itself.
describe("sharpenAdvanced adaptive: L52 m1 default", () => {
const width = 96;
const height = 12;
const ramp = gentleRampBuffer(width, height, 90, 150);
// m2 = 0 so only the flat-area gain m1 acts on the gentle ramp.
const base = { sigma: 3, m2: 0, x1: 2, y2: 30, y3: 30 } as const;
it("applies m1 = 1.0 by default", async () => {
expect(await adaptiveStats(base, ramp, width, height)).toEqual({ min: 89, max: 151 });
});
it("honors an explicit m1 (10) instead of the default", async () => {
// `10 && 1.0` = 1.0 would give {89,151}; the real `10 ?? 1.0` = 10 rings harder.
expect(await adaptiveStats({ ...base, m1: 10 }, ramp, width, height)).toEqual({
min: 83,
max: 158,
});
});
});
describe("sharpenAdvanced adaptive: L53 m2 default", () => {
const width = 96;
const height = 12;
const ramp = gentleRampBuffer(width, height, 90, 150);
// m1 = 0, x1 = 0 so the textured gain m2 acts across the ramp.
const base = { sigma: 3, m1: 0, x1: 0, y2: 30, y3: 30 } as const;
it("applies m2 = 3.0 by default", async () => {
expect(await adaptiveStats(base, ramp, width, height)).toEqual({ min: 87, max: 152 });
});
it("honors an explicit m2 (10) instead of the default", async () => {
// `10 && 3.0` = 3.0 would give {87,152}; `10 ?? 3.0` = 10 pushes further.
expect(await adaptiveStats({ ...base, m2: 10 }, ramp, width, height)).toEqual({
min: 83,
max: 158,
});
});
});
describe("sharpenAdvanced adaptive: L54 x1 default", () => {
const width = 80;
const height = 12;
const staircase = staircaseBuffer(width, height);
const base = { sigma: 2, m1: 0, m2: 12, y2: 40, y3: 40 } as const;
it("applies x1 = 2.0 by default", async () => {
expect(await adaptiveStats(base, staircase, width, height)).toEqual({ min: 60, max: 158 });
});
it("honors an explicit x1 (4) instead of the default", async () => {
// `4 && 2.0` = 2.0 would give {60,158}; `4 ?? 2.0` = 4 raises the flat/jagged
// threshold so most steps stop sharpening: {100,120}.
expect(await adaptiveStats({ ...base, x1: 4 }, staircase, width, height)).toEqual({
min: 100,
max: 120,
});
});
});
describe("sharpenAdvanced adaptive: L55 y2 default (max brightening halo)", () => {
// Hard edge; strong gains so the overshoot pushes into the halo clamp.
const base = { sigma: 2, m1: 2, m2: 6, x1: 1, y3: 20 } as const;
it("applies y2 = 12 by default", async () => {
const stats = await edgeStats((img) => sharpenAdvanced(img, { method: "adaptive", ...base }));
expect(stats).toEqual({ min: 54, max: 192 });
});
it("honors an explicit y2 (3) that clamps the bright overshoot lower", async () => {
// `3 && 12` = 12 would leave max 192; `3 ?? 12` = 3 caps brightening at 168.
const stats = await edgeStats((img) =>
sharpenAdvanced(img, { method: "adaptive", ...base, y2: 3 }),
);
expect(stats).toEqual({ min: 54, max: 168 });
});
});
describe("sharpenAdvanced adaptive: L56 y3 default (max darkening halo)", () => {
const base = { sigma: 2, m1: 2, m2: 6, x1: 1, y2: 12 } as const;
it("applies y3 = 20 by default", async () => {
const stats = await edgeStats((img) => sharpenAdvanced(img, { method: "adaptive", ...base }));
expect(stats).toEqual({ min: 54, max: 192 });
});
it("honors an explicit y3 (3) that clamps the dark undershoot higher", async () => {
// `3 && 20` = 20 would leave min 54; `3 ?? 20` = 3 caps darkening at 93.
const stats = await edgeStats((img) =>
sharpenAdvanced(img, { method: "adaptive", ...base, y3: 3 }),
);
expect(stats).toEqual({ min: 93, max: 192 });
});
});
@@ -0,0 +1,418 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { optimizeForWeb } from "../src/operations/optimize-for-web.js";
import { sharpen, sharpenAdvanced } from "../src/operations/sharpen.js";
import type { Sharp } from "../src/types.js";
// A vertical mid-gray step edge (left = lo, right = hi). Sharpening rings the
// step, pushing pixels near the boundary BELOW lo and ABOVE hi. That overshoot
// is the oracle: its presence and magnitude scale with sharpening strength, so
// asserting on min/max kills the sign and magnitude mutants in the sigma / m1 /
// m2 / kernel math. Grayscale single-channel keeps the stats clean.
async function edgeStats(
apply: (img: Sharp) => Sharp | Promise<Sharp>,
lo = 100,
hi = 160,
width = 60,
height = 8,
): Promise<{ min: number; max: number }> {
const buf = Buffer.alloc(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
buf[y * width + x] = x < width / 2 ? lo : hi;
}
}
const img = sharp(buf, { raw: { width, height, channels: 1 } });
const result = await apply(img);
const out = await result.raw().toBuffer();
let min = 255;
let max = 0;
for (const v of out) {
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
// Variance of a noisy uniform patch. Median denoise lowers it; a bigger kernel
// lowers it more. Deterministic LCG so the fixture is stable across runs.
async function patchVariance(apply: (img: Sharp) => Sharp | Promise<Sharp>): Promise<number> {
const width = 32;
const height = 32;
const buf = Buffer.alloc(width * height);
let seed = 12345;
const rand = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
for (let i = 0; i < width * height; i++) {
buf[i] = Math.round(120 + (rand() - 0.5) * 80);
}
const img = sharp(buf, { raw: { width, height, channels: 1 } });
const out = await (await apply(img)).raw().toBuffer();
let sum = 0;
let sumSq = 0;
for (const v of out) {
sum += v;
sumSq += v * v;
}
const n = out.length;
const mean = sum / n;
return sumSq / n - mean * mean;
}
function solidPng(
width: number,
height: number,
channels: 3 | 4 = 3,
background: { r: number; g: number; b: number; alpha?: number } = { r: 120, g: 130, b: 140 },
): Promise<Buffer> {
return sharp({ create: { width, height, channels, background } }).png().toBuffer();
}
describe("sharpen (basic)", () => {
it("leaves the edge untouched when value is 0 (<= 0 no-op branch)", async () => {
const { min, max } = await edgeStats((img) => sharpen(img, { value: 0 }));
// No sharpen applied: the step stays exactly [lo, hi], no overshoot.
expect(min).toBe(100);
expect(max).toBe(160);
});
it("leaves the edge untouched for negative values (boundary below 0)", async () => {
const { min, max } = await edgeStats((img) => sharpen(img, { value: -5 }));
expect(min).toBe(100);
expect(max).toBe(160);
});
it("overshoots the edge for the smallest positive value (value = 1)", async () => {
// value=1 -> sigma 0.595. Must actually sharpen: min drops below lo,
// max rises above hi. Kills the "sigma always 0" / dropped-term mutants.
const { min, max } = await edgeStats((img) => sharpen(img, { value: 1 }));
expect(min).toBeLessThan(100);
expect(max).toBeGreaterThan(160);
// Bounded overshoot at this low sigma: observed min 86, max 175. If the
// mapping lost its "+ 0.5" base or flipped sign the magnitude would differ.
expect(min).toBeGreaterThanOrEqual(80);
expect(min).toBeLessThanOrEqual(95);
expect(max).toBeGreaterThanOrEqual(170);
expect(max).toBeLessThanOrEqual(180);
});
it("produces strictly more overshoot at value=25 than at value=1 (monotonic sigma)", async () => {
const low = await edgeStats((img) => sharpen(img, { value: 1 }));
const high = await edgeStats((img) => sharpen(img, { value: 25 }));
// Higher strength => lower undershoot and higher overshoot.
expect(high.min).toBeLessThan(low.min);
expect(high.max).toBeGreaterThan(low.max);
});
it("maps value=100 to the maximum sigma (10) with full overshoot", async () => {
const { min, max } = await edgeStats((img) => sharpen(img, { value: 100 }));
// sigma 10: strongest ringing. Observed min 54, max 187.
expect(min).toBeLessThanOrEqual(60);
expect(max).toBeGreaterThanOrEqual(185);
});
it("throws when value exceeds 100 (upper clamp boundary)", async () => {
const png = await solidPng(16, 16);
await expect(sharpen(sharp(png), { value: 101 })).rejects.toThrow(
"Sharpness value must be between 0 and 100",
);
});
it("does not throw at the inclusive upper boundary (value = 100)", async () => {
const png = await solidPng(16, 16);
await expect(sharpen(sharp(png), { value: 100 })).resolves.toBeDefined();
});
it("preserves dimensions and format", async () => {
const png = await solidPng(32, 24);
const result = await sharpen(sharp(png), { value: 50 });
const meta = await sharp(await result.png().toBuffer()).metadata();
expect(meta.width).toBe(32);
expect(meta.height).toBe(24);
expect(meta.format).toBe("png");
});
});
describe("sharpenAdvanced (dispatch + denoise)", () => {
it("throws on an unknown method", async () => {
const buf = Buffer.alloc(64);
const img = sharp(buf, { raw: { width: 8, height: 8, channels: 1 } });
await expect(
sharpenAdvanced(img, { method: "bogus" as unknown as "adaptive" }),
).rejects.toThrow("Unknown sharpening method: bogus");
});
it("denoise 'off' skips the median pre-pass (variance unchanged vs raw)", async () => {
// high-pass strength 0 is an identity convolution, so any variance drop is
// purely the median pass. 'off' must leave the noise intact.
const off = await patchVariance((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 0, denoise: "off" }),
);
const raw = await patchVariance((img) => img);
expect(off).toBeCloseTo(raw, 1);
});
it("denoise strength increases with kernel size: off > light > strong", async () => {
const off = await patchVariance((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 0, denoise: "off" }),
);
const light = await patchVariance((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 0, denoise: "light" }),
);
const medium = await patchVariance((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 0, denoise: "medium" }),
);
const strong = await patchVariance((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 0, denoise: "strong" }),
);
// Median kernel 3 (light) < 5 (medium) < 7 (strong) => monotonically
// smoother. Kills the DENOISE_KERNEL value mutants and branch swaps.
expect(light).toBeLessThan(off);
expect(medium).toBeLessThan(light);
expect(strong).toBeLessThan(medium);
});
});
describe("sharpenAdvanced: adaptive", () => {
it("sharpens with the default params (overshoots the edge)", async () => {
const base = await edgeStats((img) => img);
const adaptive = await edgeStats((img) => sharpenAdvanced(img, { method: "adaptive" }));
expect(adaptive.min).toBeLessThan(base.min);
expect(adaptive.max).toBeGreaterThan(base.max);
});
it("does nothing when m1 and m2 are 0 (flat/textured gains disabled)", async () => {
// With no flat-area and no textured-area gain, the adaptive sharpen is a
// no-op: the step stays exactly [100, 160]. Kills the m1/m2 default mutants.
const { min, max } = await edgeStats((img) =>
sharpenAdvanced(img, { method: "adaptive", sigma: 2, m1: 0, m2: 0 }),
);
expect(min).toBe(100);
expect(max).toBe(160);
});
it("stronger m1/m2 gains produce more overshoot than the disabled case", async () => {
const off = await edgeStats((img) =>
sharpenAdvanced(img, { method: "adaptive", sigma: 2, m1: 0, m2: 0 }),
);
const on = await edgeStats((img) =>
sharpenAdvanced(img, { method: "adaptive", sigma: 2, m1: 5, m2: 5 }),
);
expect(on.min).toBeLessThan(off.min);
expect(on.max).toBeGreaterThan(off.max);
});
});
describe("sharpenAdvanced: unsharp-mask", () => {
it("sharpens with default amount (overshoots the edge)", async () => {
const base = await edgeStats((img) => img);
const um = await edgeStats((img) => sharpenAdvanced(img, { method: "unsharp-mask" }));
expect(um.min).toBeLessThan(base.min);
expect(um.max).toBeGreaterThan(base.max);
});
it("higher amount yields more overshoot (intensity = amount / 100)", async () => {
const low = await edgeStats((img) =>
sharpenAdvanced(img, { method: "unsharp-mask", amount: 100, radius: 2 }),
);
const high = await edgeStats((img) =>
sharpenAdvanced(img, { method: "unsharp-mask", amount: 300, radius: 2 }),
);
// amount 300 -> intensity 3.0 vs 1.0: markedly stronger ringing.
expect(high.min).toBeLessThan(low.min);
expect(high.max).toBeGreaterThan(low.max);
});
});
describe("sharpenAdvanced: high-pass", () => {
it("the 3x3 and 5x5 kernels give different results (kernelSize === 5 branch)", async () => {
const k3 = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 80, kernelSize: 3 }),
);
const k5 = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 80, kernelSize: 5 }),
);
// Observed: 3x3 rings harder (52/208) than 5x5 (80/179) on this edge.
// The point is they diverge, so the === 5 branch selection is real.
expect(k3.min).not.toBe(k5.min);
expect(k3.max).not.toBe(k5.max);
});
it("defaults to the 3x3 kernel when kernelSize is omitted", async () => {
const explicit3 = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 80, kernelSize: 3 }),
);
const defaulted = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 80 }),
);
expect(defaulted.min).toBe(explicit3.min);
expect(defaulted.max).toBe(explicit3.max);
});
it("stronger strength sharpens more (s = strength / 100)", async () => {
const lo = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 20, kernelSize: 3 }),
);
const hi = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 100, kernelSize: 3 }),
);
expect(hi.min).toBeLessThan(lo.min);
expect(hi.max).toBeGreaterThan(lo.max);
});
it("strength 0 is an identity convolution (edge unchanged)", async () => {
// Center weight 1 + 4*0 = 1, neighbours 0 => output equals input.
const { min, max } = await edgeStats((img) =>
sharpenAdvanced(img, { method: "high-pass", strength: 0, kernelSize: 3 }),
);
expect(min).toBe(100);
expect(max).toBe(160);
});
});
describe("optimizeForWeb: format selection", () => {
it("encodes webp when format is 'webp'", async () => {
const png = await solidPng(40, 40);
const out = await (
await optimizeForWeb(sharp(png), { format: "webp", quality: 80 })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.format).toBe("webp");
});
it("encodes progressive mozjpeg when format is 'jpeg'", async () => {
const png = await solidPng(40, 40);
const out = await (
await optimizeForWeb(sharp(png), { format: "jpeg", quality: 70, progressive: true })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.format).toBe("jpeg");
// progressive: true flows into jpeg({ progressive }); mozjpeg encodes it.
expect(meta.isProgressive).toBe(true);
});
it("encodes avif when format is 'avif'", async () => {
const png = await solidPng(40, 40);
const out = await (
await optimizeForWeb(sharp(png), { format: "avif", quality: 40 })
).toBuffer();
const meta = await sharp(out).metadata();
// Sharp reports AVIF containers as "heif".
const format = meta.format === "heif" ? "avif" : meta.format;
expect(format).toBe("avif");
});
it("encodes a palette png that preserves alpha when format is 'png'", async () => {
const alpha = await solidPng(24, 24, 4, { r: 255, g: 0, b: 0, alpha: 0.5 });
const out = await (
await optimizeForWeb(sharp(alpha), { format: "png", quality: 80 })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.format).toBe("png");
// png({ palette: true }) is set, and alpha survives the round-trip.
expect(meta.isPalette).toBe(true);
expect(meta.hasAlpha).toBe(true);
});
it("throws on an unsupported format", async () => {
const png = await solidPng(16, 16);
await expect(
optimizeForWeb(sharp(png), {
format: "tiff" as unknown as "webp",
quality: 70,
}),
).rejects.toThrow("Unsupported format: tiff");
});
});
describe("optimizeForWeb: resize cap", () => {
it("resizes an over-cap image down to fit inside maxWidth (exact dimensions)", async () => {
// 200x100, maxWidth 100 => scaled to 100x50 (fit: inside, aspect kept).
const big = await solidPng(200, 100, 3, { r: 80, g: 120, b: 160 });
const out = await (
await optimizeForWeb(sharp(big), { format: "jpeg", quality: 70, maxWidth: 100 })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(50);
});
it("caps on height when maxHeight is the binding dimension", async () => {
// 100x200, maxHeight 100 => 50x100.
const tall = await solidPng(100, 200, 3, { r: 80, g: 120, b: 160 });
const out = await (
await optimizeForWeb(sharp(tall), { format: "webp", quality: 80, maxHeight: 100 })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(100);
});
it("does NOT upscale an under-cap image (withoutEnlargement)", async () => {
// 80x40, maxWidth 100 => untouched 80x40, never enlarged to the cap.
const small = await solidPng(80, 40, 3, { r: 80, g: 120, b: 160 });
const out = await (
await optimizeForWeb(sharp(small), { format: "webp", quality: 80, maxWidth: 100 })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.width).toBe(80);
expect(meta.height).toBe(40);
});
it("leaves dimensions untouched when no max is set", async () => {
const img = await solidPng(150, 90, 3, { r: 80, g: 120, b: 160 });
const out = await (
await optimizeForWeb(sharp(img), { format: "webp", quality: 80 })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.width).toBe(150);
expect(meta.height).toBe(90);
});
it("does not resize when the image exactly equals the cap (boundary)", async () => {
// 100 wide, maxWidth 100: fit inside with withoutEnlargement is a no-op.
const exact = await solidPng(100, 60, 3, { r: 80, g: 120, b: 160 });
const out = await (
await optimizeForWeb(sharp(exact), { format: "webp", quality: 80, maxWidth: 100 })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(60);
});
});
describe("optimizeForWeb: metadata handling", () => {
it("strips metadata by default (no ICC profile carried through)", async () => {
const withProfile = await sharp({
create: { width: 30, height: 30, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.withMetadata({ icc: "srgb" })
.png()
.toBuffer();
const out = await (
await optimizeForWeb(sharp(withProfile), { format: "jpeg", quality: 70 })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.hasProfile).toBe(false);
});
it("keeps the ICC profile when stripMetadata is false", async () => {
const withProfile = await sharp({
create: { width: 30, height: 30, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.withMetadata({ icc: "srgb" })
.png()
.toBuffer();
const out = await (
await optimizeForWeb(sharp(withProfile), {
format: "jpeg",
quality: 70,
stripMetadata: false,
})
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.hasProfile).toBe(true);
});
});