import { afterEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import { ApiError, NetworkError } from '@/composables/useApi' import { MOBILE_WIDTH } from './viewport' import { fixture } from '@/views/__tests__/mediaTestKit' import { altField, apiKit, click, clickTab, dialogText, findButton, installApi, listCalls, modalOpen, mountUploader, openUploader, pageBody, press, searchField, secondPage, selectedTab, selectTile, settle, skeletonTiles, textOf, tile, tiles, typeInto, type PickerRoutes, } from './pickerKit' vi.mock('@/lib/imageCrop', async (importOriginal) => { const actual = await importOriginal() return { ...actual, readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })), } }) vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) function deferred() { let resolve!: (value: T) => void const promise = new Promise((res) => { resolve = res }) return { promise, resolve } } let wrapper: ReturnType | null = null afterEach(() => { wrapper?.unmount() wrapper = null vi.useRealTimers() vi.unstubAllGlobals() document.body.innerHTML = '' }) async function openLibrary(routes: PickerRoutes = {}) { installApi(routes) wrapper = mountUploader() await openUploader(wrapper) } async function openWithFakeTimers(routes: PickerRoutes = {}, width?: number) { vi.useFakeTimers() installApi(routes) wrapper = mountUploader({}, width) ;(wrapper.vm as unknown as { open: boolean }).open = true await vi.advanceTimersByTimeAsync(0) } function summary() { return textOf(document.body.querySelector('[data-testid="picker-summary"]')) } async function searchFor(value: string) { await typeInto(searchField(), value) await new Promise((resolve) => setTimeout(resolve, 150)) await settle() } describe('The library has images, so Library opens', () => { it('selects Library with a disabled "Insert image" and draws skeletons only after 150 ms', async () => { const pending = deferred() await openWithFakeTimers({ list: () => pending.promise }) expect(textOf(selectedTab())).toBe('Library') expect(findButton('Cancel')?.disabled).toBe(false) expect(findButton('Insert image')?.disabled).toBe(true) expect(skeletonTiles()).toHaveLength(0) expect(tiles()).toHaveLength(0) await vi.advanceTimersByTimeAsync(150) expect(skeletonTiles()).toHaveLength(8) pending.resolve(pageBody()) await vi.advanceTimersByTimeAsync(0) expect(skeletonTiles()).toHaveLength(0) expect(tiles()).toHaveLength(12) }) it('draws 9 skeleton tiles on mobile', async () => { await openWithFakeTimers({ list: () => new Promise(() => undefined) }, MOBILE_WIDTH) await vi.advanceTimersByTimeAsync(150) expect(skeletonTiles()).toHaveLength(9) }) it('asks for the first page of 25 images and nothing else', async () => { await openLibrary() expect(listCalls()).toHaveLength(1) expect(listCalls()[0]!.toString()).toBe('page=1&size=25') }) }) describe('The library is empty, so Upload opens', () => { it('opens the Upload tab idle, and Library then shows the empty panel', async () => { await openLibrary({ list: () => pageBody([], null) }) expect(textOf(selectedTab())).toBe('Upload') expect(dialogText()).toContain('Drop an image here, or choose a file') await clickTab('Library') expect(dialogText()).toContain('Your library is empty') expect(dialogText()).toContain( 'Images you upload appear here, ready to use in any article.', ) expect(findButton('Insert image')?.disabled).toBe(true) }) it('switches to Upload from "Upload an image"', async () => { await openLibrary({ list: () => pageBody([], null) }) await clickTab('Library') await click(findButton('Upload an image')) expect(textOf(selectedTab())).toBe('Upload') }) }) describe('A dropped file opens Upload and sends no list request', () => { it('stays quiet until the Library tab is selected', async () => { installApi({ hash: () => new Promise(() => undefined) }) wrapper = mountUploader({ initialFile: new File(['x'], 'before.png', { type: 'image/png' }), }) await openUploader(wrapper) expect(textOf(selectedTab())).toBe('Upload') expect(listCalls()).toHaveLength(0) await clickTab('Library') expect(listCalls()).toHaveLength(1) }) }) describe('Library, nothing selected', () => { it('shows the line, the 12 tiles in order and the tags on the images in the article', async () => { await openLibrary() expect(summary()).toBe('Newest first · 48 images') expect(tiles().map((element) => element.getAttribute('data-image-id'))).toEqual( fixture().map((item) => String(item.id)), ) const tagged = tiles() .filter((element) => textOf(element).includes('In this article')) .map((element) => element.getAttribute('data-image-id')) expect(tagged).toEqual(['1', '4']) expect(textOf(tile('north-wall.jpg'))).toContain('north-wall.jpg') expect(findButton('Load more')?.getAttribute('aria-disabled')).toBeNull() expect(findButton('Insert image')?.disabled).toBe(true) }) }) describe('A search is active, so the count is not shown', () => { it('reads "Newest first"', async () => { await openLibrary({ list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()), }) await searchFor('wall') expect(summary()).toBe('Newest first') }) }) describe('Load more is hidden on the last page', () => { it('does not show "Load more" once every matching image is loaded', async () => { await openLibrary({ list: () => pageBody(fixture(), null) }) expect(findButton('Load more')).toBeUndefined() }) }) describe("A tile's accessible name", () => { it.each([ ['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles'], ['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article'], ['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused'], ])('names %s', async (filename, name) => { await openLibrary() expect(tile(filename).getAttribute('aria-label')).toBe(name) }) }) describe('A tile is selected by pointer or keyboard', () => { it.each([ ['clicks', async () => click(tile('kitchen-after.webp'))], [ 'presses Enter', async () => { press(tile('kitchen-after.webp'), 'Enter') await settle() }, ], [ 'presses Space', async () => { press(tile('kitchen-after.webp'), ' ') await settle() }, ], ])('selects the tile when the user %s on it', async (_name, act) => { await openLibrary() await act() expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true') expect( tile('kitchen-after.webp').querySelector('[data-testid="picker-check"]'), ).not.toBeNull() expect(findButton('Insert image')?.disabled).toBe(false) }) }) describe('The selection panel', () => { it('shows the thumbnail, the details and the alt text field', async () => { await openLibrary() await selectTile('kitchen-after.webp') const panel = textOf(document.body.querySelector('[data-testid="picker-panel"]')) expect(panel).toContain('kitchen-after.webp') expect(panel).toContain('2400 × 1600 · 1.2 MB') expect(panel).toContain('Alt text (optional)') expect(panel).toContain( "Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.", ) expect(altField()?.value).toBe('') expect(findButton('Load more')).toBeUndefined() }) it('reads "Vector · 8 KB" for an SVG', async () => { await openLibrary() await selectTile('logo-renew.svg') expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain( 'Vector · 8 KB', ) }) it('allows an image already in the article to be selected', async () => { await openLibrary() await selectTile('north-wall.jpg') expect(findButton('Insert image')?.disabled).toBe(false) }) }) describe('Selection is single', () => { it('selects only the last tile and empties the alt text', async () => { await openLibrary() await selectTile('kitchen-after.webp') await typeInto(altField()!, 'A kitchen') await selectTile('window-frame.jpg') expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false') expect(tile('window-frame.jpg').getAttribute('aria-pressed')).toBe('true') expect(altField()!.value).toBe('') }) it('keeps the tile selected, and its alt text, when it is activated again', async () => { await openLibrary() await selectTile('kitchen-after.webp') await typeInto(altField()!, 'A kitchen') await selectTile('kitchen-after.webp') expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true') expect(altField()!.value).toBe('A kitchen') }) }) describe('Insert', () => { function kitchenAfterAtStaticUrl() { return pageBody( fixture().map((item) => item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item, ), ) } it('emits the item and the alt text, then closes at once with no write request', async () => { await openLibrary({ list: kitchenAfterAtStaticUrl }) await selectTile('kitchen-after.webp') await typeInto(altField()!, 'A kitchen with new oak worktops and white tiles') await click(findButton('Insert image')) const [item, alt] = wrapper!.emitted('inserted')![0] as [ { id: number; url: string }, string, ] expect(item.id).toBe(2) expect(item.url).toBe('/static/uploadedImages/2.webp') expect(alt).toBe('A kitchen with new oak worktops and white tiles') expect(modalOpen()).toBe(false) expect(apiKit.request.mock.calls.every(([, options]) => !options?.method)).toBe(true) }) it('inserts an empty alt for a decorative image', async () => { await openLibrary() await selectTile('kitchen-after.webp') await click(findButton('Insert image')) expect(wrapper!.emitted('inserted')![0]![1]).toBe('') }) it.each([ ['activates "Cancel"', async () => click(findButton('Cancel'))], [ 'activates the x', async () => click(document.body.querySelector('[role="dialog"] button[aria-label="Close"]')), ], [ 'presses Escape', async () => { document.body .querySelector('[role="dialog"]')! .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await settle() }, ], ])('changes nothing when the user %s', async (_name, act) => { await openLibrary() await selectTile('kitchen-after.webp') await typeInto(altField()!, 'A kitchen') await act() expect(modalOpen()).toBe(false) expect(wrapper!.emitted('inserted')).toBeUndefined() expect(wrapper!.emitted('uploaded')).toBeUndefined() }) }) describe('Search', () => { it('searches after a 120 ms debounce, on page 1, and clears the selection', async () => { await openWithFakeTimers() tile('kitchen-after.webp').click() await vi.advanceTimersByTimeAsync(0) expect(findButton('Insert image')?.disabled).toBe(false) await typeInto(searchField(), 'wall') await vi.advanceTimersByTimeAsync(119) expect(listCalls()).toHaveLength(1) await vi.advanceTimersByTimeAsync(1) expect(listCalls()).toHaveLength(2) expect(listCalls()[1]!.get('q')).toBe('wall') expect(listCalls()[1]!.get('size')).toBe('25') expect(listCalls()[1]!.get('page')).toBe('1') expect(findButton('Insert image')?.disabled).toBe(true) }) it('draws skeleton tiles while a search loads, after 150 ms', async () => { await openWithFakeTimers({ list: (query) => (query.get('q') ? new Promise(() => undefined) : pageBody()), }) await typeInto(searchField(), 'wall') await vi.advanceTimersByTimeAsync(120) expect(skeletonTiles()).toHaveLength(0) await vi.advanceTimersByTimeAsync(150) expect(skeletonTiles()).toHaveLength(8) }) it('clears the search from the x in the field', async () => { await openLibrary({ list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()), }) await searchFor('wall') await click(document.body.querySelector('button[aria-label="Clear search"]')) expect(searchField().value).toBe('') const last = listCalls().at(-1)! expect(last.has('q')).toBe(false) expect(last.get('size')).toBe('25') }) it('shows the no results panel and "Clear search" empties the field', async () => { await openLibrary({ list: (query) => (query.get('q') ? pageBody([], null) : pageBody()), }) await searchFor('wall') expect(dialogText()).toContain('No images match "wall"') expect(dialogText()).toContain( 'Check the spelling, or search for a shorter part of the filename.', ) expect(searchField().value).toBe('wall') expect(findButton('Insert image')?.disabled).toBe(true) await click(findButton('Clear search')) expect(searchField().value).toBe('') expect(listCalls().at(-1)!.has('q')).toBe(false) expect(tiles()).toHaveLength(12) }) }) describe('Load more', () => { it('shows progress, appends the new tiles and moves focus to the first one', async () => { const more = deferred() await openWithFakeTimers({ list: (query) => (query.get('page') === '2' ? more.promise : pageBody()), }) const button = findButton('Load more')! button.focus() button.click() await vi.advanceTimersByTimeAsync(0) const last = listCalls().at(-1)! expect(last.get('page')).toBe('2') expect(last.get('size')).toBe('25') const loading = findButton('Loading…')! expect(loading.getAttribute('aria-disabled')).toBe('true') expect(document.activeElement).toBe(loading) await vi.advanceTimersByTimeAsync(150) expect(skeletonTiles()).toHaveLength(4) more.resolve(pageBody(secondPage(), null)) await vi.advanceTimersByTimeAsync(0) await flushPromises() expect(tiles()).toHaveLength(24) expect(skeletonTiles()).toHaveLength(0) expect(document.activeElement).toBe(tiles()[12]) }) it('draws a row of 3 skeleton tiles on mobile', async () => { await openWithFakeTimers( { list: (query) => query.get('page') === '2' ? new Promise(() => undefined) : pageBody(), }, MOBILE_WIDTH, ) findButton('Load more')!.click() await vi.advanceTimersByTimeAsync(150) expect(skeletonTiles()).toHaveLength(3) }) it('combines the next page with the search', async () => { await openLibrary({ list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 48), }) await searchFor('wall') await click(findButton('Load more')) const last = listCalls().at(-1)! expect(last.get('q')).toBe('wall') expect(last.get('size')).toBe('25') expect(last.get('page')).toBe('2') }) }) describe('The library fails to load', () => { it.each([ ['a 500 response', () => Promise.reject(new ApiError(500))], ['a request that never reaches the server', () => Promise.reject(new NetworkError())], ])('shows the alert for %s and retries', async (_name, failure) => { let failing = true await openLibrary({ list: () => (failing ? failure() : pageBody()) }) expect(dialogText()).toContain("Couldn't load your library") expect(dialogText()).toContain('Check your connection and try again.') expect(findButton('Insert image')?.disabled).toBe(true) failing = false await click(findButton('Try again')) expect(listCalls()).toHaveLength(2) expect(tiles()).toHaveLength(12) expect(dialogText()).not.toContain("Couldn't load your library") }) })