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,783 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import UButton from '@nuxt/ui/components/Button.vue'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
NORTH_WALL_BYTES,
|
||||
callsTo,
|
||||
cardFor,
|
||||
cards,
|
||||
deferred,
|
||||
findButton,
|
||||
kit,
|
||||
listBody,
|
||||
fixture,
|
||||
mountPage,
|
||||
panel,
|
||||
press,
|
||||
tab,
|
||||
tabs,
|
||||
textOf,
|
||||
unmountPage,
|
||||
variantItems,
|
||||
variantList,
|
||||
} from './mediaTestKit'
|
||||
import { savedPercent, signedPercent } from '@/lib/variants/variantSaving'
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
const PARTLY = ['card', 'hero', 'square', 'thumb']
|
||||
const SVG_LIST = { unsupported: 'svg', items: [] }
|
||||
const writeText = vi.fn()
|
||||
|
||||
function mountTab(...[handlers, url, width, options]: Parameters<typeof mountPage>) {
|
||||
return mountPage(handlers, url, width, { ...options, app: true })
|
||||
}
|
||||
|
||||
function toastWith(id: string) {
|
||||
return toasts.add.mock.calls.map(([content]) => content).find((content) => content.id === id)
|
||||
}
|
||||
|
||||
function lastToast() {
|
||||
return toasts.add.mock.calls.at(-1)![0]
|
||||
}
|
||||
|
||||
async function openVariants(filename = 'north-wall.jpg') {
|
||||
cardFor(filename).click()
|
||||
await flushPromises()
|
||||
activateTab('Variants')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function activateTab(label: string) {
|
||||
const target = tab(label)
|
||||
target.focus()
|
||||
press(target, 'Enter')
|
||||
}
|
||||
|
||||
function body() {
|
||||
return panel()!.querySelector<HTMLElement>('[data-testid="variants-tab"]')!
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return Array.from(body().querySelectorAll<HTMLElement>('[data-testid="variant-row"]'))
|
||||
}
|
||||
|
||||
function rowOf(name: string) {
|
||||
return rows().find((row) => row.getAttribute('data-variant') === name)!
|
||||
}
|
||||
|
||||
function rowAction(name: string, label: string) {
|
||||
return Array.from(rowOf(name).querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => (button.getAttribute('aria-label') ?? textOf(button)) === label,
|
||||
)
|
||||
}
|
||||
|
||||
function summary() {
|
||||
return textOf(
|
||||
Array.from(body().querySelectorAll('span')).find((span) =>
|
||||
/of \d+ generated/.test(span.textContent ?? ''),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function generateAllButton() {
|
||||
return findButton('Generate all', body()) ?? findButton('Generating…', body())
|
||||
}
|
||||
|
||||
function variantRequests(method = 'GET') {
|
||||
return callsTo(method, /\/v1\/media\/1\/variants$/)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('Saving percent', () => {
|
||||
it.each([
|
||||
[18432, '-99%'],
|
||||
[65536, '-98%'],
|
||||
[98304, '-98%'],
|
||||
[145408, '-96%'],
|
||||
[3250586, '-21%'],
|
||||
[4089446, '0%'],
|
||||
[4500000, '+10%'],
|
||||
])('Saving against the original for %i bytes reads %s', (size, text) => {
|
||||
expect(signedPercent(NORTH_WALL_BYTES, size)).toBe(text)
|
||||
})
|
||||
|
||||
it('never shows -100% for a non-empty file', () => {
|
||||
expect(savedPercent(NORTH_WALL_BYTES, 1)).toBe(-99)
|
||||
expect(savedPercent(NORTH_WALL_BYTES, 0)).toBe(-100)
|
||||
})
|
||||
|
||||
it('rounds half away from zero', () => {
|
||||
expect(savedPercent(200, 101)).toBe(-50)
|
||||
expect(savedPercent(200, 301)).toBe(51)
|
||||
expect(savedPercent(0, 10)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The panel tabs', () => {
|
||||
it('gives an owner two tabs under the header with the first selected', async () => {
|
||||
await mountTab({ variants: () => variantList() })
|
||||
cardFor('north-wall.jpg').click()
|
||||
await flushPromises()
|
||||
|
||||
const labels = tabs()
|
||||
.map(textOf)
|
||||
.filter((label) => label === 'Details' || label === 'Variants')
|
||||
expect(labels).toEqual(['Details', 'Variants'])
|
||||
expect(tab('Details').getAttribute('aria-selected')).toBe('true')
|
||||
expect(tab('Variants').className).toContain('h-10')
|
||||
expect(tab('Variants').className).toContain('max-md:h-11')
|
||||
})
|
||||
|
||||
it('gives an admin the tabs too', async () => {
|
||||
await mountTab({ variants: () => variantList() }, '/media', undefined, { role: 'admin' })
|
||||
cardFor('north-wall.jpg').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(tab('Variants')).toBeDefined()
|
||||
})
|
||||
|
||||
it('A session that is not owner or admin sees no tab and sends no request', async () => {
|
||||
await mountTab({}, '/media', undefined, { role: 'user' })
|
||||
cardFor('north-wall.jpg').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(tabs().filter((entry) => textOf(entry) === 'Variants')).toHaveLength(0)
|
||||
expect(textOf(panel())).toContain('Copy URL')
|
||||
expect(variantRequests()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('Selecting the tab requests the variants once per open panel', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
cardFor('north-wall.jpg').click()
|
||||
await flushPromises()
|
||||
expect(variantRequests()).toHaveLength(0)
|
||||
|
||||
activateTab('Variants')
|
||||
await flushPromises()
|
||||
activateTab('Details')
|
||||
await flushPromises()
|
||||
activateTab('Variants')
|
||||
await flushPromises()
|
||||
|
||||
expect(variantRequests()).toHaveLength(1)
|
||||
expect(textOf(panel())).toContain('Original · kept untouched')
|
||||
})
|
||||
|
||||
it('keeps the Details body unchanged', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
activateTab('Details')
|
||||
await flushPromises()
|
||||
|
||||
const details = panel()!.querySelector<HTMLElement>('dl')!
|
||||
expect(textOf(details)).toContain('JPEG')
|
||||
expect(details.closest<HTMLElement>('[style*="display: none"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading and failing', () => {
|
||||
it('draws nothing at 149 ms and five skeleton rows with aria-busy at 150 ms', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountTab({ variants: () => pending.promise })
|
||||
cardFor('north-wall.jpg').click()
|
||||
await flushPromises()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
activateTab('Variants')
|
||||
await flushPromises()
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(body().children).toHaveLength(1)
|
||||
expect(body().querySelector('[aria-busy="true"]')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
const region = body().querySelector<HTMLElement>('[aria-busy="true"]')!
|
||||
expect(region.getAttribute('aria-label')).toBe('Loading variants')
|
||||
expect(region.querySelectorAll('[data-testid="variant-skeleton-row"]')).toHaveLength(5)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['status 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('The variants request fails with %s and Try again sends it again', async (_label, fail) => {
|
||||
await mountTab({ variants: fail })
|
||||
await openVariants()
|
||||
|
||||
const alert = body().querySelector<HTMLElement>('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain("Couldn't load variants")
|
||||
expect(textOf(alert)).toContain('Check your connection and try again.')
|
||||
const retry = findButton('Try again', alert)!
|
||||
expect(retry).toBeDefined()
|
||||
|
||||
retry.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(variantRequests()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The list', () => {
|
||||
it('shows the Original block, the summary and the list when partly generated', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
const children = Array.from(body().children)
|
||||
expect(children[0]!.getAttribute('data-testid')).toBe('variants-original')
|
||||
expect(textOf(children[0])).toBe(
|
||||
'Original · kept untouched north-wall.jpg JPEG · 4032 × 3024 · 3.9 MB',
|
||||
)
|
||||
expect(summary()).toBe('4 of 5 generated')
|
||||
expect(findButton('Generate all', body())).toBeDefined()
|
||||
expect(rows().map((row) => row.getAttribute('data-variant'))).toEqual([
|
||||
'card',
|
||||
'hero',
|
||||
'og',
|
||||
'square',
|
||||
'thumb',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['thumb', '400×300 · WebP · 18 KB · -99% vs original'],
|
||||
['card', '800×600 · WebP · 64 KB · -98% vs original'],
|
||||
['hero', '1200×630 · WebP · 96 KB · -98% vs original'],
|
||||
['square', '600×600 · JPEG · 82 KB · -98% vs original'],
|
||||
])('A generated row: %s', async (name, meta) => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
const row = rowOf(name)
|
||||
expect(textOf(row)).toContain('Generated')
|
||||
expect(textOf(row)).toContain(meta)
|
||||
const saving = Array.from(row.querySelectorAll('span')).find((span) =>
|
||||
span.textContent?.endsWith('vs original'),
|
||||
)!
|
||||
expect(saving.className).toContain('text-success')
|
||||
expect(row.querySelector('.font-mono')!.className).toContain('font-medium')
|
||||
})
|
||||
|
||||
it('A not generated row', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
const row = rowOf('og')
|
||||
expect(textOf(row)).toContain('Not generated')
|
||||
expect(textOf(row)).toContain('1200×630 · JPEG')
|
||||
expect(textOf(row)).not.toContain('vs original')
|
||||
expect(textOf(row)).not.toContain('KB')
|
||||
})
|
||||
|
||||
it('Nothing generated yet', async () => {
|
||||
await mountTab({ variants: () => variantList([]) })
|
||||
await openVariants()
|
||||
|
||||
expect(textOf(body())).toContain('0 of 5 generated')
|
||||
expect(textOf(body())).toContain('Nothing generated yet.')
|
||||
expect(rows().every((row) => textOf(row).includes('Not generated'))).toBe(true)
|
||||
})
|
||||
|
||||
it('No variants defined', async () => {
|
||||
await mountTab({ variants: () => ({ unsupported: null, items: [] }) })
|
||||
await openVariants()
|
||||
|
||||
expect(textOf(body())).toContain('No variants defined')
|
||||
expect(textOf(body())).toContain(
|
||||
'Define a variant in Settings to get resized copies of this image.',
|
||||
)
|
||||
const link = body().querySelector<HTMLAnchorElement>('a')!
|
||||
expect(textOf(link)).toBe('Settings → Media → Variants')
|
||||
expect(link.getAttribute('href')).toBe('/settings/media/variants')
|
||||
expect(rows()).toHaveLength(0)
|
||||
expect(generateAllButton()).toBeUndefined()
|
||||
expect(body().querySelector('[data-testid="variants-original"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('An SVG source', async () => {
|
||||
await mountTab({ variants: () => SVG_LIST })
|
||||
cardFor('logo-renew.svg').click()
|
||||
await flushPromises()
|
||||
activateTab('Variants')
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(body().firstElementChild)).toContain('SVG · Vector · 8 KB')
|
||||
const alert = body().querySelector('[class*="bg-(--rc-info-soft)"], [data-slot="root"]')
|
||||
expect(textOf(body())).toContain("Variants aren't generated for this format.")
|
||||
expect(textOf(body())).toContain('SVG images are served as they are.')
|
||||
expect(alert).not.toBeNull()
|
||||
expect(rows()).toHaveLength(0)
|
||||
expect(generateAllButton()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Row actions', () => {
|
||||
it('shows icon buttons with accessible names on desktop', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
const generated = Array.from(rowOf('thumb').querySelectorAll('button')).map((button) =>
|
||||
button.getAttribute('aria-label'),
|
||||
)
|
||||
expect(generated).toEqual(['Copy URL', 'Preview', 'Purge'])
|
||||
const missing = Array.from(rowOf('og').querySelectorAll('button')).map((button) =>
|
||||
button.getAttribute('aria-label'),
|
||||
)
|
||||
expect(missing).toEqual(['Copy URL', 'Generate now'])
|
||||
expect(rowAction('thumb', 'Copy URL')!.className).toContain('size-7')
|
||||
})
|
||||
|
||||
it('shows labelled 44 px buttons on their own line below 640 px', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) }, '/media', MOBILE_WIDTH)
|
||||
await openVariants()
|
||||
|
||||
const buttons = Array.from(rowOf('thumb').querySelectorAll('button'))
|
||||
expect(buttons.map(textOf)).toEqual(['Copy URL', 'Preview', 'Purge'])
|
||||
expect(buttons.every((button) => button.className.includes('h-11'))).toBe(true)
|
||||
expect(textOf(rowAction('og', 'Generate now'))).toBe('Generate now')
|
||||
})
|
||||
|
||||
it('disables Generate all when everything is generated', async () => {
|
||||
await mountTab({ variants: () => variantList() })
|
||||
await openVariants()
|
||||
|
||||
expect(generateAllButton()!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows an icon and a word for every status', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
const icons = kit
|
||||
.wrapper!.findAllComponents({ name: 'Badge' })
|
||||
.filter((badge) => badge.element.hasAttribute('data-status'))
|
||||
.map((badge) => [badge.attributes('data-status'), badge.props('icon')])
|
||||
expect(icons).toContainEqual(['generated', 'i-lucide-circle-check'])
|
||||
expect(icons).toContainEqual(['missing', 'i-lucide-circle-dashed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Generating', () => {
|
||||
it('Generate now shows the row in flight and then generated with a toast', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generate: () => pending.promise,
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo('POST', /\/v1\/media\/1\/variants\/og$/)).toHaveLength(1)
|
||||
expect(textOf(rowOf('og'))).toContain('Generating')
|
||||
expect(rowOf('og').querySelectorAll('button')).toHaveLength(0)
|
||||
expect(rowOf('og').className).toContain('bg-elevated')
|
||||
expect(rowOf('hero').className).not.toContain('bg-elevated')
|
||||
expect(textOf(body())).toContain('Generating variants…')
|
||||
const all = generateAllButton()!
|
||||
expect(textOf(all)).toBe('Generating…')
|
||||
expect(all.disabled).toBe(true)
|
||||
|
||||
pending.resolve(variantItems().find((item) => item.name === 'og'))
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(rowOf('og'))).toContain('Generated')
|
||||
expect(summary()).toBe('5 of 5 generated')
|
||||
expect(toastWith('media-variant-generated-1-og')).toMatchObject({
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Generated og',
|
||||
})
|
||||
})
|
||||
|
||||
it('Generate all shows the rows in flight and toasts the count', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountTab({
|
||||
variants: () => variantList(['thumb']),
|
||||
generateAll: () => pending.promise,
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
generateAllButton()!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(variantRequests('POST')).toHaveLength(1)
|
||||
expect(
|
||||
rows()
|
||||
.filter((row) => textOf(row).includes('Generating'))
|
||||
.map((row) => row.getAttribute('data-variant')),
|
||||
).toEqual(['card', 'hero', 'og', 'square'])
|
||||
expect(textOf(rowOf('thumb'))).toContain('Generated')
|
||||
|
||||
pending.resolve({ generated: 4, failed: 0, failures: [], items: variantItems() })
|
||||
await flushPromises()
|
||||
|
||||
expect(rows().every((row) => textOf(row).includes('Generated'))).toBe(true)
|
||||
expect(toastWith('media-variants-generated-1')).toMatchObject({
|
||||
title: 'Generated 4 variants',
|
||||
})
|
||||
})
|
||||
|
||||
it('A single generated variant reads in the singular', async () => {
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generateAll: () => ({ generated: 1, failed: 0, failures: [], items: variantItems() }),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
generateAllButton()!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(toastWith('media-variants-generated-1')).toMatchObject({
|
||||
title: 'Generated 1 variant',
|
||||
})
|
||||
})
|
||||
|
||||
it('Generate all with a failure marks the row with its cause and Retry', async () => {
|
||||
const items = variantItems().map((item) =>
|
||||
item.name === 'hero' ? { ...item, generated: false, size: null } : item,
|
||||
)
|
||||
await mountTab({
|
||||
variants: () => variantList(['thumb']),
|
||||
generateAll: () => ({
|
||||
generated: 4,
|
||||
failed: 1,
|
||||
failures: [{ name: 'hero', cause: 'too_large' }],
|
||||
items,
|
||||
}),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
generateAllButton()!.click()
|
||||
await flushPromises()
|
||||
|
||||
const row = rowOf('hero')
|
||||
expect(textOf(row)).toContain('Failed')
|
||||
expect(textOf(row)).toContain('Image too large')
|
||||
const retry = findButton('Retry', row)!
|
||||
expect(retry.className).toContain('h-7')
|
||||
expect(retry.className).toContain('text-xs')
|
||||
expect(summary()).toBe('4 of 5 generated')
|
||||
expect(toastWith('media-variants-generated-1')).toMatchObject({
|
||||
title: 'Generated 4 variants',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[413, 'Image too large'],
|
||||
[415, 'Unsupported source format'],
|
||||
])('A single generation fails with %i', async (status, cause) => {
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generate: () => Promise.reject(new ApiError(status, undefined, 'plain text body')),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(rowOf('og'))).toContain('Failed')
|
||||
expect(textOf(rowOf('og'))).toContain(cause)
|
||||
expect(findButton('Retry', rowOf('og'))).toBeDefined()
|
||||
expect(lastToast()).toMatchObject({
|
||||
color: 'error',
|
||||
title: "Couldn't generate og.",
|
||||
description: `${cause}.`,
|
||||
})
|
||||
expect(textOf(document.body)).not.toContain('plain text body')
|
||||
})
|
||||
|
||||
it('does not remember a failed state when the panel is closed and opened again', async () => {
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generate: () => Promise.reject(new ApiError(413)),
|
||||
})
|
||||
await openVariants()
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
await flushPromises()
|
||||
expect(textOf(rowOf('og'))).toContain('Failed')
|
||||
|
||||
press(panel()!, 'Escape')
|
||||
document.body
|
||||
.querySelector<HTMLElement>('[role="dialog"] button[aria-label="Close"]')
|
||||
?.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
await flushPromises()
|
||||
await openVariants()
|
||||
|
||||
expect(textOf(rowOf('og'))).toContain('Not generated')
|
||||
})
|
||||
|
||||
it('Retry sends the same request and shows Generating', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
let calls = 0
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generate: () => (++calls === 1 ? Promise.reject(new ApiError(413)) : pending.promise),
|
||||
})
|
||||
await openVariants()
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
await flushPromises()
|
||||
|
||||
findButton('Retry', rowOf('og'))!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo('POST', /\/v1\/media\/1\/variants\/og$/)).toHaveLength(2)
|
||||
expect(textOf(rowOf('og'))).toContain('Generating')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500), 'Something went wrong'],
|
||||
['403', new ApiError(403), "You don't have access"],
|
||||
['network failure', new NetworkError(), 'Something went wrong'],
|
||||
])(
|
||||
'Other failures of a generation: %s returns the row to Not generated',
|
||||
async (_l, error, title) => {
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generate: () => Promise.reject(error),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(rowOf('og'))).toContain('Not generated')
|
||||
expect(lastToast()).toMatchObject({ color: 'error', title })
|
||||
},
|
||||
)
|
||||
|
||||
it('a 404 returns the row, toasts and requests the tab again', async () => {
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generate: () => Promise.reject(new ApiError(404)),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(rowOf('og'))).toContain('Not generated')
|
||||
expect(lastToast()).toMatchObject({
|
||||
title: 'Not found',
|
||||
description: 'This variant or image no longer exists. Refresh the page.',
|
||||
})
|
||||
expect(variantRequests()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('closes the panel and drops the image when the second request is a 404', async () => {
|
||||
let variantCalls = 0
|
||||
let imageGone = false
|
||||
await mountTab({
|
||||
variants: () => {
|
||||
if (++variantCalls === 1) {
|
||||
return variantList(PARTLY)
|
||||
}
|
||||
imageGone = true
|
||||
return Promise.reject(new ApiError(404))
|
||||
},
|
||||
generate: () => Promise.reject(new ApiError(404)),
|
||||
list: () => listBody(fixture().filter((item) => !imageGone || item.id !== 1)),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
await flushPromises()
|
||||
|
||||
expect(cards().some((card) => textOf(card).includes('north-wall.jpg'))).toBe(false)
|
||||
expect(toastWith('media-not-found-1')).toMatchObject({
|
||||
title: 'That image no longer exists',
|
||||
})
|
||||
})
|
||||
|
||||
it('announces the progress in a polite live region', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountTab({ variants: () => variantList(PARTLY), generate: () => pending.promise })
|
||||
await openVariants()
|
||||
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
await flushPromises()
|
||||
const region = body().querySelector<HTMLElement>('[aria-live="polite"]')!
|
||||
expect(textOf(region)).toBe('Generating variants')
|
||||
|
||||
pending.resolve(variantItems().find((item) => item.name === 'og'))
|
||||
await flushPromises()
|
||||
expect(textOf(region)).toBe('Generated og')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy URL, Purge and Preview', () => {
|
||||
it('copies the absolute variant url, also for a row that is not generated', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
rowAction('thumb', 'Copy URL')!.click()
|
||||
await flushPromises()
|
||||
rowAction('og', 'Copy URL')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${window.location.origin}/static/uploadedImages/1.jpg?v=thumb`,
|
||||
)
|
||||
expect(writeText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`${window.location.origin}/static/uploadedImages/1.jpg?v=og`,
|
||||
)
|
||||
expect(toastWith('media-variant-url-copied-1-thumb')).toMatchObject({
|
||||
title: 'URL copied',
|
||||
description: 'Paste it wherever you need the image address.',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no success toast when the clipboard rejects', async () => {
|
||||
writeText.mockRejectedValue(new Error('denied'))
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
rowAction('thumb', 'Copy URL')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(toastWith('media-variant-url-copied-1-thumb')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Purge removes the file, updates the row and the summary, and toasts', async () => {
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
purgeVariant: () => ({ purged_files: 1, purged_bytes: 18432 }),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
rowAction('thumb', 'Purge')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo('DELETE', /\/v1\/media\/1\/variants\/thumb$/)).toHaveLength(1)
|
||||
expect(textOf(rowOf('thumb'))).toContain('Not generated')
|
||||
expect(textOf(rowOf('thumb'))).not.toContain('KB')
|
||||
expect(summary()).toBe('3 of 5 generated')
|
||||
expect(toastWith('media-variants-purged-1-thumb')).toMatchObject({
|
||||
title: 'Purged 1 file (18 KB)',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[403, "You don't have access"],
|
||||
[404, 'Not found'],
|
||||
[500, 'Something went wrong'],
|
||||
])('Purge fails with %i and the row stays Generated', async (status, title) => {
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
purgeVariant: () => Promise.reject(new ApiError(status)),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
rowAction('thumb', 'Purge')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(rowOf('thumb'))).toContain('Generated')
|
||||
expect(lastToast()).toMatchObject({ color: 'error', title })
|
||||
})
|
||||
|
||||
it('Preview opens a dialog with both panes at actual size', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
rowAction('thumb', 'Preview')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const dialogs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
|
||||
const preview = dialogs.find((entry) => textOf(entry).includes('Preview: thumb'))!
|
||||
const content = textOf(preview)
|
||||
expect(content).toContain('thumb · 400×300 · WebP')
|
||||
expect(content).toContain('18 KB · saves 99%')
|
||||
expect(content).toContain('Original · 4032×3024 · JPEG')
|
||||
expect(content).toContain('3.9 MB')
|
||||
expect(content).toContain('Both images are shown at actual size. Scroll to pan.')
|
||||
expect(findButton('Close', preview)).toBeDefined()
|
||||
expect(preview.querySelector('[data-testid="preview-variant-pane"]')!.className).toContain(
|
||||
'sm:h-75',
|
||||
)
|
||||
expect(preview.querySelector('[data-testid="preview-variant-pane"]')!.className).toContain(
|
||||
'h-45',
|
||||
)
|
||||
const sources = Array.from(preview.querySelectorAll('img')).map((image) =>
|
||||
image.getAttribute('src'),
|
||||
)
|
||||
expect(sources).toEqual(['/static/uploadedImages/1.jpg?v=thumb', '/media/north-wall.jpg'])
|
||||
})
|
||||
|
||||
it('returns focus to the Preview button when the dialog closes', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
const opener = rowAction('thumb', 'Preview')!
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
const preview = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
|
||||
).find((entry) => textOf(entry).includes('Preview: thumb'))!
|
||||
|
||||
findButton('Close', preview)!.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('uses the sheet layout from the same markup below 640 px', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) }, '/media', MOBILE_WIDTH)
|
||||
await openVariants()
|
||||
|
||||
rowAction('thumb', 'Preview')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const preview = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
|
||||
).find((entry) => textOf(entry).includes('Preview: thumb'))!
|
||||
expect(preview.className).toContain('max-sm:!bottom-4')
|
||||
expect(findButton('Close', preview)!.className).toContain('h-11')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Vocabulary', () => {
|
||||
it('never says thumbnail preset, version or rendition', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
expect(textOf(body())).not.toMatch(/thumbnail preset|version|rendition/i)
|
||||
})
|
||||
|
||||
it('keeps the buttons buttons', async () => {
|
||||
await mountTab({ variants: () => variantList(PARTLY) })
|
||||
await openVariants()
|
||||
|
||||
expect(kit.wrapper!.findAllComponents(UButton).length).toBeGreaterThan(0)
|
||||
expect(router.currentRoute.value.path).toBe('/media')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user