mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
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 });
|
|
});
|
|
});
|