import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import router from '@/router' import { ApiError, NetworkError } from '@/composables/useApi' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { announcer, bulkBar, callsTo, cardFor, cards, checkboxFor, deferred, dialog, findButton, fixture, libraryHandlers, listBody, listCalls, mountPage, panel, press, pressOnWindow, selectAllCheckbox, settle, tab, tableHeaderCheckbox, textOf, unmountPage, } from './mediaTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() })) vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => toasts } }) const NAMES = ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg'] function select(...names: string[]) { for (const name of names) { checkboxFor(name).click() } return flushPromises() } function state(element: Element) { return element.getAttribute('data-state') } function selectedNames() { return cards() .filter((card) => card.hasAttribute('data-selected')) .map((card) => card.getAttribute('aria-label')!.split(',')[0]) } function checkboxWrapper(filename: string) { return checkboxFor(filename).closest('[data-testid="card-checkbox"]')! } beforeEach(() => { toasts.add.mockClear() toasts.remove.mockClear() toasts.update.mockClear() vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) }) afterEach(unmountPage) describe('Selecting images', () => { it('shows no checkbox until the pointer or keyboard focus reaches a card, then a 36 px one', async () => { await mountPage() const wrapper = checkboxWrapper('north-wall.jpg') expect(wrapper.classList.contains('opacity-100')).toBe(false) expect(wrapper.classList.contains('md:opacity-0')).toBe(true) expect(wrapper.className).toContain('md:group-hover/card:opacity-100') expect(wrapper.className).toContain('md:group-has-[:focus-visible]/card:opacity-100') expect(wrapper.className).toContain('md:size-9') expect(checkboxFor('north-wall.jpg').getAttribute('tabindex')).toBe('-1') }) it('ticking a checkbox selects the card, shows every checkbox and announces it', async () => { await mountPage() await select('north-wall.jpg') const card = cardFor('north-wall.jpg') expect(card.hasAttribute('data-selected')).toBe(true) expect(card.className).toContain('bg-secondary-200') expect(card.className).toContain('border-primary') expect(card.className).toContain('inset-ring-primary') expect(state(checkboxFor('north-wall.jpg'))).toBe('checked') expect(checkboxWrapper('kitchen-after.webp').classList.contains('opacity-100')).toBe(true) expect(announcer()).toBe('north-wall.jpg selected, 1 selected') expect(panel()).toBeNull() }) it('Space on the card toggles it and Enter still opens the detail panel', async () => { await mountPage() const card = cardFor('north-wall.jpg') card.focus() press(card, ' ') await flushPromises() expect(card.hasAttribute('data-selected')).toBe(true) expect(announcer()).toBe('north-wall.jpg selected, 1 selected') expect(panel()).toBeNull() press(card, ' ') await flushPromises() expect(card.hasAttribute('data-selected')).toBe(false) press(card, 'Enter') await flushPromises() expect(panel()).not.toBeNull() }) it('announces the new count when unselecting, and "Selection cleared" for the last one', async () => { await mountPage() await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg') await select('north-wall.jpg') expect(announcer()).toBe('north-wall.jpg unselected, 2 selected') await select('kitchen-after.webp') await select('window-frame.jpg') expect(announcer()).toBe('Selection cleared') expect(bulkBar()).toBeNull() }) it('shows a 44 px checkbox on every card below md, and tapping it does not open the panel', async () => { await mountPage({}, '/media', MOBILE_WIDTH) const wrapper = checkboxWrapper('north-wall.jpg') expect(wrapper.className).toContain('max-md:opacity-100') expect(wrapper.className).toContain('size-11') await select('north-wall.jpg') expect(cardFor('north-wall.jpg').hasAttribute('data-selected')).toBe(true) expect(panel()).toBeNull() cardFor('kitchen-after.webp').click() await flushPromises() expect(panel()).not.toBeNull() }) it('puts a 44 px checkbox on every row of the card list in list view', async () => { await mountPage({}, '/media?view=list', MOBILE_WIDTH) expect(document.body.querySelectorAll('[data-testid="media-card-row"]')).toHaveLength(12) expect(document.body.querySelectorAll('[role="checkbox"]')).toHaveLength(12) await select('north-wall.jpg') expect( document.body.querySelector('[data-testid="media-card-row"][data-selected]')!.className, ).toContain('bg-secondary-200') expect(document.body.querySelector('[data-testid="image-count"]')).toBeNull() }) }) describe('Select all and the partial state', () => { it('shows Select all with the image count, partial with some selected, then all, then none', async () => { await mountPage() const row = document.body.querySelector('[data-testid="image-count"]')!.parentElement! expect(textOf(row)).toBe('Select all 12 images') expect(state(selectAllCheckbox())).toBe('unchecked') await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg') expect(state(selectAllCheckbox())).toBe('indeterminate') selectAllCheckbox().click() await flushPromises() expect(state(selectAllCheckbox())).toBe('checked') expect(selectedNames()).toHaveLength(12) expect(textOf(bulkBar())).toContain('12 selected') selectAllCheckbox().click() await flushPromises() expect(state(selectAllCheckbox())).toBe('unchecked') expect(bulkBar()).toBeNull() expect(announcer()).toBe('Selection cleared') }) it('the table header checkbox does the same in list view at md width or more', async () => { await mountPage({}, '/media?view=list') expect(state(tableHeaderCheckbox())).toBe('unchecked') await select('north-wall.jpg') expect(state(tableHeaderCheckbox())).toBe('indeterminate') tableHeaderCheckbox().click() await flushPromises() expect(state(tableHeaderCheckbox())).toBe('checked') expect(textOf(bulkBar())).toContain('12 selected') tableHeaderCheckbox().click() await flushPromises() expect(bulkBar()).toBeNull() }) it('has no Select all row in the mobile card list', async () => { await mountPage({}, '/media?view=list', MOBILE_WIDTH) expect(document.body.textContent).not.toContain('Select all') await select('north-wall.jpg', 'kitchen-after.webp') expect(textOf(bulkBar())).toContain('2 selected') }) }) describe('Escape and Clear', () => { it('Escape and Clear empty the selection when the panel is closed', async () => { await mountPage() await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg') pressOnWindow('Escape') await flushPromises() expect(bulkBar()).toBeNull() expect(selectedNames()).toEqual([]) await select('north-wall.jpg', 'kitchen-after.webp') findButton('Clear', bulkBar()!)!.click() await flushPromises() expect(bulkBar()).toBeNull() expect(announcer()).toBe('Selection cleared') }) it('Escape closes only the panel while it is open over a selection', async () => { await mountPage() await select('north-wall.jpg', 'kitchen-after.webp') cardFor('window-frame.jpg').click() await flushPromises() expect(panel()).not.toBeNull() panel()!.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), ) await settle() expect(panel()).toBeNull() expect(selectedNames()).toEqual(['north-wall.jpg', 'kitchen-after.webp']) pressOnWindow('Escape') await flushPromises() expect(selectedNames()).toEqual([]) }) }) describe('The selection follows the query', () => { it('clears when the tab changes', async () => { await mountPage() await select('north-wall.jpg') tab('Unused').focus() press(tab('Unused'), 'Enter') await settle() expect(bulkBar()).toBeNull() }) it('clears when the sort changes and stays when the view changes', async () => { await mountPage() await select('north-wall.jpg', 'kitchen-after.webp') await router.replace({ query: { view: 'list' } }) await settle() expect(textOf(bulkBar())).toContain('2 selected') await router.replace({ query: { view: 'list', sort: 'oldest' } }) await settle() expect(bulkBar()).toBeNull() }) it('clears when the search text changes', async () => { await mountPage() await select('north-wall.jpg') await router.replace({ query: { q: 'wall' } }) await settle() expect(bulkBar()).toBeNull() }) }) describe('The bulk bar', () => { it('shows the count, a danger Delete and an inverted Clear, placed per breakpoint', async () => { await mountPage() await select(...NAMES) const bar = bulkBar()! expect(textOf(bar)).toContain('3 selected') expect(bar.className).toContain('bg-inverted') expect(bar.className).toContain('text-inverted') expect(bar.className).toContain('fixed') expect(bar.className).toContain('md:w-100') expect(bar.className).toContain('md:bottom-6') expect(bar.className).toContain('bottom-4') expect(bar.className).toContain('max-md:inset-x-4') const remove = findButton('Delete', bar)! expect(remove.className).toContain('bg-error') expect(findButton('Clear', bar)!.className).toContain('border-(--ui-text-inverted)') await vi.waitFor(() => expect(remove.querySelector('svg.iconify--lucide')).toBeTruthy()) }) it('replaces the mobile pinned Upload button', async () => { await mountPage({}, '/media', MOBILE_WIDTH) expect(findButton('Upload')).toBeTruthy() await select('north-wall.jpg') expect(findButton('Upload')).toBeUndefined() expect(textOf(bulkBar())).toContain('1 selected') }) }) describe('Bulk delete', () => { async function openBulkDialog(names = NAMES) { await select(...names) findButton('Delete', bulkBar()!)!.click() await flushPromises() } it('confirms with the summed article count and one row per image, without a usage request', async () => { await mountPage() await openBulkDialog() const box = dialog()! expect(textOf(box)).toContain('Delete 3 images?') expect(textOf(box)).toContain( "2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone.", ) const rows = Array.from(box.querySelectorAll('[data-testid="bulk-delete-row"]')).map(textOf) expect(rows).toEqual([ 'north-wall.jpg Used in 2', 'kitchen-after.webp Used in 3', 'window-frame.jpg Unused', ]) const buttons = Array.from(box.querySelectorAll('button')).map(textOf) expect(buttons).toEqual(['Cancel', 'Delete 3 images']) expect(callsTo('GET', /usage/)).toHaveLength(0) expect(callsTo('DELETE', /media/)).toHaveLength(0) }) it('uses the singular for one image', async () => { await mountPage() await openBulkDialog(['window-frame.jpg']) expect(textOf(dialog())).toContain('Delete 1 image?') expect(findButton('Delete 1 image', dialog()!)).toBeTruthy() }) it('sends one DELETE per image and locks the dialog while they are in flight', async () => { const pending = deferred() await mountPage({ remove: () => pending.promise }) await openBulkDialog() findButton('Delete 3 images', dialog()!)!.click() await flushPromises() expect(callsTo('DELETE', /^\/v1\/media\/(1|2|8)$/).map(([path]) => path)).toEqual([ '/v1/media/1', '/v1/media/2', '/v1/media/8', ]) expect(findButton('Cancel', dialog()!)!.disabled).toBe(true) const confirm = findButton('Deleting…', dialog()!)! expect(confirm.disabled).toBe(true) await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy()) dialog()!.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), ) await flushPromises() expect(dialog()).not.toBeNull() pending.resolve(undefined) await settle() }) it('done: the cards leave, the selection empties, focus moves on and a toast shows', async () => { let calls = 0 await mountPage({ list: () => ++calls === 1 ? listBody(fixture()) : listBody( fixture().filter((item) => ![1, 2, 8].includes(item.id)), { all: 9, unused: 1, duplicates: 2 }, ), }) await openBulkDialog() findButton('Delete 3 images', dialog()!)!.click() await settle() expect(dialog()).toBeNull() expect(cards()).toHaveLength(9) expect(bulkBar()).toBeNull() expect(document.activeElement).toBe(cardFor('kitchen-before.webp')) expect(toasts.add).toHaveBeenCalledWith( expect.objectContaining({ id: 'media-bulk-deleted', color: 'success', icon: 'i-lucide-circle-check', title: '3 images deleted', description: "They're gone from your library.", duration: 5000, }), ) expect(listCalls()).toHaveLength(2) expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength( 0, ) }) it('done with nothing left moves focus to the page heading', async () => { await mountPage(libraryHandlers(() => undefined, [1])) await openBulkDialog(['north-wall.jpg']) findButton('Delete 1 image', dialog()!)!.click() await settle() expect(document.activeElement).toBe( document.body.querySelector('[data-testid="media-heading"]'), ) }) it('partly failed: deleted cards leave, the failed one stays selected and an error toast offers Retry', async () => { await mountPage( libraryHandlers((id) => (id === 8 ? Promise.reject(new ApiError(500)) : undefined)), ) await openBulkDialog() findButton('Delete 3 images', dialog()!)!.click() await settle() expect(dialog()).toBeNull() expect(cardFor('north-wall.jpg')).toBeUndefined() expect(cardFor('kitchen-after.webp')).toBeUndefined() expect(selectedNames()).toEqual(['window-frame.jpg']) expect(textOf(bulkBar())).toContain('1 selected') const toast = toasts.add.mock.calls.at(-1)![0] expect(toast).toMatchObject({ id: 'media-bulk-delete-failed', color: 'error', icon: 'i-lucide-circle-x', title: "Couldn't delete 1 image", description: '2 images were deleted. window-frame.jpg is still in your library. Try again.', duration: 0, }) expect(toast.actions).toHaveLength(1) expect(toast.actions[0]).toMatchObject({ label: 'Retry', icon: 'i-lucide-rotate-cw' }) }) it('Retry sends only the failed DELETE with no dialog and replaces the toast on success', async () => { let eightAttempts = 0 await mountPage( libraryHandlers((id) => { if (id === 8 && ++eightAttempts === 1) { return Promise.reject(new NetworkError()) } return undefined }), ) await openBulkDialog() findButton('Delete 3 images', dialog()!)!.click() await settle() const failed = toasts.add.mock.calls.at(-1)![0] const deletesBefore = callsTo('DELETE', /media/).length failed.actions[0].onClick() await settle() expect(callsTo('DELETE', /media/).length).toBe(deletesBefore + 1) expect(callsTo('DELETE', /media\/8$/)).toHaveLength(2) expect(dialog()).toBeNull() expect(cardFor('window-frame.jpg')).toBeUndefined() expect(toasts.remove).toHaveBeenCalledWith('media-bulk-delete-failed') expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({ id: 'media-bulk-deleted', color: 'success', title: '3 images deleted', }) expect(bulkBar()).toBeNull() }) it('several failures name every file and pluralise the title', async () => { await mountPage({ remove: (id) => (id === 2 || id === 3 ? Promise.reject(new ApiError(500)) : undefined), }) await openBulkDialog([ 'north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp', 'window-frame.jpg', ]) findButton('Delete 4 images', dialog()!)!.click() await settle() expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({ title: "Couldn't delete 2 images", description: '2 images were deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.', }) }) it('an image that no longer exists counts as deleted', async () => { await mountPage( libraryHandlers((id) => (id === 2 ? Promise.reject(new ApiError(404)) : undefined)), ) await openBulkDialog() findButton('Delete 3 images', dialog()!)!.click() await settle() expect(cardFor('kitchen-after.webp')).toBeUndefined() expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({ id: 'media-bulk-deleted', title: '3 images deleted', }) }) it.each([ ['500', () => Promise.reject(new ApiError(500))], ['a network failure', () => Promise.reject(new NetworkError())], ])( 'every request failing with %s keeps the dialog open with an error alert', async (_name, remove) => { await mountPage({ remove }) await openBulkDialog() findButton('Delete 3 images', dialog()!)!.click() await settle() const box = dialog()! expect(textOf(box.querySelector('[role="alert"]'))).toBe( "Couldn't delete the images, so they're still there. Try again in a moment.", ) expect(findButton('Cancel', box)!.disabled).toBe(false) expect(findButton('Delete 3 images', box)!.disabled).toBe(false) expect(cards()).toHaveLength(12) expect(toasts.add).not.toHaveBeenCalled() }, ) it('disables Delete in the bulk bar while offline but keeps Clear working', async () => { await mountPage() await select('north-wall.jpg') vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) window.dispatchEvent(new Event('offline')) await flushPromises() expect(findButton('Delete', bulkBar()!)!.disabled).toBe(true) await select('kitchen-after.webp') expect(textOf(bulkBar())).toContain('2 selected') findButton('Clear', bulkBar()!)!.click() await flushPromises() expect(bulkBar()).toBeNull() vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) window.dispatchEvent(new Event('online')) await select('north-wall.jpg') expect(findButton('Delete', bulkBar()!)!.disabled).toBe(false) }) })