feat: image variants per image
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user