diff --git a/src/http/client.test.ts b/src/http/client.test.ts new file mode 100644 index 0000000..430db71 --- /dev/null +++ b/src/http/client.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createClient, HTTPError, TimeoutError } from './client' + +/** A fetch stub that respects an AbortSignal, the way a real fetch would. */ +function hangingFetch(): typeof fetch { + return ((_input: unknown, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal + if (!signal) return + if (signal.aborted) { + reject(signal.reason) + return + } + signal.addEventListener('abort', () => reject(signal.reason)) + }) + }) as typeof fetch +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('createClient', () => { + it('GETs and returns a Response with the body intact', async () => { + vi.stubGlobal( + 'fetch', + async () => new Response('hello', { status: 200, headers: { 'content-type': 'text/plain' } }), + ) + const client = createClient() + + const response = await client.get('https://example.test/') + + expect(response.status).toBe(200) + expect(await response.text()).toBe('hello') + }) + + it('POSTs a JSON body', async () => { + let seenBody: string | undefined + vi.stubGlobal('fetch', async (_input: unknown, init?: RequestInit) => { + seenBody = init?.body as string | undefined + return new Response('', { status: 200 }) + }) + const client = createClient() + + await client.post('https://example.test/', { json: { a: 1 } }) + + expect(seenBody).toBe(JSON.stringify({ a: 1 })) + }) + + it('throws HTTPError for a non-2xx status by default', async () => { + vi.stubGlobal('fetch', async () => new Response('not found', { status: 404 })) + const client = createClient({ retry: 0 }) + + const error = await client.get('https://example.test/').catch((cause) => cause) + + expect(error).toBeInstanceOf(HTTPError) + expect(error.response.status).toBe(404) + expect(error.body).toBe('not found') + }) + + it('resolves with the response instead of throwing when throwHttpErrors is false', async () => { + vi.stubGlobal('fetch', async () => new Response('nope', { status: 404 })) + const client = createClient({ retry: 0 }) + + const response = await client.get('https://example.test/', { throwHttpErrors: false }) + + expect(response.status).toBe(404) + }) + + it('retries a retryable status code and returns the eventual success', async () => { + let calls = 0 + vi.stubGlobal('fetch', async () => { + calls++ + if (calls < 3) return new Response('', { status: 503 }) + return new Response('ok', { status: 200 }) + }) + const client = createClient({ retry: 2 }) + + const response = await client.get('https://example.test/') + + expect(calls).toBe(3) + expect(response.status).toBe(200) + }) + + it('stops retrying once the budget runs out and throws the last failure', async () => { + let calls = 0 + vi.stubGlobal('fetch', async () => { + calls++ + return new Response('', { status: 503 }) + }) + const client = createClient({ retry: 2 }) + + const error = await client.get('https://example.test/').catch((cause) => cause) + + // The first attempt plus 2 retries, then it gives up. + expect(calls).toBe(3) + expect(error).toBeInstanceOf(HTTPError) + expect(error.response.status).toBe(503) + }) + + it('does not retry a non-retryable status code', async () => { + let calls = 0 + vi.stubGlobal('fetch', async () => { + calls++ + return new Response('', { status: 404 }) + }) + const client = createClient({ retry: 2 }) + + await expect(client.get('https://example.test/')).rejects.toThrow(HTTPError) + expect(calls).toBe(1) + }) + + it('throws TimeoutError when the request exceeds its timeout', async () => { + vi.stubGlobal('fetch', hangingFetch()) + const client = createClient({ timeout: 20, retry: 0 }) + + await expect(client.get('https://example.test/')).rejects.toThrow(TimeoutError) + }) + + it('getBuffer reads the response body into a Buffer', async () => { + vi.stubGlobal('fetch', async () => new Response('hello', { status: 200 })) + const client = createClient() + + const buffer = await client.getBuffer('https://example.test/') + + expect(Buffer.isBuffer(buffer)).toBe(true) + expect(buffer.toString()).toBe('hello') + }) +}) diff --git a/src/render/avatar.test.ts b/src/render/avatar.test.ts index b93d0fe..5efc5b0 100644 --- a/src/render/avatar.test.ts +++ b/src/render/avatar.test.ts @@ -3,6 +3,8 @@ import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AvatarFetcher } from './avatar' import { + containRect, + coverRect, drawAvatar, loadAvatar, resetFilterDetectionForTests, @@ -154,6 +156,87 @@ describe('loadAvatar', () => { }) }) +describe('coverRect', () => { + it('crops a wide image down to match a square box', () => { + const rect = coverRect(200, 100, { x: 0, y: 0, width: 100, height: 100 }) + expect(rect).toEqual({ sx: 50, sy: 0, sw: 100, sh: 100 }) + }) + + it('crops a tall image down to match a square box', () => { + const rect = coverRect(100, 200, { x: 0, y: 0, width: 100, height: 100 }) + expect(rect).toEqual({ sx: 0, sy: 50, sw: 100, sh: 100 }) + }) + + it('uses the whole image when its aspect ratio already matches the box', () => { + const rect = coverRect(100, 100, { x: 0, y: 0, width: 50, height: 50 }) + expect(rect).toEqual({ sx: 0, sy: 0, sw: 100, sh: 100 }) + }) +}) + +describe('containRect', () => { + it('letterboxes a wide image top and bottom in a square box', () => { + const rect = containRect(200, 100, { x: 0, y: 0, width: 100, height: 100 }) + expect(rect).toEqual({ x: 0, y: 25, width: 100, height: 50 }) + }) + + it('letterboxes a tall image left and right in a square box', () => { + const rect = containRect(100, 200, { x: 0, y: 0, width: 100, height: 100 }) + expect(rect).toEqual({ x: 25, y: 0, width: 50, height: 100 }) + }) + + it('fits exactly, offset with the box, when the aspect ratio already matches', () => { + const rect = containRect(100, 100, { x: 10, y: 20, width: 50, height: 50 }) + expect(rect).toEqual({ x: 10, y: 20, width: 50, height: 50 }) + }) +}) + +describe('drawAvatar', () => { + it('clips to a circle when shape is circle, leaving the box corners untouched', async () => { + const canvas = createCanvas(10, 10) + const ctx = canvas.getContext('2d') + const image = await loadAvatar(redSquare()) + + drawAvatar(ctx, image, { + theme: { + grayscale: false, + position: 'left', + widthRatio: 1, + fit: 'cover', + shape: 'circle', + fallback: null, + }, + box: { x: 0, y: 0, width: 10, height: 10 }, + }) + + const corner = ctx.getImageData(0, 0, 1, 1).data + const center = ctx.getImageData(5, 5, 1, 1).data + + expect(corner[3]).toBe(0) + expect(center[3]).toBe(255) + }) + + it('does not clip when shape is rectangle', async () => { + const canvas = createCanvas(10, 10) + const ctx = canvas.getContext('2d') + const image = await loadAvatar(redSquare()) + + drawAvatar(ctx, image, { + theme: { + grayscale: false, + position: 'left', + widthRatio: 1, + fit: 'cover', + shape: 'rectangle', + fallback: null, + }, + box: { x: 0, y: 0, width: 10, height: 10 }, + }) + + const corner = ctx.getImageData(0, 0, 1, 1).data + expect(corner[3]).toBe(255) + }) +}) + describe('drawAvatar grayscale fallback (no ctx.filter support)', () => { afterEach(() => { resetFilterDetectionForTests() diff --git a/src/text/breakpoint.test.ts b/src/text/breakpoint.test.ts index 8bad8ed..087d563 100644 --- a/src/text/breakpoint.test.ts +++ b/src/text/breakpoint.test.ts @@ -78,6 +78,23 @@ describe('findBreakpoints', () => { } }) + describe('RTL scripts (Arabic)', () => { + it('breaks after a space, the same as any other space-delimited script', () => { + // "مرحبا بالعالم" — "hello world", two space-separated words. Arabic + // reads right to left, but findBreakpoints works on logical (storage) + // order, so this is exactly the space rule already covered for Latin. + const text = 'مرحبا بالعالم' + expect(phrasePoints(text)).toEqual([text.indexOf(' ') + 1]) + }) + + it('offers no fallback break inside a single unspaced word', () => { + // Arabic uses spaces between words, unlike CJK — so a single word + // with no space gets no break at all, the same as a Latin one. + const priorities = findBreakpoints('اختبار') + expect(priorities.every((p) => p === BreakPriority.none)).toBe(true) + }) + }) + describe('kinsoku', () => { it('does not let a full stop start a line', () => { const priorities = findBreakpoints('です。ます')