Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/http/client.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
83 changes: 83 additions & 0 deletions src/render/avatar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
17 changes: 17 additions & 0 deletions src/text/breakpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('です。ます')
Expand Down
Loading