/** * FE-CHATIMG-001..006 — the pictures pinned to a chat message before it is sent. * * The reason this file exists: the hook used to create object URLs and enqueue * the preview state from inside a `setFiles` updater. React invokes an updater * an extra time in development, so one picked image produced two preview URLs * against one file, the composer strip drew two thumbnails for one attachment, * and removing one of them left a preview for a picture that would not be sent. * `renderHook` under StrictMode is what reproduces it. */ import { describe, it, expect, beforeEach, vi } from 'vitest' import { StrictMode } from 'react' import { act, renderHook } from '@testing-library/react' import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages' let nextUrl = 0 const revoked: string[] = [] beforeEach(() => { nextUrl = 0 revoked.length = 0 vi.stubGlobal('URL', { ...URL, createObjectURL: vi.fn(() => `blob:mock/${++nextUrl}`), revokeObjectURL: vi.fn((url: string) => { revoked.push(url) }), }) }) const img = (name: string, size = 1024) => new File([new Uint8Array(size)], name, { type: 'image/png' }) const strict = () => renderHook(() => useChatImages(), { wrapper: StrictMode }) describe('useChatImages', () => { it('FE-CHATIMG-001: one picked image is one file and one preview, in StrictMode', () => { const { result } = strict() act(() => { result.current.add([img('a.png')]) }) expect(result.current.files).toHaveLength(1) expect(result.current.previews).toHaveLength(1) expect(result.current.previews[0]).toBe('blob:mock/1') }) it('FE-CHATIMG-002: removing the only image empties both lists and revokes its URL', () => { const { result } = strict() act(() => { result.current.add([img('a.png')]) }) act(() => { result.current.remove(0) }) expect(result.current.files).toEqual([]) expect(result.current.previews).toEqual([]) expect(revoked).toEqual(['blob:mock/1']) }) it('FE-CHATIMG-003: files and previews stay in step across several adds', () => { const { result } = strict() act(() => { result.current.add([img('a.png')]) }) act(() => { result.current.add([img('b.png'), img('c.png')]) }) expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png']) expect(result.current.previews).toEqual(['blob:mock/1', 'blob:mock/2', 'blob:mock/3']) }) it('FE-CHATIMG-004: a rejected type is reported and nothing is pinned for it', () => { const { result } = strict() let ok = true act(() => { ok = result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' })]) }) expect(ok).toBe(false) expect(result.current.files).toEqual([]) expect(result.current.previews).toEqual([]) }) it('FE-CHATIMG-005: the cap holds and no URL is minted for what it turns away', () => { const { result } = strict() act(() => { result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`))) }) expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES) expect(result.current.previews).toHaveLength(MAX_CHAT_IMAGES) }) it('FE-CHATIMG-006: clear revokes every URL it handed out', () => { const { result } = strict() act(() => { result.current.add([img('a.png'), img('b.png')]) }) act(() => { result.current.clear() }) expect(result.current.files).toEqual([]) expect(revoked.sort()).toEqual(['blob:mock/1', 'blob:mock/2']) }) })