// services/worker/src/pipeline.spec.ts —— runPipeline 单测(Task 20,真实 sharp 缓冲)。 // 图像 fixture 全部在测试内经 sharp 生成(无二进制入库): // · EXIF orientation 用 withMetadata({ orientation }) 构造(sharp 写入 EXIF 标签, // 像素不旋转——正好模拟相机旁路存储); // · HEIC 用例分两层:HEVC 压缩 HEIF 的编码能力视 sharp 预编译二进制而定 // (当前 win32 预构建 libvips 缺 hevc 编码器 → 跳过并留 console 备注,G2 真机 // iPhone 样本验收);AV1 压缩 HEIF 走同一 libheif 解码路径与 'heif' 格式桶 // (sharp metadata.format 同为 heif),可稳定覆盖流水线的 HEIF 解码分支。 import { randomBytes } from 'node:crypto'; import sharp from 'sharp'; import { PipelineError, runPipeline } from './pipeline'; /** 生成纯色 JPEG(可选 EXIF orientation)。 */ async function makeJpeg( width: number, height: number, orientation?: number, ): Promise { let image = sharp({ create: { width, height, channels: 3, background: { r: 200, g: 100, b: 50 } }, }); if (orientation !== undefined) { image = image.withMetadata({ orientation }); } return image.jpeg({ quality: 85 }).toBuffer(); } describe('runPipeline', () => { test('合法 JPEG:thumb ≤480w / browse ≤1920w,双 jpeg,EXIF 全剥离', async () => { const input = await makeJpeg(2400, 1600, 1); // 携带 EXIF(orientation 1) const out = await runPipeline(input); expect(out.width).toBe(2400); expect(out.height).toBe(1600); expect(Buffer.isBuffer(out.thumb)).toBe(true); expect(Buffer.isBuffer(out.browse)).toBe(true); const thumbMeta = await sharp(out.thumb).metadata(); expect(thumbMeta.format).toBe('jpeg'); expect(thumbMeta.width).toBe(480); expect(thumbMeta.height).toBe(320); const browseMeta = await sharp(out.browse).metadata(); expect(browseMeta.format).toBe('jpeg'); expect(browseMeta.width).toBe(1920); expect(browseMeta.height).toBe(1280); // EXIF 剥离(GPS 等全部元数据随 EXIF 段一并去除)。 expect(thumbMeta.orientation).toBeUndefined(); expect(thumbMeta.exif).toBeUndefined(); expect(browseMeta.orientation).toBeUndefined(); expect(browseMeta.exif).toBeUndefined(); }); test('orientation 6 JPEG:自动转正,宽高交换', async () => { // 旁路存储 400x300 + orientation 6(顺时针 90°)→ 显示 300x400。 const input = await makeJpeg(400, 300, 6); const out = await runPipeline(input); expect(out.width).toBe(300); expect(out.height).toBe(400); // 像素真的转了:thumb(≤480 不放大)即 300x400。 const thumbMeta = await sharp(out.thumb).metadata(); expect(thumbMeta.width).toBe(300); expect(thumbMeta.height).toBe(400); // 输出不再携带 orientation(已物理转正)。 expect(thumbMeta.orientation).toBeUndefined(); }); test('损坏缓冲(jpeg 魔数 + 随机字节)→ PipelineError(DECODE_FAILED)', async () => { const corrupt = Buffer.concat([ Buffer.from([0xff, 0xd8, 0xff, 0xe0]), randomBytes(8192), ]); const err = await runPipeline(corrupt).then( () => null, (e: unknown) => e, ); expect(err).toBeInstanceOf(PipelineError); expect((err as PipelineError).code).toBe('DECODE_FAILED'); }); test('PNG 输入可处理,输出仍为 jpeg', async () => { const input = await sharp({ create: { width: 640, height: 480, channels: 3, background: { r: 10, g: 20, b: 30 } }, }) .png() .toBuffer(); const out = await runPipeline(input); expect(out.width).toBe(640); expect(out.height).toBe(480); expect((await sharp(out.thumb).metadata()).format).toBe('jpeg'); expect((await sharp(out.browse).metadata()).format).toBe('jpeg'); }); test('小图不放大(withoutEnlargement):300px 原图 thumb/browse 均原尺寸', async () => { const input = await makeJpeg(300, 200); const out = await runPipeline(input); expect(out.thumbWidth).toBe(300); expect(out.thumbHeight).toBe(200); expect(out.browseWidth).toBe(300); expect(out.browseHeight).toBe(200); }); test('白名单外格式(gif)→ PipelineError(UNSUPPORTED_TYPE)', async () => { const gif = await sharp({ create: { width: 24, height: 24, channels: 3, background: { r: 1, g: 2, b: 3 } }, }) .gif() .toBuffer(); const err = await runPipeline(gif).then( () => null, (e: unknown) => e, ); expect(err).toBeInstanceOf(PipelineError); expect((err as PipelineError).code).toBe('UNSUPPORTED_TYPE'); }); test('HEIF(HVEC) 输入可解码出 jpeg 变体(缺 hevc 编码器则跳过,G2 真机验证)', async () => { let heic: Buffer; try { heic = await sharp({ create: { width: 300, height: 200, channels: 3, background: { r: 90, g: 120, b: 150 } }, }) .heif({ compression: 'hevc', quality: 50 }) .toBuffer(); } catch (err) { // 预编译 libvips 不含 hevc 编码器:HEVC 压缩 HEIF(iPhone 默认)的端到端 // 验证留给 G2 真机样本;解码能力由下方 AV1-HEIF 用例覆盖同一 libheif 路径。 console.warn( `[pipeline.spec] HEVC 编码不可用,跳过 HEVC 分支(G2 真机样本验证): ${ err instanceof Error ? err.message : String(err) }`, ); return; } const out = await runPipeline(heic); expect(out.width).toBe(300); expect(out.height).toBe(200); expect((await sharp(out.browse).metadata()).format).toBe('jpeg'); }); test('HEIF(AV1) 输入(同一 heif 解码路径/格式桶)产出 jpeg 变体', async () => { const avif = await sharp({ create: { width: 300, height: 200, channels: 3, background: { r: 90, g: 120, b: 150 } }, }) .avif({ quality: 50 }) .toBuffer(); const out = await runPipeline(avif); expect(out.width).toBe(300); expect(out.height).toBe(200); expect((await sharp(out.browse).metadata()).format).toBe('jpeg'); }); });