Files
RenewCMS/web/src/views/__tests__/MediaVariantsLibrary.spec.ts
T

442 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})