// @vitest-environment jsdom /** * What the AttachmentCard page claims, pinned: the file decides the shape (an * image with a picture is a thumbnail, anything else a row); a row with an href * opens in a new tab and a row without one is dimmed; the picture hands its * click to the app; download and remove are the app's too, and neither click * reaches what the card sits in; a pending card says what is happening to it; * and a file that could not be read is dashed. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AttachmentCard } from './AttachmentCard' afterEach(cleanup) const classesOf = (el: Element | null) => el?.getAttribute('class')?.split(' ').filter(Boolean) ?? [] const rootOf = (container: HTMLElement) => container.firstElementChild! describe('AttachmentCard', () => { it('a document is a row: its type, its name, and what it is', () => { const { container } = render() expect(classesOf(rootOf(container))).toContain('w-[240px]') expect(screen.getByText('report.pdf')).toBeTruthy() // 2,412,000 bytes is 2.3 MB by the 1024 steps Peek's own story shows. expect(screen.getByText('PDF · 2.3 MB')).toBeTruthy() }) it('a row with an href opens it in a new tab, and its click stays out of what the card sits in', async () => { const outside = vi.fn() render(
, ) const link = screen.getByRole('link') expect(link.getAttribute('href')).toBe('https://example.com/r') expect(link.getAttribute('target')).toBe('_blank') expect(link.getAttribute('rel')).toBe('noopener noreferrer') link.addEventListener('click', (event) => event.preventDefault()) await userEvent.click(link) expect(outside).not.toHaveBeenCalled() }) it('a row without an href is dimmed and opens nothing', () => { const { container } = render() expect(classesOf(rootOf(container))).toContain('opacity-70') expect(screen.queryByRole('link')).toBeNull() }) it('an image with a picture is a thumbnail, and its click is the app’s', async () => { const onOpen = vi.fn() const { container } = render() expect(classesOf(rootOf(container))).toContain('w-[180px]') await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' })) expect(onOpen).toHaveBeenCalledTimes(1) }) it('an image type wins over a name with no extension', () => { const { container } = render() expect(classesOf(rootOf(container))).toContain('w-[180px]') }) it('download shows only when the app can do it, and its click is the app’s alone', async () => { const onDownload = vi.fn() const outside = vi.fn() render() expect(screen.queryByRole('button', { name: 'Download report.pdf' })).toBeNull() cleanup() render(
, ) await userEvent.click(screen.getByRole('button', { name: 'Download report.pdf' })) expect(onDownload).toHaveBeenCalledTimes(1) expect(outside).not.toHaveBeenCalled() }) describe('the full words on hover, only when they are cut off', () => { // jsdom lays nothing out: a line is "cut off" here when its words are longer than 20 characters. beforeEach(() => { vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { return (this.textContent?.length ?? 0) * 7 }) vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(140) }) afterEach(() => vi.restoreAllMocks()) // Longer than the tooltip's 300ms wait, so "nothing" means nothing opened. const pastTheWait = () => new Promise((resolve) => setTimeout(resolve, 600)) it.each([ ['a document', (name: string) => ], ['a document without an address', (name: string) => ], ['an image', (name: string) => ], ['a file that could not be read', (name: string) => ], ['a pending card', (name: string) => ], ])('%s: a name that is cut off shows in full; one that fits shows nothing', async (_, card) => { const user = userEvent.setup() render(card('2026-Q3-billing-reconciliation-FINAL-v4.xlsx')) const long = screen.getByText(/^2026-Q3-billing-reconciliation-FINAL-v4/) expect(long.className).toContain('truncate') await user.hover(long) expect((await screen.findByRole('tooltip')).textContent).toBe(long.textContent) cleanup() render(card('notes')) await user.hover(screen.getByText(/^notes/)) await pastTheWait() expect(screen.queryByRole('tooltip')).toBeNull() }) it('the size never has one', async () => { const user = userEvent.setup() render() await user.hover(screen.getByText('242 KB')) await pastTheWait() expect(screen.queryByRole('tooltip')).toBeNull() }) it('a note’s hint is always on hover: it says more than the line', async () => { const user = userEvent.setup() render() await user.hover(screen.getByText('Not shared')) expect((await screen.findByRole('tooltip')).textContent).toBe('Only this app has a copy of this file.') }) }) it('an image on its way is a busy placeholder, named as a status', () => { const { container } = render() const root = rootOf(container) // A name needs a role to hang on: a plain div may not carry aria-label. expect(screen.getByRole('status', { name: 'Loading shot.png' })).toBe(root) expect(root.getAttribute('aria-busy')).toBe('true') expect(classesOf(root)).toContain('animate-pulse') }) it('a file that could not be read says so, with a dashed hairline', () => { const { container } = render() expect(classesOf(rootOf(container))).toContain('border-dashed') expect(screen.getByText('Could not be loaded')).toBeTruthy() }) it('pending: the size when ready, "Uploading…" while not, the reason when it failed, the warning when it is one', () => { render() expect(screen.getByText('242 KB')).toBeTruthy() cleanup() render() expect(screen.getByText('Uploading…')).toBeTruthy() cleanup() const failed = render() expect(screen.getByText("TIFF files aren't supported.").className).toContain('text-error-default') expect(classesOf(rootOf(failed.container))).toContain('border-error-default') cleanup() const warning = render() expect(screen.getByText('Not everyone can see this').className).toContain('text-warning-default') expect(classesOf(rootOf(warning.container))).toContain('border-warning-default') }) it('a pending card is removed by the app, and the click stays out of what it sits in', async () => { const onRemove = vi.fn() const outside = vi.fn() render(
, ) await userEvent.click(screen.getByRole('button', { name: 'Remove a.png' })) expect(onRemove).toHaveBeenCalledTimes(1) expect(outside).not.toHaveBeenCalled() }) it('the ✕ shows when the keyboard reaches it, not only on hover (C4)', async () => { render( {}} />) const remove = screen.getByRole('button', { name: 'Remove a.png' }) // Hidden at rest; each of these reveals it. jsdom draws nothing, so the classes are the contract. expect(remove.className).toContain('opacity-0') for (const reveal of ['group-hover:opacity-100', 'group-focus-within:opacity-100', 'focus-visible:opacity-100']) expect(remove.className).toContain(reveal) await userEvent.tab() expect(document.activeElement).toBe(remove) }) /* UIG-35: what the two apps were doing around the card, and it now does. */ it('a picture that needs permission: the card waits, then draws what the app fetched', async () => { const fetchImage = vi.fn().mockResolvedValue('blob:the-picture') render() // While it is in flight the card is the loading card, not a gap. expect(screen.getByRole('status', { name: 'Loading shot.png' })).toBeTruthy() const picture = await screen.findByAltText('shot.png') expect(picture.getAttribute('src')).toBe('blob:the-picture') expect(fetchImage).toHaveBeenCalledWith('relay://shot') }) it('a document that has to be fetched keeps its row, and opens once the bytes land', async () => { const fetchImage = vi.fn().mockResolvedValue('blob:the-file') const { container } = render() // Not the picture's loading card: the row is already saying what the file is. expect(screen.getByText('report.pdf')).toBeTruthy() expect(screen.queryByRole('status')).toBeNull() expect(classesOf(rootOf(container))).toContain('w-[240px]') expect(screen.queryByRole('link')).toBeNull() await vi.waitFor(() => expect(screen.getByRole('link').getAttribute('href')).toBe('blob:the-file')) }) it("the sender's words about a picture are its alt text, and name it in the viewer", async () => { render() expect(screen.getByAltText('the failing dialog')).toBeTruthy() // The file's name still sits under the thumbnail: the two say different things. expect(screen.getByText('shot.png')).toBeTruthy() await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' })) expect(screen.getByRole('dialog', { name: 'the failing dialog' })).toBeTruthy() }) it('a refused request is said, with the unreadable card', async () => { render( Promise.reject(new Error('relay_membership_required'))} />, ) expect(await screen.findByText('Could not be loaded')).toBeTruthy() }) it('a fetcher written inline does not re-fetch on every render', async () => { const calls = { n: 0 } // A new function identity on every render of the app around it, which is // what an inline `fetchImage={…}` is. Only the URL may start a fetch. const Harness = ({ tick }: { tick: number }) => ( { calls.n += 1 return Promise.resolve('blob:the-picture') }} /> ) const { rerender } = render() await screen.findByAltText('shot.png') rerender() rerender() expect(calls.n).toBe(1) }) it('pressing the picture opens it full screen, and Escape closes it', async () => { render() await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' })) const viewer = screen.getByRole('dialog', { name: 'shot.png' }) expect(viewer).toBeTruthy() await userEvent.keyboard('{Escape}') expect(screen.queryByRole('dialog')).toBeNull() }) it('an app that wants its own viewer still gets the click, and no viewer opens', async () => { const onOpen = vi.fn() render() await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' })) expect(onOpen).toHaveBeenCalledTimes(1) expect(screen.queryByRole('dialog')).toBeNull() }) it('opening a relay picture fetches the original, and shows the thumbnail until it lands', async () => { let landOriginal: (src: string) => void = () => {} const fetchImage = vi.fn((url: string) => url === 'relay://original' ? new Promise((resolve) => (landOriginal = resolve)) : Promise.resolve('blob:thumb'), ) render( , ) await screen.findByAltText('shot.png') await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' })) // The thumbnail stands in: a moment of a smaller picture beats a moment of an empty screen. expect(screen.getByRole('dialog').querySelector('img')?.getAttribute('src')).toBe('blob:thumb') landOriginal('blob:original') await vi.waitFor(() => expect(screen.getByRole('dialog').querySelector('img')?.getAttribute('src')).toBe('blob:original')) }) it('the download control saves the original, not the thumbnail on screen', async () => { const fetchImage = vi.fn().mockResolvedValue('blob:the-picture') render( , ) await screen.findByAltText('shot.png') fetchImage.mockClear() await userEvent.click(screen.getByRole('button', { name: 'Download shot.png' })) expect(fetchImage).toHaveBeenCalledWith('relay://original') }) })