// @vitest-environment jsdom import { describe, it, expect } from 'vitest'; import type { ImageLoader } from './imageUtils'; import { base64ByteLength, compressToTarget, UndecodableImageError, resizeAvatarImage, compressGarmentImage, } from './imageUtils'; describe( 'base64ByteLength', () => { it( 'returns the decoded byte length of a data URL payload (no padding)', () => { // "AAAA" is 4 base64 chars -> 3 decoded bytes. expect( base64ByteLength( 'data:image/png;base64,AAAA' ) ).toBe( 3 ); } ); it( 'accounts for base64 padding', () => { expect( base64ByteLength( 'data:image/jpeg;base64,AA==' ) ).toBe( 1 ); expect( base64ByteLength( 'data:image/jpeg;base64,AAA=' ) ).toBe( 2 ); } ); } ); // A deterministic fake loader: `bytesFor` decides the encoded size so we can // drive the descent without a real canvas. The encoded dataUrl embeds the // chosen dimensions/quality so tests can assert what was produced. function fakeLoader( spec: { width: number; height: number; hasAlpha: boolean; bytesFor: ( w: number, h: number, format: 'jpeg' | 'png', q: number ) => number; } ): ImageLoader { return async () => ( { width: spec.width, height: spec.height, hasAlpha: spec.hasAlpha, encode: ( w, h, format, q ) => ( { dataUrl: `data:image/${ format };base64,${ w }x${ h }@${ q }`, bytes: spec.bytesFor( w, h, format, q ), } ), } ); } const OPTS = { maxWidth: 768, maxHeight: 1024, targetBytes: 1_500_000, startQuality: 0.85, minQuality: 0.6, qualityStep: 0.1, minDimension: 512, dimensionStep: 0.85, }; describe( 'compressToTarget', () => { it( 'caps dimensions and returns immediately when already under target (JPEG for opaque)', async () => { // 1536x768 opaque -> capRatio 0.5 -> 768x384; small bytes -> no descent. const loader = fakeLoader( { width: 1536, height: 768, hasAlpha: false, bytesFor: () => 1000 } ); const out = await compressToTarget( 'x', OPTS, loader ); expect( out ).toBe( 'data:image/jpeg;base64,768x384@0.85' ); } ); it( 'lowers JPEG quality until under target before shrinking dimensions', async () => { const loader = fakeLoader( { width: 1536, height: 768, hasAlpha: false, bytesFor: ( _w, _h, _f, q ) => ( q > 0.8 ? 2_000_000 : 1_000_000 ), } ); const out = await compressToTarget( 'x', OPTS, loader ); expect( out ).toBe( 'data:image/jpeg;base64,768x384@0.75' ); } ); it( 'shrinks dimensions when quality alone cannot reach the target', async () => { // bytes depend only on width; > 600px never fits, <= 600px fits. const loader = fakeLoader( { width: 1536, height: 768, hasAlpha: false, bytesFor: ( w ) => ( w > 600 ? 2_000_000 : 1_000_000 ), } ); const out = await compressToTarget( 'x', OPTS, loader ); // 768 -> *0.85=653 -> *0.85=555 (<=600) at first quality 0.85. expect( out ).toBe( 'data:image/jpeg;base64,555x277@0.85' ); } ); it( 'uses PNG and dimension-only descent for images with alpha', async () => { const loader = fakeLoader( { width: 1536, height: 768, hasAlpha: true, bytesFor: ( w ) => ( w > 600 ? 2_000_000 : 1_000_000 ), } ); const out = await compressToTarget( 'x', OPTS, loader ); expect( out ).toBe( 'data:image/png;base64,555x277@1' ); } ); it( 'never refuses for size: returns best-effort at the floor', async () => { const loader = fakeLoader( { width: 1536, height: 768, hasAlpha: false, bytesFor: () => 9_000_000 } ); const out = await compressToTarget( 'x', OPTS, loader ); // Floor: long edge clamped to minDimension 512 -> 512x256. expect( out ).toBe( 'data:image/jpeg;base64,512x256@0.6' ); } ); it( 'does not upscale a small source', async () => { const loader = fakeLoader( { width: 300, height: 300, hasAlpha: false, bytesFor: () => 1000 } ); const out = await compressToTarget( 'x', OPTS, loader ); expect( out ).toBe( 'data:image/jpeg;base64,300x300@0.85' ); } ); it( 'rejects with UndecodableImageError when the loader cannot decode', async () => { const loader: ImageLoader = async () => { throw new UndecodableImageError(); }; await expect( compressToTarget( 'x', OPTS, loader ) ).rejects.toBeInstanceOf( UndecodableImageError ); } ); } ); describe( 'wrappers apply per-image dimension caps', () => { const opaqueBig = ( ): ImageLoader => fakeLoader( { width: 4000, height: 4000, hasAlpha: false, bytesFor: () => 1000 } ); it( 'resizeAvatarImage caps within 768x1024', async () => { const out = await resizeAvatarImage( 'x', opaqueBig() ); expect( out ).toBe( 'data:image/jpeg;base64,768x768@0.85' ); } ); it( 'compressGarmentImage caps within 1024x1024', async () => { const out = await compressGarmentImage( 'x', opaqueBig() ); expect( out ).toBe( 'data:image/jpeg;base64,1024x1024@0.85' ); } ); } );