Files
rea/src/browser/PngVisualDiff.test.ts
T
Rudy Mizrahi Celekli 9997152c2d fix(browser): preserve RGB PNG transparent samples (#595)
Respect the RGB PNG tRNS transparent-color key when expanding 8-bit RGB pixels to RGBA, so equivalent RGB+tRNS and RGBA captures compare identically.
2026-10-06 01:51:32 +08:00

208 lines
6.3 KiB
TypeScript

import { crc32, deflateSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { comparePngScreenshots } from "./PngVisualDiff.js";
import {
compareWebScreenshotsInputSchema,
createWebScreenshotArtifact,
webScreenshotDiffSchema,
} from "../domain/webScreenshot.js";
describe("PNG visual diff", () => {
it("reports exact changed-pixel and channel metrics", () => {
const before = artifact(1, 1, [0, 0, 0, 255]);
const after = artifact(1, 1, [10, 0, 0, 255]);
const result = comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({ before, after }),
);
expect(result).toMatchObject({
status: "different",
compared_pixels: 1,
changed_pixels: 1,
changed_ratio: 1,
maximum_channel_delta: 10,
mean_absolute_channel_delta: 2.5,
});
});
it("applies a channel threshold and reports dimension mismatch", () => {
const one = artifact(1, 1, [0, 0, 0, 255]);
const near = artifact(1, 1, [2, 0, 0, 255]);
expect(
comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({
before: one,
after: near,
channel_threshold: 2,
}),
).status,
).toBe("identical");
expect(
comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({
before: one,
after: artifact(2, 1, [0, 0, 0, 255, 0, 0, 0, 255]),
}),
),
).toMatchObject({ status: "dimension_mismatch", compared_pixels: 0 });
});
it("preserves RGB transparent-color samples when comparing RGBA pixels", () => {
const header = Buffer.alloc(13);
header.writeUInt32BE(2, 0);
header.writeUInt32BE(1, 4);
header[8] = 8;
header[9] = 2;
const transparent = Buffer.alloc(6);
transparent.writeUInt16BE(10, 0);
transparent.writeUInt16BE(20, 2);
transparent.writeUInt16BE(30, 4);
const rgb = createWebScreenshotArtifact(
Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", header),
chunk("tRNS", transparent),
chunk("IDAT", deflateSync(Buffer.from([0, 10, 20, 30, 10, 20, 31]))),
chunk("IEND", Buffer.alloc(0)),
]),
);
const rgba = artifact(2, 1, [10, 20, 30, 0, 10, 20, 31, 255]);
expect(
comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({ before: rgb, after: rgba }),
),
).toMatchObject({
status: "identical",
changed_pixels: 0,
maximum_channel_delta: 0,
});
});
it("keeps 8-bit samples opaque when a 16-bit transparency key exceeds 255", () => {
const key = Buffer.alloc(6);
key.writeUInt16BE(266, 0);
key.writeUInt16BE(20, 2);
key.writeUInt16BE(30, 4);
const rgb = transparencyArtifact(2, key, [10, 20, 30]);
const result = comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({
before: rgb,
after: artifact(1, 1, [10, 20, 30, 255]),
}),
);
expect(result).toMatchObject({ status: "identical", changed_pixels: 0 });
expect(result.limitations).toContain(
"PNG tRNS transparency is accepted only as a six-byte RGB color key; tRNS on RGBA or with another length is rejected.",
);
});
it.each([0, 5, 7])("rejects an RGB tRNS chunk of length %i", (length) => {
const image = transparencyArtifact(2, Buffer.alloc(length), [10, 20, 30]);
expect(() =>
comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({ before: image, after: image }),
),
).toThrow("Unsupported PNG transparency");
});
it("rejects tRNS on an RGBA image instead of dropping its transparency data", () => {
const image = transparencyArtifact(6, Buffer.alloc(6), [10, 20, 30, 255]);
expect(() =>
comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({ before: image, after: image }),
),
).toThrow("Unsupported PNG transparency");
});
it("rejects malformed PNG dimensions after validating image data", () => {
const image = artifact(32_000_001, 1, [0, 0, 0, 255]);
expect(() =>
comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({
before: image,
after: image,
}),
),
).toThrow("Unexpected PNG size");
});
it("rejects metrics that cannot represent their comparison status", () => {
const image = artifact(1, 1, [0, 0, 0, 255]);
const result = comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({ before: image, after: image }),
);
expect(
webScreenshotDiffSchema.safeParse({
...result,
status: "dimension_mismatch",
}).success,
).toBe(false);
expect(
webScreenshotDiffSchema.safeParse({
...result,
compared_pixels: 2,
}).success,
).toBe(false);
});
});
const artifact = (width: number, height: number, pixels: readonly number[]) =>
createWebScreenshotArtifact(png(width, height, pixels));
const png = (
width: number,
height: number,
pixels: readonly number[],
): Buffer => {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
const rows: number[] = [];
for (let row = 0; row < height; row += 1)
rows.push(0, ...pixels.slice(row * width * 4, (row + 1) * width * 4));
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", header),
chunk("IDAT", deflateSync(Buffer.from(rows))),
chunk("IEND", Buffer.alloc(0)),
]);
};
const chunk = (type: string, data: Buffer): Buffer => {
const result = Buffer.alloc(12 + data.byteLength);
result.writeUInt32BE(data.byteLength, 0);
result.write(type, 4, 4, "ascii");
data.copy(result, 8);
result.writeUInt32BE(
crc32(result.subarray(4, 8 + data.byteLength)),
8 + data.byteLength,
);
return result;
};
const transparencyArtifact = (
colorType: number,
transparency: Buffer,
pixels: readonly number[],
) => {
const header = Buffer.alloc(13);
header.writeUInt32BE(1, 0);
header.writeUInt32BE(1, 4);
header[8] = 8;
header[9] = colorType;
return createWebScreenshotArtifact(
Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", header),
chunk("tRNS", transparency),
chunk("IDAT", deflateSync(Buffer.from([0, ...pixels]))),
chunk("IEND", Buffer.alloc(0)),
]),
);
};