442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { flushPromises } from '@vue/test-utils'
|
||
import UBadge from '@nuxt/ui/components/Badge.vue'
|
||
import router from '@/router'
|
||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||
import {
|
||
callsTo,
|
||
cardFor,
|
||
cards,
|
||
checkboxFor,
|
||
dialog,
|
||
findButton,
|
||
fixture,
|
||
kit,
|
||
lastListPath,
|
||
listBody,
|
||
listCalls,
|
||
mountPage,
|
||
panel,
|
||
settle,
|
||
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 }
|
||
})
|
||
|
||
function activate(element: Element) {
|
||
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
}
|
||
|
||
function badgesOf(filename: string) {
|
||
return Array.from(cardFor(filename).querySelectorAll('[data-kind]')).map(textOf)
|
||
}
|
||
|
||
function metaOf(filename: string) {
|
||
return textOf(cardFor(filename).querySelector('.text-xs\\/5'))
|
||
}
|
||
|
||
function segment(label: string) {
|
||
return Array.from(
|
||
document.body.querySelectorAll<HTMLElement>('[data-testid="variants-filter"] button'),
|
||
).find((button) => textOf(button) === label)!
|
||
}
|
||
|
||
async function openSortMenu() {
|
||
const sort = document.body.querySelector<HTMLElement>('[aria-label="Sort"]')!
|
||
sort.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||
sort.click()
|
||
await flushPromises()
|
||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitemcheckbox"]'))
|
||
}
|
||
|
||
async function openPanelFor(filename: string) {
|
||
cardFor(filename).click()
|
||
await flushPromises()
|
||
}
|
||
|
||
beforeEach(() => {
|
||
toasts.add.mockClear()
|
||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||
})
|
||
|
||
afterEach(unmountPage)
|
||
|
||
describe('The library badge', () => {
|
||
it('A card with generated variants carries the badge', async () => {
|
||
await mountPage()
|
||
|
||
expect(badgesOf('north-wall.jpg')).toEqual(['Used in 2', '4 variants'])
|
||
expect(badgesOf('kitchen-after.webp')).toEqual(['Used in 3', '5 variants'])
|
||
})
|
||
|
||
it.each([
|
||
['north-wall.jpg', ['Used in 2', '4 variants']],
|
||
['stone-wall-detail.jpg', ['Duplicate', 'Used in 1', '1 variant']],
|
||
['window-frame.jpg', ['Unused']],
|
||
['logo-renew.svg', ['Used in 5']],
|
||
['IMG_2208.jpg', ['Duplicate', 'Used in 2']],
|
||
])('The badge text and its absence: %s', async (filename, expected) => {
|
||
await mountPage()
|
||
|
||
expect(badgesOf(filename)).toEqual(expected)
|
||
})
|
||
|
||
it('draws the badge with the info fill and the layers icon', async () => {
|
||
await mountPage()
|
||
|
||
const badge = cardFor('north-wall.jpg').querySelector('[data-kind="variants"]')!
|
||
expect(badge.className).toContain('bg-(--rc-info-soft)')
|
||
expect(badge.className).toContain('text-info')
|
||
expect(badge.className).toContain('h-5')
|
||
const component = kit
|
||
.wrapper!.findAllComponents(UBadge)
|
||
.find((entry) => entry.element === badge)
|
||
expect(component?.props('icon')).toBe('i-lucide-layers')
|
||
})
|
||
|
||
it('The accessible name of a card is unchanged', async () => {
|
||
await mountPage()
|
||
|
||
expect(cardFor('north-wall.jpg').getAttribute('aria-label')).toBe(
|
||
'north-wall.jpg, 3.9 MB, used in 2 articles',
|
||
)
|
||
})
|
||
|
||
it('List view shows no variants badge', async () => {
|
||
await mountPage({}, '/media?view=list')
|
||
|
||
const rows = Array.from(document.body.querySelectorAll('[data-testid="media-row"]'))
|
||
expect(rows).toHaveLength(12)
|
||
expect(rows.map(textOf).join(' ')).not.toMatch(/\d variants?/)
|
||
expect(document.body.querySelector('[data-kind="variants"]')).toBeNull()
|
||
})
|
||
|
||
it('List view on mobile shows no variants badge', async () => {
|
||
await mountPage({}, '/media?view=list', MOBILE_WIDTH)
|
||
|
||
expect(document.body.querySelector('[data-kind="variants"]')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('The variants filter', () => {
|
||
it('sits between the search field and the sort menu with All selected', async () => {
|
||
await mountPage()
|
||
|
||
const control = document.body.querySelector<HTMLElement>('[data-testid="variants-filter"]')!
|
||
const toolbarOrder = Array.from(control.parentElement!.children).map(
|
||
(element) => element.getAttribute('data-testid') ?? element.tagName,
|
||
)
|
||
expect(toolbarOrder.indexOf('variants-filter')).toBeGreaterThan(
|
||
toolbarOrder.findIndex((id) => id === 'DIV' || id.includes('tabs')),
|
||
)
|
||
expect(Array.from(control.querySelectorAll('button')).map(textOf)).toEqual([
|
||
'All',
|
||
'With variants',
|
||
'Without',
|
||
])
|
||
expect(segment('All').getAttribute('aria-pressed')).toBe('true')
|
||
expect(segment('All').className).toContain('bg-primary')
|
||
expect(segment('With variants').getAttribute('aria-pressed')).toBe('false')
|
||
expect(control.className).toContain('border-accented')
|
||
})
|
||
|
||
it('On mobile the control is its own full-width row with 44 px segments', async () => {
|
||
await mountPage({}, '/media', MOBILE_WIDTH)
|
||
|
||
const control = document.body.querySelector<HTMLElement>('[data-testid="variants-filter"]')!
|
||
expect(control.className).toContain('w-full')
|
||
for (const button of control.querySelectorAll('button')) {
|
||
expect(button.className).toContain('h-10.5')
|
||
expect(button.className).toContain('flex-1')
|
||
expect(button.className).toContain('text-xs')
|
||
}
|
||
})
|
||
|
||
it.each([
|
||
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&page=1&size=25'],
|
||
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&page=1&size=25'],
|
||
])('Choosing %s writes the URL and requests the list', async (label, search, request) => {
|
||
await mountPage()
|
||
|
||
segment(label).click()
|
||
await flushPromises()
|
||
|
||
expect(router.currentRoute.value.fullPath).toBe(`/media${search}`)
|
||
expect(lastListPath()).toBe(`/v1/media/?${request}`)
|
||
})
|
||
|
||
it('shows the skeleton 150 ms after the request starts', async () => {
|
||
await mountPage()
|
||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||
const never = new Promise(() => undefined)
|
||
const { kit } = await import('./mediaTestKit')
|
||
const original = kit.requestMock.getMockImplementation()!
|
||
kit.requestMock.mockImplementation((path: string, options?: RequestInit) =>
|
||
path.includes('variants=with') ? never : original(path, options),
|
||
)
|
||
|
||
segment('With variants').click()
|
||
await flushPromises()
|
||
expect(document.body.querySelector('[data-testid="media-skeleton"]')).toBeNull()
|
||
vi.advanceTimersByTime(150)
|
||
await flushPromises()
|
||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||
})
|
||
|
||
it('All removes the parameter', async () => {
|
||
await mountPage({}, '/media?variants=with')
|
||
|
||
segment('All').click()
|
||
await flushPromises()
|
||
|
||
expect(router.currentRoute.value.fullPath).toBe('/media')
|
||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
|
||
})
|
||
|
||
it.each([
|
||
[
|
||
'?variants=with',
|
||
'filter=all&sort=newest&variants=with',
|
||
'With variants',
|
||
'/media?variants=with',
|
||
],
|
||
[
|
||
'?variants=without&filter=unused',
|
||
'filter=unused&sort=newest&variants=without',
|
||
'Without',
|
||
'/media?filter=unused&variants=without',
|
||
],
|
||
['?variants=all', 'filter=all&sort=newest', 'All', '/media'],
|
||
['?variants=zzz', 'filter=all&sort=newest', 'All', '/media'],
|
||
])('The query string is the source of truth: %s', async (search, request, selected, url) => {
|
||
await mountPage({}, `/media${search}`)
|
||
|
||
expect(lastListPath()).toBe(`/v1/media/?${request}&page=1&size=25`)
|
||
expect(segment(selected).getAttribute('aria-pressed')).toBe('true')
|
||
expect(router.currentRoute.value.fullPath).toBe(url)
|
||
})
|
||
|
||
it('With variants returns only the images that have cached files', async () => {
|
||
const ids = [1, 7, 9, 12, 5, 11, 2, 3, 10]
|
||
await mountPage(
|
||
{
|
||
list: () => listBody(ids.map((id) => fixture([id])[0]!)),
|
||
},
|
||
'/media?variants=with&sort=total_size',
|
||
)
|
||
|
||
expect(cards().map((card) => card.getAttribute('aria-label')!.split(',')[0])).toEqual([
|
||
'north-wall.jpg',
|
||
'IMG_4471.jpg',
|
||
'stone-wall-detail.jpg',
|
||
'floorboards.jpg',
|
||
'floorboards-copy.jpg',
|
||
'lime-plaster.jpg',
|
||
'kitchen-after.webp',
|
||
'kitchen-before.webp',
|
||
'bathroom-tiles.png',
|
||
])
|
||
expect(cards().every((card) => card.querySelector('[data-kind="variants"]'))).toBe(true)
|
||
})
|
||
|
||
it('Without variants returns the images with no cached file', async () => {
|
||
await mountPage(
|
||
{ list: () => listBody([4, 6, 8].map((id) => fixture([id])[0]!)) },
|
||
'/media?variants=without',
|
||
)
|
||
|
||
expect(cards()).toHaveLength(3)
|
||
expect(document.body.querySelector('[data-kind="variants"]')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('Sorting by total size', () => {
|
||
it('The new option is last in the sort menu and the selected one carries a check', async () => {
|
||
await mountPage()
|
||
|
||
const items = await openSortMenu()
|
||
|
||
expect(items.map(textOf)).toEqual([
|
||
'Newest',
|
||
'Oldest',
|
||
'Name',
|
||
'Largest',
|
||
'Largest total size (original + variants)',
|
||
])
|
||
expect(items[0]!.getAttribute('aria-checked')).toBe('true')
|
||
})
|
||
|
||
it('labels the option "Largest total size" on mobile', async () => {
|
||
await mountPage({}, '/media', MOBILE_WIDTH)
|
||
|
||
const items = await openSortMenu()
|
||
|
||
expect(textOf(items.at(-1))).toBe('Largest total size')
|
||
})
|
||
|
||
it('Choosing it sends sort=total_size and the button reads the long label', async () => {
|
||
await mountPage()
|
||
|
||
const items = await openSortMenu()
|
||
activate(items.at(-1)!)
|
||
await flushPromises()
|
||
|
||
expect(router.currentRoute.value.query).toEqual({ sort: 'total_size' })
|
||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=total_size&page=1&size=25')
|
||
expect(textOf(document.body.querySelector('[aria-label="Sort"]'))).toBe(
|
||
'Largest total size (original + variants)',
|
||
)
|
||
})
|
||
|
||
it.each([
|
||
['north-wall.jpg', '4.2 MB total · 3.9 MB + 260 KB'],
|
||
['kitchen-after.webp', '1.6 MB total · 1.2 MB + 410 KB'],
|
||
['window-frame.jpg', '1.8 MB total'],
|
||
['logo-renew.svg', '8 KB total'],
|
||
])('The meta line shows the total while sorted by total size: %s', async (filename, meta) => {
|
||
await mountPage({}, '/media?sort=total_size')
|
||
|
||
expect(metaOf(filename)).toBe(meta)
|
||
})
|
||
|
||
it('Other sorts keep the usual meta line', async () => {
|
||
await mountPage()
|
||
|
||
expect(metaOf('north-wall.jpg')).toBe('3.9 MB · 4032 × 3024')
|
||
})
|
||
|
||
it('A stale sort value is dropped', async () => {
|
||
await mountPage({}, '/media?sort=total')
|
||
|
||
expect(router.currentRoute.value.fullPath).toBe('/media')
|
||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
|
||
})
|
||
})
|
||
|
||
describe('Nothing to show with the variants filter', () => {
|
||
it('With variants and an empty cache', async () => {
|
||
await mountPage({ list: () => listBody([]) }, '/media?variants=with')
|
||
|
||
const content = textOf(document.body)
|
||
expect(content).toContain('No images with variants')
|
||
expect(content).toContain(
|
||
'Variants are generated when an image is requested with ?v=<name>, or from its Variants tab.',
|
||
)
|
||
expect(findButton('Show all images')).toBeDefined()
|
||
expect(document.body.querySelector('[data-testid="variants-filter"]')).not.toBeNull()
|
||
})
|
||
|
||
it('Show all images resets the variants filter', async () => {
|
||
const handlers = {
|
||
list: (path: string) =>
|
||
path.includes('variants=with') ? listBody([]) : listBody(fixture()),
|
||
}
|
||
await mountPage(handlers, '/media?variants=with')
|
||
|
||
findButton('Show all images')!.click()
|
||
await flushPromises()
|
||
|
||
expect(router.currentRoute.value.fullPath).toBe('/media')
|
||
expect(segment('All').getAttribute('aria-pressed')).toBe('true')
|
||
expect(cards()).toHaveLength(12)
|
||
})
|
||
})
|
||
|
||
describe('Delete dialogs name the generated variants they also remove', () => {
|
||
it('A single delete of an image with variants', async () => {
|
||
await mountPage({
|
||
usage: () => [{ id: 10, title: 'How we price a bathroom', published: true }],
|
||
})
|
||
await openPanelFor('north-wall.jpg')
|
||
const before = callsTo('GET', /\/v1\/media\//).length
|
||
|
||
findButton('Delete', panel()!)!.click()
|
||
await flushPromises()
|
||
|
||
const content = textOf(dialog())
|
||
expect(content).toContain("Delete an image that's in use?")
|
||
expect(content).toContain('Also removes 4 generated variants (260 KB)')
|
||
expect(callsTo('GET', /\/v1\/media\//).length).toBe(before)
|
||
})
|
||
|
||
it.each([
|
||
['stone-wall-detail.jpg', 'Also removes 1 generated variant (60 KB)'],
|
||
['window-frame.jpg', null],
|
||
])('The note is singular for one variant and absent for none: %s', async (filename, note) => {
|
||
await mountPage()
|
||
await openPanelFor(filename)
|
||
|
||
findButton('Delete', panel()!)!.click()
|
||
await flushPromises()
|
||
|
||
if (note) {
|
||
expect(textOf(dialog())).toContain(note)
|
||
} else {
|
||
expect(textOf(dialog())).not.toContain('Also removes')
|
||
}
|
||
})
|
||
|
||
it('Bulk delete sums the selection', async () => {
|
||
await mountPage()
|
||
for (const name of ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg']) {
|
||
checkboxFor(name).click()
|
||
}
|
||
await flushPromises()
|
||
|
||
findButton(
|
||
'Delete',
|
||
document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')!,
|
||
)!.click()
|
||
await flushPromises()
|
||
|
||
expect(textOf(dialog())).toContain('Also removes 9 generated variants (670 KB)')
|
||
expect(dialog()!.querySelectorAll('[data-testid="bulk-delete-row"]')).toHaveLength(3)
|
||
})
|
||
|
||
it('A bulk delete with no variant in the selection shows no note', async () => {
|
||
await mountPage()
|
||
for (const name of ['window-frame.jpg', 'IMG_2208.jpg']) {
|
||
checkboxFor(name).click()
|
||
}
|
||
await flushPromises()
|
||
|
||
findButton(
|
||
'Delete',
|
||
document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')!,
|
||
)!.click()
|
||
await flushPromises()
|
||
|
||
expect(textOf(dialog())).not.toContain('Also removes')
|
||
})
|
||
|
||
it('The delete flow itself is unchanged and the list is requested again', async () => {
|
||
await mountPage({ remove: () => undefined })
|
||
await openPanelFor('window-frame.jpg')
|
||
const before = listCalls().length
|
||
|
||
findButton('Delete', panel()!)!.click()
|
||
await flushPromises()
|
||
findButton('Delete image', dialog()!)!.click()
|
||
await settle()
|
||
|
||
expect(callsTo('DELETE', /\/v1\/media\/8$/)).toHaveLength(1)
|
||
expect(listCalls().length).toBeGreaterThan(before)
|
||
})
|
||
})
|