feat: image variants per image

This commit is contained in:
fsylvain committed 2026-10-08 19:57:51 +02:00
1 parent f567866161
commit 951fef18ba
92 files changed
+8695 -413

No files matched your search

@@ -0,0 +1,437 @@
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&limit=100'],
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&limit=100'],
])('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&limit=100')
})
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}&limit=100`)
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&limit=100')
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&limit=100')
})
})
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)
})
})