1
9
10import { describe, expect, it } from "vitest";
11import { inflateSync } from "node:zlib";
12import { createHash } from "node:crypto";
13
14import {
15 planThumbnails,
16 encodePng,
17 parsePngHeader,
18 parseJpegDimensions,
19 generatePngThumbnail,
20 nearestDownscale,
21 computeMetadata,
22 crc32,
23} from "./thumbnails.js";
24
25
26
27const WS = "wsp_1";
28const FID = "file_1";
29
30describe("planThumbnails (TASK-0102 §5: 160/480/1280 px, thumbs/ layout)", () => {
31 it("png image without known dims → all 3 sizes, same format", () => {
32 const plan = planThumbnails({ mime: "image/png", workspaceId: WS, fileId: FID, name: "cat.png" });
33 expect(plan.map((a) => a.key)).toEqual([
34 "thumbs/wsp_1/file_1/160/cat.png",
35 "thumbs/wsp_1/file_1/480/cat.png",
36 "thumbs/wsp_1/file_1/1280/cat.png",
37 ]);
38 expect(plan.map((a) => a.targetWidth)).toEqual([160, 480, 1280]);
39 expect(plan.map((a) => a.mime)).toEqual(["image/png", "image/png", "image/png"]);
40 expect(plan.map((a) => a.source)).toEqual(["image", "image", "image"]);
41 });
42
43 it("small image (50px) → no upscaling: empty plan", () => {
44 expect(
45 planThumbnails({ mime: "image/png", workspaceId: WS, fileId: FID, name: "c.png", sourceWidth: 50 }),
46 ).toEqual([]);
47 });
48
49 it("200px image → only 160 kept (480/1280 would upscale)", () => {
50 const plan = planThumbnails({
51 mime: "image/webp",
52 workspaceId: WS,
53 fileId: FID,
54 name: "c.webp",
55 sourceWidth: 200,
56 });
57 expect(plan.map((a) => a.key)).toEqual(["thumbs/wsp_1/file_1/160/c.webp"]);
58 });
59
60 it("source width exactly equal to a target → that size skipped (never upscale)", () => {
61 const plan = planThumbnails({
62 mime: "image/jpeg",
63 workspaceId: WS,
64 fileId: FID,
65 name: "c.jpg",
66 sourceWidth: 480,
67 });
68
69 expect(plan.map((a) => a.key)).toEqual(["thumbs/wsp_1/file_1/160/c.jpeg"]);
70 });
71
72 it("pdf → one first-page PNG at 1280", () => {
73 const plan = planThumbnails({ mime: "application/pdf", workspaceId: WS, fileId: FID, name: "doc.pdf" });
74 expect(plan).toEqual([
75 {
76 size: "1280",
77 key: "thumbs/wsp_1/file_1/1280/doc.png",
78 targetWidth: 1280,
79 mime: "image/png",
80 source: "pdf-page-1",
81 },
82 ]);
83 });
84
85 it("video → one first-frame PNG at 1280", () => {
86 const plan = planThumbnails({ mime: "video/mp4", workspaceId: WS, fileId: FID, name: "clip.mp4" });
87 expect(plan).toEqual([
88 {
89 size: "1280",
90 key: "thumbs/wsp_1/file_1/1280/clip.png",
91 targetWidth: 1280,
92 mime: "image/png",
93 source: "video-first-frame",
94 },
95 ]);
96 });
97
98 it("audio/documents/other → no thumbnails", () => {
99 expect(
100 planThumbnails({ mime: "audio/mpeg", workspaceId: WS, fileId: FID, name: "a.mp3" }),
101 ).toEqual([]);
102 expect(
103 planThumbnails({
104 mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
105 workspaceId: WS,
106 fileId: FID,
107 name: "d.docx",
108 }),
109 ).toEqual([]);
110 expect(planThumbnails({ mime: "text/plain", workspaceId: WS, fileId: FID, name: "t.txt" })).toEqual([]);
111 });
112
113 it("is deterministic (same input → same plan)", () => {
114 const a = { mime: "image/jpeg", workspaceId: WS, fileId: FID, name: "c.jpg" } as const;
115 expect(planThumbnails(a)).toEqual(planThumbnails(a));
116 });
117});
118
119
120
121
122function solidRGBA(w: number, h: number, r: number, g: number, b: number, a = 255): Uint8Array {
123 const buf = new Uint8Array(w * h * 4);
124 for (let i = 0; i < w * h; i++) {
125 buf[i * 4] = r;
126 buf[i * 4 + 1] = g;
127 buf[i * 4 + 2] = b;
128 buf[i * 4 + 3] = a;
129 }
130 return buf;
131}
132
133const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
134
135function expectPngMagic(buf: Uint8Array) {
136 for (let i = 0; i < 8; i++) expect(buf[i]).toBe(PNG_MAGIC[i]);
137}
138
139describe("encodePng — real, dependency-free PNG", () => {
140 it("produces a valid PNG magic + IHDR for 2x2", () => {
141 const png = encodePng(solidRGBA(2, 2, 255, 0, 0), 2, 2);
142 expectPngMagic(png);
143 const h = parsePngHeader(png);
144 expect(h).toEqual({ width: 2, height: 2, bitDepth: 8, colorType: 6 });
145 });
146
147 it("parsePngHeader round-trip: encode → parse → re-encode identical dims", () => {
148 const h1 = parsePngHeader(encodePng(solidRGBA(3, 5, 1, 2, 3), 3, 5));
149 expect(h1.width).toBe(3);
150 expect(h1.height).toBe(5);
151 const h2 = parsePngHeader(encodePng(solidRGBA(3, 5, 1, 2, 3), 3, 5));
152 expect(h2).toEqual(h1);
153 });
154
155 it("IDAT is real zlib data that inflates to raw scanlines", () => {
156 const png = encodePng(solidRGBA(2, 1, 9, 8, 7), 2, 1);
157
158 let off = 8;
159 const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
160 let idat: Uint8Array | null = null;
161 while (off < png.length) {
162 const len = view.getUint32(off);
163 const type = String.fromCharCode(png[off + 4]!, png[off + 5]!, png[off + 6]!, png[off + 7]!);
164 if (type === "IDAT") idat = png.subarray(off + 8, off + 8 + len);
165 off += 12 + len;
166 }
167 expect(idat).not.toBeNull();
168
169 const raw = inflateSync(Buffer.from(idat!));
170 expect(raw.length).toBe(9);
171 expect(raw[0]).toBe(0);
172 expect(Array.from(raw.subarray(1, 5))).toEqual([9, 8, 7, 255]);
173 });
174
175 it("every chunk's CRC32 verifies", () => {
176 const png = encodePng(solidRGBA(2, 2, 0, 255, 0), 2, 2);
177 const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
178 let off = 8;
179 let chunks = 0;
180 while (off < png.length) {
181 const len = view.getUint32(off);
182 const stored = view.getUint32(off + 8 + len);
183 const calc = crc32(png.subarray(off + 4, off + 8 + len));
184 expect(stored).toBe(calc);
185 chunks++;
186 off += 12 + len;
187 }
188 expect(chunks).toBe(3);
189 });
190});
191
192describe("nearest-neighbor downscale — pixel equality", () => {
193 it("2x2 → 1x1 picks the (1,1) source pixel", () => {
194
195 const src = new Uint8Array([
196 0,
197 0,
198 0,
199 255,
200 1,
201 0,
202 0,
203 255,
204 2,
205 0,
206 0,
207 255,
208 3,
209 0,
210 0,
211 255,
212 ]);
213 const dst = nearestDownscale(src, 2, 2, 1, 1);
214 expect(Array.from(dst)).toEqual([3, 0, 0, 255]);
215 });
216
217 it("4x4 → 2x2 picks columns/rows 1 and 3", () => {
218 const src = new Uint8Array(4 * 4 * 4);
219 for (let y = 0; y < 4; y++) {
220 for (let x = 0; x < 4; x++) {
221 const v = y * 4 + x;
222 src[(y * 4 + x) * 4] = v;
223 src[(y * 4 + x) * 4 + 3] = 255;
224 }
225 }
226 const dst = nearestDownscale(src, 4, 4, 2, 2);
227
228 expect(dst[0]).toBe(5);
229 expect(dst[4]).toBe(7);
230 expect(dst[8]).toBe(13);
231 expect(dst[12]).toBe(15);
232 });
233
234 it("generatePngThumbnail 2x2→1x1: valid PNG, 1x1 IHDR, expected pixel", () => {
235 const src = new Uint8Array([10, 0, 0, 255, 11, 0, 0, 255, 12, 0, 0, 255, 13, 0, 0, 255]);
236
237 const png = generatePngThumbnail(src, 2, 2, 96);
238 expect(parsePngHeader(png).width).toBe(2);
239
240 const png1 = generatePngThumbnail(src, 2, 2, 1);
241 expectPngMagic(png1);
242 const h = parsePngHeader(png1);
243 expect(h.width).toBe(1);
244 expect(h.height).toBe(1);
245
246 let off = 8;
247 const view = new DataView(png1.buffer, png1.byteOffset, png1.byteLength);
248 let idat: Uint8Array | null = null;
249 while (off < png1.length) {
250 const len = view.getUint32(off);
251 const type = String.fromCharCode(
252 png1[off + 4]!,
253 png1[off + 5]!,
254 png1[off + 6]!,
255 png1[off + 7]!,
256 );
257 if (type === "IDAT") idat = png1.subarray(off + 8, off + 8 + len);
258 off += 12 + len;
259 }
260 const raw = inflateSync(Buffer.from(idat!));
261 expect(raw[0]).toBe(0);
262 expect(Array.from(raw.subarray(1, 5))).toEqual([13, 0, 0, 255]);
263 });
264
265 it("generatePngThumbnail 4x4→2x2: valid PNG, 2x2 IHDR, expected pixels", () => {
266 const src = new Uint8Array(4 * 4 * 4);
267 for (let y = 0; y < 4; y++) {
268 for (let x = 0; x < 4; x++) {
269 const v = y * 4 + x;
270 src[(y * 4 + x) * 4] = v;
271 src[(y * 4 + x) * 4 + 3] = 255;
272 }
273 }
274 const png = generatePngThumbnail(src, 4, 4, 2);
275 expectPngMagic(png);
276 expect(parsePngHeader(png)).toMatchObject({ width: 2, height: 2 });
277 });
278
279 it("rejects invalid dimensions", () => {
280 expect(() => generatePngThumbnail(new Uint8Array(4), 0, 2, 1)).toThrow();
281 expect(() => generatePngThumbnail(new Uint8Array(4), 2, 2, 0)).toThrow();
282 });
283});
284
285describe("parsePngHeader", () => {
286 it("throws on non-PNG input", () => {
287 expect(() => parsePngHeader(new Uint8Array(32))).toThrow(/not a PNG/i);
288 expect(() => parsePngHeader(new TextEncoder().encode("%PDF-1.4…"))).toThrow(/not a PNG/i);
289 });
290});
291
292
293
294
295function makeJpeg(): Uint8Array {
296 const buf = new Uint8Array([
297 0xff,
298 0xd8,
299 0xff,
300 0xe0,
301 0x00,
302 0x10,
303 0x4a,
304 0x46,
305 0x49,
306 0x46,
307 0x00,
308 0x01,
309 0x01,
310 0x00,
311 0x00,
312 0x01,
313 0x00,
314 0x01,
315 0x00,
316 0x00,
317 0xff,
318 0xc0,
319 0x00,
320 0x11,
321 0x08,
322 0x00,
323 0xc8,
324 0x01,
325 0x00,
326 0x03,
327 0x01,
328 0x22,
329 0x00,
330 0x02,
331 0x11,
332 0x01,
333 0x03,
334 0x11,
335 0x01,
336 0xff,
337 0xd9,
338 ]);
339 return buf;
340}
341
342describe("parseJpegDimensions", () => {
343 it("finds SOF0 in a hand-built frame (256x200)", () => {
344 expect(parseJpegDimensions(makeJpeg())).toEqual({ width: 256, height: 200 });
345 });
346
347 it("returns null for non-JPEG / no SOF", () => {
348 expect(parseJpegDimensions(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
349 expect(
350 parseJpegDimensions(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0x00, 0x00])),
351 ).toBeNull();
352 });
353});
354
355
356
357describe("crc32", () => {
358 it("known vector: crc32('123456789') = 0xCBF43926", () => {
359 expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf43926);
360 });
361 it("crc32 of empty = 0", () => {
362 expect(crc32(new Uint8Array(0))).toBe(0);
363 });
364});
365
366
367
368describe("computeMetadata (metadata-extraction step over bytes)", () => {
369 it("on a real tiny 2x2 PNG: mime, isImage, dims, sha256", () => {
370 const png = encodePng(solidRGBA(2, 2, 42, 84, 126), 2, 2);
371 const meta = computeMetadata("image/png", png, "pic.png");
372 expect(meta.mimeType).toBe("image/png");
373 expect(meta.isImage).toBe(true);
374 expect(meta.width).toBe(2);
375 expect(meta.height).toBe(2);
376 expect(meta.sha256).toBe(createHash("sha256").update(png).digest("hex"));
377 expect(meta.isText).toBeUndefined();
378 });
379
380 it("on a JPEG: dims via SOF scanner", () => {
381 const meta = computeMetadata("image/jpeg", makeJpeg(), "photo.jpg");
382 expect(meta.mimeType).toBe("image/jpeg");
383 expect(meta.isImage).toBe(true);
384 expect(meta.width).toBe(256);
385 expect(meta.height).toBe(200);
386 });
387
388 it("on plain text: isText + no dims", () => {
389 const meta = computeMetadata(
390 "text/plain",
391 new TextEncoder().encode("just words ".repeat(50)),
392 "note.txt",
393 );
394 expect(meta.mimeType).toBe("text/plain");
395 expect(meta.isText).toBe(true);
396 expect(meta.width).toBeUndefined();
397 expect(meta.sha256).toHaveLength(64);
398 });
399
400 it("ignores a lying claimed mime (bytes win)", () => {
401 const png = encodePng(solidRGBA(1, 1, 1, 1, 1), 1, 1);
402 const meta = computeMetadata("application/zip", png, "x.png");
403 expect(meta.mimeType).toBe("image/png");
404 });
405});
406