Files
SnapOtter/packages/image-engine/tests/sharpen-optimize.test.ts
T
SnapOtterandGitHub 301e6eb01a 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.
2026-07-24 17:36:57 +08:00

419 lines
16 KiB
TypeScript

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);
});
});