111 lines
5.2 KiB
TypeScript
111 lines
5.2 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
import {
|
||
ImageError, inspectAll, inspectCandidate, probeImage, readCapped, sha256Hex, shapeOf, typeForShape,
|
||
} from "../src/core/images";
|
||
import type { CoverCandidate } from "../src/core/model";
|
||
import { FakeTransport, bytes, text } from "./helpers/fake-transport";
|
||
import { fixtureBytes } from "./helpers/fixtures";
|
||
|
||
describe("probeImage", () => {
|
||
test("real JPEG and PNG fixtures", async () => {
|
||
expect(probeImage(await fixtureBytes("images/poster-95.jpg")))
|
||
.toEqual({ width: 480, height: 623, contentType: "image/jpeg", ext: "jpg" });
|
||
expect(probeImage(await fixtureBytes("images/foreground-95.png")))
|
||
.toEqual({ width: 1200, height: 240, contentType: "image/png", ext: "png" });
|
||
expect(probeImage(await fixtureBytes("images/landing-2023.jpg")))
|
||
.toMatchObject({ width: 720, height: 1230 });
|
||
});
|
||
|
||
// Synthetic headers: these test the format decoders, they are not captured service responses.
|
||
test("GIF and WebP (VP8X) headers", () => {
|
||
const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x2c, 0x01, 0xc8, 0x00]);
|
||
expect(probeImage(gif)).toEqual({ width: 300, height: 200, contentType: "image/gif", ext: "gif" });
|
||
const webp = new Uint8Array(30);
|
||
webp.set([...new TextEncoder().encode("RIFF")], 0);
|
||
webp.set([...new TextEncoder().encode("WEBPVP8X")], 8);
|
||
webp.set([0x2b, 0x01, 0x00, 0xc7, 0x00, 0x00], 24); // 299+1 × 199+1
|
||
expect(probeImage(webp)).toEqual({ width: 300, height: 200, contentType: "image/webp", ext: "webp" });
|
||
});
|
||
|
||
test("HTML error pages and empty bodies are not images", () => {
|
||
expect(probeImage(new TextEncoder().encode("<!doctype html><title>404</title>"))).toBeNull();
|
||
expect(probeImage(new Uint8Array())).toBeNull();
|
||
});
|
||
});
|
||
|
||
test.each([
|
||
[480, 623, "portrait", "volume"],
|
||
[720, 1230, "tall", "other"],
|
||
[510, 510, "square", "other"],
|
||
[240, 254, "square", "other"],
|
||
[1920, 321, "wide", "banner"],
|
||
[1200, 240, "wide", "banner"],
|
||
[1000, 700, "other", "other"],
|
||
] as const)("%i×%i is %s → %s", (w, h, shape, type) => {
|
||
expect(shapeOf(w, h)).toBe(shape);
|
||
expect(typeForShape(shapeOf(w, h))).toBe(type);
|
||
});
|
||
|
||
test("sha256Hex", async () => {
|
||
expect(await sha256Hex(new TextEncoder().encode("abc")))
|
||
.toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||
});
|
||
|
||
describe("readCapped", () => {
|
||
test("refuses a declared size over the cap", async () => {
|
||
const res = new Response("x", { headers: { "content-length": "999999" } });
|
||
await expect(readCapped(res, 1000)).rejects.toBeInstanceOf(ImageError);
|
||
});
|
||
test("refuses a stream that grows over the cap", async () => {
|
||
const stream = new ReadableStream({ start(c) { c.enqueue(new Uint8Array(600)); c.enqueue(new Uint8Array(600)); c.close(); } });
|
||
await expect(readCapped(new Response(stream), 1000)).rejects.toBeInstanceOf(ImageError);
|
||
});
|
||
test("returns the bytes under the cap", async () => {
|
||
expect((await readCapped(new Response(new Uint8Array(10)), 1000)).byteLength).toBe(10);
|
||
});
|
||
});
|
||
|
||
describe("inspectCandidate", () => {
|
||
const live = "https://swebtoon-phinf.pstatic.net/a/95.jpg";
|
||
const archived = "https://web.archive.org/web/20150312154616id_/http://swebtoon.phinf.naver.net/a/95.jpg";
|
||
const candidate: CoverCandidate = { url: live, role: "poster", current: false, seenAt: "20150312154616", archivedUrl: archived };
|
||
|
||
test("live image: measured, hashed, classified", async () => {
|
||
const poster = await fixtureBytes("images/poster-95.jpg");
|
||
const fake = new FakeTransport().on(live, () => bytes(poster));
|
||
const c = await inspectCandidate(candidate, fake);
|
||
expect(c).toMatchObject({ fetchedFrom: live, live: true, width: 480, height: 623, ext: "jpg", shape: "portrait", type: "volume" });
|
||
expect(c.sha256).toBe(await sha256Hex(poster));
|
||
});
|
||
|
||
test("falls back to the archived copy when the live URL is gone or not an image", async () => {
|
||
const poster = await fixtureBytes("images/poster-95.jpg");
|
||
for (const gone of [() => text("nope", 404), () => text("<html>image not found</html>")]) {
|
||
const fake = new FakeTransport().on(live, gone).on(archived, () => bytes(poster));
|
||
const c = await inspectCandidate(candidate, fake);
|
||
expect(c).toMatchObject({ fetchedFrom: archived, live: false });
|
||
}
|
||
});
|
||
|
||
test("throws ImageError naming every URL tried", async () => {
|
||
const fake = new FakeTransport().on(() => true, () => text("nope", 404));
|
||
await expect(inspectCandidate(candidate, fake)).rejects.toThrow(/95\.jpg.*404.*404/);
|
||
});
|
||
|
||
test("inspectAll drops failures with a warning and reports progress", async () => {
|
||
const poster = await fixtureBytes("images/poster-95.jpg");
|
||
const fake = new FakeTransport().on(live, () => bytes(poster)).on(() => true, () => text("nope", 404));
|
||
const warnings: string[] = [];
|
||
const progress: string[] = [];
|
||
const out = await inspectAll(
|
||
[candidate, { url: "https://swebtoon-phinf.pstatic.net/missing.jpg", role: "square", current: true }],
|
||
fake,
|
||
{ warn: (m) => warnings.push(m), onProgress: (d, t) => progress.push(`${d}/${t}`) },
|
||
);
|
||
expect(out).toHaveLength(1);
|
||
expect(warnings).toHaveLength(1);
|
||
expect(warnings[0]).toContain("missing.jpg");
|
||
expect(progress).toEqual(["1/2", "2/2"]);
|
||
});
|
||
});
|