mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user