/** * @factory/storage — thumbnails.test.ts (planning + the real PNG path). * * planThumbnails per mime; PNG encoder → valid magic + correct IHDR dims * (2x2→1x1, 4x4→2x2); parsePngHeader round-trip; nearest-neighbor pixel * equality; parseJpegDimensions on a hand-built SOF frame; crc32 vector; * computeMetadata on a real tiny PNG. */ import { describe, expect, it } from "vitest"; import { inflateSync } from "node:zlib"; import { createHash } from "node:crypto"; import { planThumbnails, encodePng, parsePngHeader, parseJpegDimensions, generatePngThumbnail, nearestDownscale, computeMetadata, crc32, } from "./thumbnails.js"; // ── planThumbnails ────────────────────────────────────────────────────────── const WS = "wsp_1"; const FID = "file_1"; describe("planThumbnails (TASK-0102 §5: 160/480/1280 px, thumbs/ layout)", () => { it("png image without known dims → all 3 sizes, same format", () => { const plan = planThumbnails({ mime: "image/png", workspaceId: WS, fileId: FID, name: "cat.png" }); expect(plan.map((a) => a.key)).toEqual([ "thumbs/wsp_1/file_1/160/cat.png", "thumbs/wsp_1/file_1/480/cat.png", "thumbs/wsp_1/file_1/1280/cat.png", ]); expect(plan.map((a) => a.targetWidth)).toEqual([160, 480, 1280]); expect(plan.map((a) => a.mime)).toEqual(["image/png", "image/png", "image/png"]); expect(plan.map((a) => a.source)).toEqual(["image", "image", "image"]); }); it("small image (50px) → no upscaling: empty plan", () => { expect( planThumbnails({ mime: "image/png", workspaceId: WS, fileId: FID, name: "c.png", sourceWidth: 50 }), ).toEqual([]); }); it("200px image → only 160 kept (480/1280 would upscale)", () => { const plan = planThumbnails({ mime: "image/webp", workspaceId: WS, fileId: FID, name: "c.webp", sourceWidth: 200, }); expect(plan.map((a) => a.key)).toEqual(["thumbs/wsp_1/file_1/160/c.webp"]); }); it("source width exactly equal to a target → that size skipped (never upscale)", () => { const plan = planThumbnails({ mime: "image/jpeg", workspaceId: WS, fileId: FID, name: "c.jpg", sourceWidth: 480, }); // jpeg → "jpeg" ext: c.jpg → c.jpeg (original extension REPLACED). expect(plan.map((a) => a.key)).toEqual(["thumbs/wsp_1/file_1/160/c.jpeg"]); }); it("pdf → one first-page PNG at 1280", () => { const plan = planThumbnails({ mime: "application/pdf", workspaceId: WS, fileId: FID, name: "doc.pdf" }); expect(plan).toEqual([ { size: "1280", key: "thumbs/wsp_1/file_1/1280/doc.png", targetWidth: 1280, mime: "image/png", source: "pdf-page-1", }, ]); }); it("video → one first-frame PNG at 1280", () => { const plan = planThumbnails({ mime: "video/mp4", workspaceId: WS, fileId: FID, name: "clip.mp4" }); expect(plan).toEqual([ { size: "1280", key: "thumbs/wsp_1/file_1/1280/clip.png", targetWidth: 1280, mime: "image/png", source: "video-first-frame", }, ]); }); it("audio/documents/other → no thumbnails", () => { expect( planThumbnails({ mime: "audio/mpeg", workspaceId: WS, fileId: FID, name: "a.mp3" }), ).toEqual([]); expect( planThumbnails({ mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", workspaceId: WS, fileId: FID, name: "d.docx", }), ).toEqual([]); expect(planThumbnails({ mime: "text/plain", workspaceId: WS, fileId: FID, name: "t.txt" })).toEqual([]); }); it("is deterministic (same input → same plan)", () => { const a = { mime: "image/jpeg", workspaceId: WS, fileId: FID, name: "c.jpg" } as const; expect(planThumbnails(a)).toEqual(planThumbnails(a)); }); }); // ── PNG encoder / decoder round-trip ──────────────────────────────────────── /** Solid-color RGBA buffer. */ function solidRGBA(w: number, h: number, r: number, g: number, b: number, a = 255): Uint8Array { const buf = new Uint8Array(w * h * 4); for (let i = 0; i < w * h; i++) { buf[i * 4] = r; buf[i * 4 + 1] = g; buf[i * 4 + 2] = b; buf[i * 4 + 3] = a; } return buf; } const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; function expectPngMagic(buf: Uint8Array) { for (let i = 0; i < 8; i++) expect(buf[i]).toBe(PNG_MAGIC[i]); } describe("encodePng — real, dependency-free PNG", () => { it("produces a valid PNG magic + IHDR for 2x2", () => { const png = encodePng(solidRGBA(2, 2, 255, 0, 0), 2, 2); expectPngMagic(png); const h = parsePngHeader(png); expect(h).toEqual({ width: 2, height: 2, bitDepth: 8, colorType: 6 }); }); it("parsePngHeader round-trip: encode → parse → re-encode identical dims", () => { const h1 = parsePngHeader(encodePng(solidRGBA(3, 5, 1, 2, 3), 3, 5)); expect(h1.width).toBe(3); expect(h1.height).toBe(5); const h2 = parsePngHeader(encodePng(solidRGBA(3, 5, 1, 2, 3), 3, 5)); expect(h2).toEqual(h1); }); it("IDAT is real zlib data that inflates to raw scanlines", () => { const png = encodePng(solidRGBA(2, 1, 9, 8, 7), 2, 1); // Find IDAT: chunks after 8-byte signature. Walk chunks by length. let off = 8; const view = new DataView(png.buffer, png.byteOffset, png.byteLength); let idat: Uint8Array | null = null; while (off < png.length) { const len = view.getUint32(off); const type = String.fromCharCode(png[off + 4]!, png[off + 5]!, png[off + 6]!, png[off + 7]!); if (type === "IDAT") idat = png.subarray(off + 8, off + 8 + len); off += 12 + len; } expect(idat).not.toBeNull(); // rowLen = 2*4 = 8; one row + 1 filter byte = 9 const raw = inflateSync(Buffer.from(idat!)); expect(raw.length).toBe(9); expect(raw[0]).toBe(0); // filter: none expect(Array.from(raw.subarray(1, 5))).toEqual([9, 8, 7, 255]); }); it("every chunk's CRC32 verifies", () => { const png = encodePng(solidRGBA(2, 2, 0, 255, 0), 2, 2); const view = new DataView(png.buffer, png.byteOffset, png.byteLength); let off = 8; let chunks = 0; while (off < png.length) { const len = view.getUint32(off); const stored = view.getUint32(off + 8 + len); const calc = crc32(png.subarray(off + 4, off + 8 + len)); expect(stored).toBe(calc); chunks++; off += 12 + len; } expect(chunks).toBe(3); // IHDR, IDAT, IEND }); }); describe("nearest-neighbor downscale — pixel equality", () => { it("2x2 → 1x1 picks the (1,1) source pixel", () => { // Pixel values = index (y*2+x): 0,1 / 2,3 → as R bytes. const src = new Uint8Array([ 0, 0, 0, 255, 1, 0, 0, 255, // row 0 2, 0, 0, 255, 3, 0, 0, 255, // row 1 ]); const dst = nearestDownscale(src, 2, 2, 1, 1); expect(Array.from(dst)).toEqual([3, 0, 0, 255]); }); it("4x4 → 2x2 picks columns/rows 1 and 3", () => { const src = new Uint8Array(4 * 4 * 4); for (let y = 0; y < 4; y++) { for (let x = 0; x < 4; x++) { const v = y * 4 + x; src[(y * 4 + x) * 4] = v; src[(y * 4 + x) * 4 + 3] = 255; } } const dst = nearestDownscale(src, 4, 4, 2, 2); // dst(x,y) = src(floor((x+.5)*2), floor((y+.5)*2)) → src cols/rows {1,3} expect(dst[0]).toBe(5); // src(1,1) expect(dst[4]).toBe(7); // src(3,1) expect(dst[8]).toBe(13); // src(1,3) expect(dst[12]).toBe(15); // src(3,3) }); it("generatePngThumbnail 2x2→1x1: valid PNG, 1x1 IHDR, expected pixel", () => { const src = new Uint8Array([10, 0, 0, 255, 11, 0, 0, 255, 12, 0, 0, 255, 13, 0, 0, 255]); // targetW ≥ srcW → scale capped at 1 → no upscale, 2x2 output. const png = generatePngThumbnail(src, 2, 2, 96); expect(parsePngHeader(png).width).toBe(2); // Force a real downscale: target 1 const png1 = generatePngThumbnail(src, 2, 2, 1); expectPngMagic(png1); const h = parsePngHeader(png1); expect(h.width).toBe(1); expect(h.height).toBe(1); // The single pixel must be src(1,1) = red 13. let off = 8; const view = new DataView(png1.buffer, png1.byteOffset, png1.byteLength); let idat: Uint8Array | null = null; while (off < png1.length) { const len = view.getUint32(off); const type = String.fromCharCode( png1[off + 4]!, png1[off + 5]!, png1[off + 6]!, png1[off + 7]!, ); if (type === "IDAT") idat = png1.subarray(off + 8, off + 8 + len); off += 12 + len; } const raw = inflateSync(Buffer.from(idat!)); expect(raw[0]).toBe(0); expect(Array.from(raw.subarray(1, 5))).toEqual([13, 0, 0, 255]); }); it("generatePngThumbnail 4x4→2x2: valid PNG, 2x2 IHDR, expected pixels", () => { const src = new Uint8Array(4 * 4 * 4); for (let y = 0; y < 4; y++) { for (let x = 0; x < 4; x++) { const v = y * 4 + x; src[(y * 4 + x) * 4] = v; src[(y * 4 + x) * 4 + 3] = 255; } } const png = generatePngThumbnail(src, 4, 4, 2); expectPngMagic(png); expect(parsePngHeader(png)).toMatchObject({ width: 2, height: 2 }); }); it("rejects invalid dimensions", () => { expect(() => generatePngThumbnail(new Uint8Array(4), 0, 2, 1)).toThrow(); expect(() => generatePngThumbnail(new Uint8Array(4), 2, 2, 0)).toThrow(); }); }); describe("parsePngHeader", () => { it("throws on non-PNG input", () => { expect(() => parsePngHeader(new Uint8Array(32))).toThrow(/not a PNG/i); expect(() => parsePngHeader(new TextEncoder().encode("%PDF-1.4…"))).toThrow(/not a PNG/i); }); }); // ── JPEG SOF scanner ──────────────────────────────────────────────────────── /** Hand-built JPEG: SOI + APP0 (JFIF) + SOF0 (200×256) + EOI. */ function makeJpeg(): Uint8Array { const buf = new Uint8Array([ 0xff, 0xd8, // SOI 0xff, 0xe0, 0x00, 0x10, // APP0, len 16 0x4a, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // 9 more (16-7) 0xff, 0xc0, 0x00, 0x11, // SOF0, len 17 0x08, // precision 8 0x00, 0xc8, // height 200 0x01, 0x00, // width 256 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, // 3 components 0xff, 0xd9, // EOI ]); return buf; } describe("parseJpegDimensions", () => { it("finds SOF0 in a hand-built frame (256x200)", () => { expect(parseJpegDimensions(makeJpeg())).toEqual({ width: 256, height: 200 }); }); it("returns null for non-JPEG / no SOF", () => { expect(parseJpegDimensions(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))).toBeNull(); expect( parseJpegDimensions(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0x00, 0x00])), ).toBeNull(); }); }); // ── crc32 ─────────────────────────────────────────────────────────────────── describe("crc32", () => { it("known vector: crc32('123456789') = 0xCBF43926", () => { expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf43926); }); it("crc32 of empty = 0", () => { expect(crc32(new Uint8Array(0))).toBe(0); }); }); // ── computeMetadata ───────────────────────────────────────────────────────── describe("computeMetadata (metadata-extraction step over bytes)", () => { it("on a real tiny 2x2 PNG: mime, isImage, dims, sha256", () => { const png = encodePng(solidRGBA(2, 2, 42, 84, 126), 2, 2); const meta = computeMetadata("image/png", png, "pic.png"); expect(meta.mimeType).toBe("image/png"); expect(meta.isImage).toBe(true); expect(meta.width).toBe(2); expect(meta.height).toBe(2); expect(meta.sha256).toBe(createHash("sha256").update(png).digest("hex")); expect(meta.isText).toBeUndefined(); }); it("on a JPEG: dims via SOF scanner", () => { const meta = computeMetadata("image/jpeg", makeJpeg(), "photo.jpg"); expect(meta.mimeType).toBe("image/jpeg"); expect(meta.isImage).toBe(true); expect(meta.width).toBe(256); expect(meta.height).toBe(200); }); it("on plain text: isText + no dims", () => { const meta = computeMetadata( "text/plain", new TextEncoder().encode("just words ".repeat(50)), "note.txt", ); expect(meta.mimeType).toBe("text/plain"); expect(meta.isText).toBe(true); expect(meta.width).toBeUndefined(); expect(meta.sha256).toHaveLength(64); }); it("ignores a lying claimed mime (bytes win)", () => { const png = encodePng(solidRGBA(1, 1, 1, 1, 1), 1, 1); const meta = computeMetadata("application/zip", png, "x.png"); expect(meta.mimeType).toBe("image/png"); }); });