Files
RenewCMS/web/src/components/__tests__/ImageUploaderLibrary.spec.ts
T

526 lines
15 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, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import { MOBILE_WIDTH } from './viewport'
import { fixture } from '@/views/__tests__/mediaTestKit'
import {
altField,
apiKit,
click,
clickTab,
dialogText,
findButton,
installApi,
listCalls,
modalOpen,
mountUploader,
openUploader,
pageBody,
press,
searchField,
secondPage,
selectedTab,
selectTile,
settle,
skeletonTiles,
textOf,
tile,
tiles,
typeInto,
type PickerRoutes,
} from './pickerKit'
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
}
})
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
let wrapper: ReturnType<typeof mountUploader> | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
async function openLibrary(routes: PickerRoutes = {}) {
installApi(routes)
wrapper = mountUploader()
await openUploader(wrapper)
}
async function openWithFakeTimers(routes: PickerRoutes = {}, width?: number) {
vi.useFakeTimers()
installApi(routes)
wrapper = mountUploader({}, width)
;(wrapper.vm as unknown as { open: boolean }).open = true
await vi.advanceTimersByTimeAsync(0)
}
function summary() {
return textOf(document.body.querySelector('[data-testid="picker-summary"]'))
}
async function searchFor(value: string) {
await typeInto(searchField(), value)
await new Promise((resolve) => setTimeout(resolve, 150))
await settle()
}
describe('The library has images, so Library opens', () => {
it('selects Library with a disabled "Insert image" and draws skeletons only after 150 ms', async () => {
const pending = deferred<unknown>()
await openWithFakeTimers({ list: () => pending.promise })
expect(textOf(selectedTab())).toBe('Library')
expect(findButton('Cancel')?.disabled).toBe(false)
expect(findButton('Insert image')?.disabled).toBe(true)
expect(skeletonTiles()).toHaveLength(0)
expect(tiles()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(8)
pending.resolve(pageBody())
await vi.advanceTimersByTimeAsync(0)
expect(skeletonTiles()).toHaveLength(0)
expect(tiles()).toHaveLength(12)
})
it('draws 9 skeleton tiles on mobile', async () => {
await openWithFakeTimers({ list: () => new Promise(() => undefined) }, MOBILE_WIDTH)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(9)
})
it('asks for the first page of 25 images and nothing else', async () => {
await openLibrary()
expect(listCalls()).toHaveLength(1)
expect(listCalls()[0]!.toString()).toBe('page=1&size=25')
})
})
describe('The library is empty, so Upload opens', () => {
it('opens the Upload tab idle, and Library then shows the empty panel', async () => {
await openLibrary({ list: () => pageBody([], null) })
expect(textOf(selectedTab())).toBe('Upload')
expect(dialogText()).toContain('Drop an image here, or choose a file')
await clickTab('Library')
expect(dialogText()).toContain('Your library is empty')
expect(dialogText()).toContain(
'Images you upload appear here, ready to use in any article.',
)
expect(findButton('Insert image')?.disabled).toBe(true)
})
it('switches to Upload from "Upload an image"', async () => {
await openLibrary({ list: () => pageBody([], null) })
await clickTab('Library')
await click(findButton('Upload an image'))
expect(textOf(selectedTab())).toBe('Upload')
})
})
describe('A dropped file opens Upload and sends no list request', () => {
it('stays quiet until the Library tab is selected', async () => {
installApi({ hash: () => new Promise(() => undefined) })
wrapper = mountUploader({
initialFile: new File(['x'], 'before.png', { type: 'image/png' }),
})
await openUploader(wrapper)
expect(textOf(selectedTab())).toBe('Upload')
expect(listCalls()).toHaveLength(0)
await clickTab('Library')
expect(listCalls()).toHaveLength(1)
})
})
describe('Library, nothing selected', () => {
it('shows the line, the 12 tiles in order and the tags on the images in the article', async () => {
await openLibrary()
expect(summary()).toBe('Newest first · 48 images')
expect(tiles().map((element) => element.getAttribute('data-image-id'))).toEqual(
fixture().map((item) => String(item.id)),
)
const tagged = tiles()
.filter((element) => textOf(element).includes('In this article'))
.map((element) => element.getAttribute('data-image-id'))
expect(tagged).toEqual(['1', '4'])
expect(textOf(tile('north-wall.jpg'))).toContain('north-wall.jpg')
expect(findButton('Load more')?.getAttribute('aria-disabled')).toBeNull()
expect(findButton('Insert image')?.disabled).toBe(true)
})
})
describe('A search is active, so the count is not shown', () => {
it('reads "Newest first"', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
})
await searchFor('wall')
expect(summary()).toBe('Newest first')
})
})
describe('Load more is hidden on the last page', () => {
it('does not show "Load more" once every matching image is loaded', async () => {
await openLibrary({ list: () => pageBody(fixture(), null) })
expect(findButton('Load more')).toBeUndefined()
})
})
describe("A tile's accessible name", () => {
it.each([
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles'],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article'],
['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused'],
])('names %s', async (filename, name) => {
await openLibrary()
expect(tile(filename).getAttribute('aria-label')).toBe(name)
})
})
describe('A tile is selected by pointer or keyboard', () => {
it.each([
['clicks', async () => click(tile('kitchen-after.webp'))],
[
'presses Enter',
async () => {
press(tile('kitchen-after.webp'), 'Enter')
await settle()
},
],
[
'presses Space',
async () => {
press(tile('kitchen-after.webp'), ' ')
await settle()
},
],
])('selects the tile when the user %s on it', async (_name, act) => {
await openLibrary()
await act()
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
expect(
tile('kitchen-after.webp').querySelector('[data-testid="picker-check"]'),
).not.toBeNull()
expect(findButton('Insert image')?.disabled).toBe(false)
})
})
describe('The selection panel', () => {
it('shows the thumbnail, the details and the alt text field', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
const panel = textOf(document.body.querySelector('[data-testid="picker-panel"]'))
expect(panel).toContain('kitchen-after.webp')
expect(panel).toContain('2400 × 1600 · 1.2 MB')
expect(panel).toContain('Alt text (optional)')
expect(panel).toContain(
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.",
)
expect(altField()?.value).toBe('')
expect(findButton('Load more')).toBeUndefined()
})
it('reads "Vector · 8 KB" for an SVG', async () => {
await openLibrary()
await selectTile('logo-renew.svg')
expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain(
'Vector · 8 KB',
)
})
it('allows an image already in the article to be selected', async () => {
await openLibrary()
await selectTile('north-wall.jpg')
expect(findButton('Insert image')?.disabled).toBe(false)
})
})
describe('Selection is single', () => {
it('selects only the last tile and empties the alt text', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await selectTile('window-frame.jpg')
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false')
expect(tile('window-frame.jpg').getAttribute('aria-pressed')).toBe('true')
expect(altField()!.value).toBe('')
})
it('keeps the tile selected, and its alt text, when it is activated again', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await selectTile('kitchen-after.webp')
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
expect(altField()!.value).toBe('A kitchen')
})
})
describe('Insert', () => {
function kitchenAfterAtStaticUrl() {
return pageBody(
fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
),
)
}
it('emits the item and the alt text, then closes at once with no write request', async () => {
await openLibrary({ list: kitchenAfterAtStaticUrl })
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen with new oak worktops and white tiles')
await click(findButton('Insert image'))
const [item, alt] = wrapper!.emitted('inserted')![0] as [
{ id: number; url: string },
string,
]
expect(item.id).toBe(2)
expect(item.url).toBe('/static/uploadedImages/2.webp')
expect(alt).toBe('A kitchen with new oak worktops and white tiles')
expect(modalOpen()).toBe(false)
expect(apiKit.request.mock.calls.every(([, options]) => !options?.method)).toBe(true)
})
it('inserts an empty alt for a decorative image', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await click(findButton('Insert image'))
expect(wrapper!.emitted('inserted')![0]![1]).toBe('')
})
it.each([
['activates "Cancel"', async () => click(findButton('Cancel'))],
[
'activates the x',
async () =>
click(document.body.querySelector('[role="dialog"] button[aria-label="Close"]')),
],
[
'presses Escape',
async () => {
document.body
.querySelector('[role="dialog"]')!
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await settle()
},
],
])('changes nothing when the user %s', async (_name, act) => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await act()
expect(modalOpen()).toBe(false)
expect(wrapper!.emitted('inserted')).toBeUndefined()
expect(wrapper!.emitted('uploaded')).toBeUndefined()
})
})
describe('Search', () => {
it('searches after a 120 ms debounce, on page 1, and clears the selection', async () => {
await openWithFakeTimers()
tile('kitchen-after.webp').click()
await vi.advanceTimersByTimeAsync(0)
expect(findButton('Insert image')?.disabled).toBe(false)
await typeInto(searchField(), 'wall')
await vi.advanceTimersByTimeAsync(119)
expect(listCalls()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(listCalls()).toHaveLength(2)
expect(listCalls()[1]!.get('q')).toBe('wall')
expect(listCalls()[1]!.get('size')).toBe('25')
expect(listCalls()[1]!.get('page')).toBe('1')
expect(findButton('Insert image')?.disabled).toBe(true)
})
it('draws skeleton tiles while a search loads, after 150 ms', async () => {
await openWithFakeTimers({
list: (query) => (query.get('q') ? new Promise(() => undefined) : pageBody()),
})
await typeInto(searchField(), 'wall')
await vi.advanceTimersByTimeAsync(120)
expect(skeletonTiles()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(8)
})
it('clears the search from the x in the field', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
})
await searchFor('wall')
await click(document.body.querySelector('button[aria-label="Clear search"]'))
expect(searchField().value).toBe('')
const last = listCalls().at(-1)!
expect(last.has('q')).toBe(false)
expect(last.get('size')).toBe('25')
})
it('shows the no results panel and "Clear search" empties the field', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody([], null) : pageBody()),
})
await searchFor('wall')
expect(dialogText()).toContain('No images match "wall"')
expect(dialogText()).toContain(
'Check the spelling, or search for a shorter part of the filename.',
)
expect(searchField().value).toBe('wall')
expect(findButton('Insert image')?.disabled).toBe(true)
await click(findButton('Clear search'))
expect(searchField().value).toBe('')
expect(listCalls().at(-1)!.has('q')).toBe(false)
expect(tiles()).toHaveLength(12)
})
})
describe('Load more', () => {
it('shows progress, appends the new tiles and moves focus to the first one', async () => {
const more = deferred<unknown>()
await openWithFakeTimers({
list: (query) => (query.get('page') === '2' ? more.promise : pageBody()),
})
const button = findButton('Load more')!
button.focus()
button.click()
await vi.advanceTimersByTimeAsync(0)
const last = listCalls().at(-1)!
expect(last.get('page')).toBe('2')
expect(last.get('size')).toBe('25')
const loading = findButton('Loading…')!
expect(loading.getAttribute('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(loading)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(4)
more.resolve(pageBody(secondPage(), null))
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(tiles()).toHaveLength(24)
expect(skeletonTiles()).toHaveLength(0)
expect(document.activeElement).toBe(tiles()[12])
})
it('draws a row of 3 skeleton tiles on mobile', async () => {
await openWithFakeTimers(
{
list: (query) =>
query.get('page') === '2' ? new Promise(() => undefined) : pageBody(),
},
MOBILE_WIDTH,
)
findButton('Load more')!.click()
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(3)
})
it('combines the next page with the search', async () => {
await openLibrary({
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 48),
})
await searchFor('wall')
await click(findButton('Load more'))
const last = listCalls().at(-1)!
expect(last.get('q')).toBe('wall')
expect(last.get('size')).toBe('25')
expect(last.get('page')).toBe('2')
})
})
describe('The library fails to load', () => {
it.each([
['a 500 response', () => Promise.reject(new ApiError(500))],
['a request that never reaches the server', () => Promise.reject(new NetworkError())],
])('shows the alert for %s and retries', async (_name, failure) => {
let failing = true
await openLibrary({ list: () => (failing ? failure() : pageBody()) })
expect(dialogText()).toContain("Couldn't load your library")
expect(dialogText()).toContain('Check your connection and try again.')
expect(findButton('Insert image')?.disabled).toBe(true)
failing = false
await click(findButton('Try again'))
expect(listCalls()).toHaveLength(2)
expect(tiles()).toHaveLength(12)
expect(dialogText()).not.toContain("Couldn't load your library")
})
})