526 lines
15 KiB
TypeScript
526 lines
15 KiB
TypeScript
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")
|
||
})
|
||
})
|