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() 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 } }) 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('[data-testid="variants-filter"] button'), ).find((button) => textOf(button) === label)! } async function openSortMenu() { const sort = document.body.querySelector('[aria-label="Sort"]')! sort.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) sort.click() await flushPromises() return Array.from(document.body.querySelectorAll('[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('[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('[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=, 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('[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('[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) }) })