Files
RenewCMS/web/src/views/__tests__/MediaSelection.spec.ts
T
2026-10-01 01:36:35 +02:00

573 lines
18 KiB
TypeScript

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<typeof import('@/composables/useApi')>()
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<typeof import('@nuxt/ui/composables')>()
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<HTMLElement>('[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<undefined>()
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)
})
})