573 lines
18 KiB
TypeScript
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)
|
|
})
|
|
})
|