From 1da182e67564fa0e6cb6ed73bab92aaf79f2344d Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 29 Aug 2026 21:10:32 +0900 Subject: [PATCH 1/3] test(http): cover retry, timeout and error handling in createClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.ts is the shared HTTP layer for avatars, emoji, API calls and update checks, but had no dedicated test file — the only place it was exercised was api/client.test.ts, and every case there pins retry: 0, so the retry loop, retryable-vs-non-retryable status codes and the timeout -> TimeoutError path had never actually run in the test suite. Covers: GET/POST success, HTTPError thrown by default vs. throwHttpErrors: false, retrying a retryable status until it succeeds, giving up once the retry budget runs out, not retrying a non-retryable status, TimeoutError on a hung request, and getBuffer. --- src/http/client.test.ts | 129 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/http/client.test.ts 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') + }) +}) From 15668a4e9e81fac822a76be21c7759294ee6acdf Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 29 Aug 2026 21:10:42 +0900 Subject: [PATCH 2/3] test(render): cover coverRect/containRect geometry and drawAvatar's shape clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coverRect and containRect are pure aspect-ratio math with no test of their own — only exercised indirectly through pipeline.test.ts's pixel-comparing render tests, which don't isolate wide-image-in-a-square-box vs. tall-image-in-a-square-box vs. matching-aspect-ratio cases. Added direct cases for all three, for both functions. drawAvatar's shape: 'circle' clip and shape: 'rectangle' no-clip path also had no test of their own; added one checking a box corner stays transparent under circle clipping and stays painted without it. --- src/render/avatar.test.ts | 83 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/render/avatar.test.ts b/src/render/avatar.test.ts index 16cda5f..4122a7d 100644 --- a/src/render/avatar.test.ts +++ b/src/render/avatar.test.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AvatarFetcher } from './avatar' -import { loadAvatar } from './avatar' +import { containRect, coverRect, drawAvatar, loadAvatar } from './avatar' import { avatarCacheInfo, clearAvatarCache, configureAvatarCache } from './avatarCache' import { createCanvas } from './canvasFactory' @@ -148,3 +148,84 @@ describe('loadAvatar', () => { expect(fetcher.calls).toHaveLength(2) }) }) + +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) + }) +}) From cfdb20ff51c8c09deddf11ea9c48b397c69b13fa Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 29 Aug 2026 21:10:51 +0900 Subject: [PATCH 3/3] test(text): cover Arabic (RTL) wrapping in findBreakpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every non-Latin case in breakpoint.test.ts was Japanese/Chinese; Arabic (one of the default script-fallback fonts, IBM Plex Sans Arabic) had no coverage at all. Added two cases: spaces break the same way they do for any other space-delimited script, and a single unspaced word gets no fallback break (Arabic uses spaces between words, unlike CJK, so this is expected, not a gap — Hangul's similar-looking but different case is tracked separately and intentionally not addressed here). --- src/text/breakpoint.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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('です。ます')