feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
@@ -0,0 +1,443 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
announcer,
|
||||
callsTo,
|
||||
deferred,
|
||||
dialog,
|
||||
findButton,
|
||||
fixture,
|
||||
lastListPath,
|
||||
listBody,
|
||||
listCalls,
|
||||
mountPage,
|
||||
panel,
|
||||
press,
|
||||
settle,
|
||||
tab,
|
||||
tabs,
|
||||
textOf,
|
||||
unmountPage,
|
||||
type Handlers,
|
||||
} 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() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
|
||||
const DUPLICATE_COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
||||
|
||||
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
|
||||
return (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(ids), DUPLICATE_COUNTS)
|
||||
: listBody(fixture())
|
||||
}
|
||||
|
||||
function groups() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[data-testid="duplicate-group"]'),
|
||||
)
|
||||
}
|
||||
|
||||
function copiesOf(group: HTMLElement) {
|
||||
return Array.from(group.querySelectorAll<HTMLElement>('[data-testid="duplicate-copy"]'))
|
||||
}
|
||||
|
||||
function mergeButton(filename: string) {
|
||||
const copy = copiesOf(groups()[0]!)
|
||||
.concat(copiesOf(groups()[1] ?? groups()[0]!))
|
||||
.find((entry) => textOf(entry).includes(filename))!
|
||||
return findButton('Merge into this one', copy)!
|
||||
}
|
||||
|
||||
async function openMerge(filename: string, handlers: Handlers = {}) {
|
||||
await mountPage({ list: duplicatesList(), ...handlers }, '/media?filter=duplicates')
|
||||
mergeButton(filename).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
toasts.remove.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('The Duplicates tab', () => {
|
||||
it('is enabled, writes the URL, requests filter=duplicates with limit=100 and shows sort and view', async () => {
|
||||
await mountPage({ list: duplicatesList() })
|
||||
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
|
||||
|
||||
const duplicates = tab('Duplicates 2')
|
||||
duplicates.focus()
|
||||
press(duplicates, 'Enter')
|
||||
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
|
||||
expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe(
|
||||
'2',
|
||||
)
|
||||
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
|
||||
expect(document.body.querySelector('[aria-label="Grid view"]')).not.toBeNull()
|
||||
expect(
|
||||
document.body.querySelector('input[placeholder="Search by filename"]'),
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens from a deep link on the same view', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(groups()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies the search to the duplicates request', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search by filename"]',
|
||||
)!
|
||||
input.value = 'floor'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
expect(lastListPath()).toBe('/v1/media/?q=floor&filter=duplicates&sort=newest&limit=100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duplicates, groups', () => {
|
||||
it('groups the images by content into bordered cards in the suggested order', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
|
||||
expect(groups()).toHaveLength(2)
|
||||
const [first, second] = groups()
|
||||
expect(textOf(first!.querySelector('header'))).toBe(
|
||||
'Group 1 3 identical images · 2.4 MB each · 3600 × 2400',
|
||||
)
|
||||
expect(textOf(second!.querySelector('header'))).toBe(
|
||||
'Group 2 2 identical images · 3.1 MB each · 3600 × 2400',
|
||||
)
|
||||
const names = (group: HTMLElement) =>
|
||||
copiesOf(group).map((copy) => textOf(copy).split(' ')[0])
|
||||
expect(names(first!)).toEqual(['floorboards.jpg', 'floorboards-copy.jpg', 'IMG_2208.jpg'])
|
||||
expect(names(second!)).toEqual(['IMG_4471.jpg', 'stone-wall-detail.jpg'])
|
||||
expect(first!.className).toContain('border')
|
||||
expect(first!.parentElement!.className).toContain('gap-4')
|
||||
expect(textOf(first!.querySelector('footer'))).toBe(
|
||||
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows each copy with its uploader, use, merge button and the suggested styling', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
|
||||
const [suggested, other] = copiesOf(groups()[0]!)
|
||||
expect(textOf(suggested)).toContain('Uploaded 14 Jun 2026 by Inès Duarte')
|
||||
expect(textOf(suggested)).toContain('Used in 4 articles')
|
||||
expect(textOf(suggested)).toContain('Suggested')
|
||||
expect(suggested!.hasAttribute('data-suggested')).toBe(true)
|
||||
expect(suggested!.className).toContain('bg-secondary-200')
|
||||
expect(suggested!.className).toContain('md:border-primary')
|
||||
expect(findButton('Merge into this one', suggested)!.className).toContain('bg-primary')
|
||||
expect(textOf(other)).not.toContain('Suggested')
|
||||
expect(other!.hasAttribute('data-suggested')).toBe(false)
|
||||
expect(findButton('Merge into this one', other)!.className).toContain('border-accented')
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
findButton('Merge into this one', suggested)!.querySelector('svg.iconify--lucide'),
|
||||
).toBeTruthy(),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the detail panel from the underlined "Used in" count', async () => {
|
||||
await mountPage(
|
||||
{
|
||||
list: duplicatesList(),
|
||||
usage: () => [{ id: 10, title: 'How we price', published: true }],
|
||||
},
|
||||
'/media?filter=duplicates',
|
||||
)
|
||||
|
||||
copiesOf(groups()[0]!)[0]!
|
||||
.querySelector<HTMLButtonElement>('[data-testid="duplicate-used-in"]')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(panel()).not.toBeNull()
|
||||
expect(textOf(panel())).toContain('floorboards.jpg')
|
||||
})
|
||||
|
||||
it('stacks the columns below md', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates', MOBILE_WIDTH)
|
||||
|
||||
const grid = copiesOf(groups()[0]!)[0]!.parentElement!
|
||||
expect(grid.className).toContain('md:grid-cols-(--columns)')
|
||||
expect(grid.className).not.toMatch(/(^| )grid-cols-/)
|
||||
})
|
||||
|
||||
it('reads the vector size and omits "by" when the uploader is unknown', async () => {
|
||||
const svg = (id: number, createdAt: string): MediaItem => ({
|
||||
...fixture([4])[0]!,
|
||||
id,
|
||||
filename: `mark-${id}.svg`,
|
||||
sha256: 'svg',
|
||||
uploaded_by: '',
|
||||
created_at: createdAt,
|
||||
used_in: 0,
|
||||
duplicate: true,
|
||||
})
|
||||
await mountPage(
|
||||
{
|
||||
list: () =>
|
||||
listBody(
|
||||
[svg(20, '2026-09-01T12:00:00Z'), svg(21, '2026-09-02T12:00:00Z')],
|
||||
DUPLICATE_COUNTS,
|
||||
),
|
||||
},
|
||||
'/media?filter=duplicates',
|
||||
)
|
||||
|
||||
expect(textOf(groups()[0]!.querySelector('header'))).toBe(
|
||||
'Group 1 2 identical images · 8 KB each · Vector',
|
||||
)
|
||||
expect(textOf(copiesOf(groups()[0]!)[0])).toContain('Uploaded 1 Sep 2026')
|
||||
expect(textOf(copiesOf(groups()[0]!)[0])).not.toContain(' by ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duplicates, empty', () => {
|
||||
const noDuplicates = () => listBody([], { all: 12, unused: 2, duplicates: 0 })
|
||||
|
||||
it('has no count badge and shows the panel, and Back to all images returns to All', async () => {
|
||||
await mountPage(
|
||||
{
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates') ? noDuplicates() : listBody(fixture()),
|
||||
},
|
||||
'/media?filter=duplicates',
|
||||
)
|
||||
|
||||
expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull()
|
||||
expect(textOf(tab('Duplicates'))).toBe('Duplicates')
|
||||
expect(document.body.textContent).toContain('No duplicates')
|
||||
expect(document.body.textContent).toContain('Every image in your library is different.')
|
||||
|
||||
findButton('Back to all images')!.click()
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({})
|
||||
expect(tab('All').getAttribute('aria-selected')).toBe('true')
|
||||
expect(document.body.querySelectorAll('[data-media-card]')).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Merge', () => {
|
||||
it('confirms for the suggested copy with the kept image and the article count', async () => {
|
||||
await openMerge('floorboards.jpg')
|
||||
|
||||
const box = dialog()!
|
||||
expect(textOf(box)).toContain('Merge into floorboards.jpg?')
|
||||
expect(textOf(box)).toContain(
|
||||
"Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone.",
|
||||
)
|
||||
expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 articles now')
|
||||
expect(Array.from(box.querySelectorAll('button')).map(textOf)).toEqual([
|
||||
'Cancel',
|
||||
'Merge 2 copies',
|
||||
])
|
||||
expect(callsTo('POST', /merge/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps another copy and sums the use of the others', async () => {
|
||||
await openMerge('IMG_2208.jpg')
|
||||
|
||||
expect(textOf(dialog())).toContain('Merge into IMG_2208.jpg?')
|
||||
expect(textOf(dialog())).toContain('7 articles will be updated.')
|
||||
})
|
||||
|
||||
it('uses the singular for a group of two copies', async () => {
|
||||
await openMerge('IMG_4471.jpg')
|
||||
|
||||
expect(textOf(dialog())).toContain('Articles that use the other copy will be switched to')
|
||||
expect(textOf(dialog())).toContain('the other copy will be deleted.')
|
||||
expect(findButton('Merge 1 copy', dialog()!)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends the ids and locks the dialog while the request is in flight', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await openMerge('floorboards.jpg', { merge: () => pending.promise })
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
const [call] = callsTo('POST', /merge/)
|
||||
expect(call![0]).toBe('/v1/media/merge')
|
||||
expect(JSON.parse(call![1].body)).toEqual({ keep_id: 12, merge_ids: [5, 6] })
|
||||
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
|
||||
const confirm = findButton('Merging…', dialog()!)!
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
dialog()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
pending.resolve({ articles_updated: 5, merged: 2 })
|
||||
await settle()
|
||||
})
|
||||
|
||||
it('done: the group leaves, a toast and the live region say so, and the list reloads silently', async () => {
|
||||
let merged = false
|
||||
await openMerge('floorboards.jpg', {
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(merged ? [7, 9] : DUPLICATE_IDS), {
|
||||
all: merged ? 10 : 12,
|
||||
unused: 2,
|
||||
duplicates: merged ? 1 : 2,
|
||||
})
|
||||
: listBody(fixture()),
|
||||
merge: () => {
|
||||
merged = true
|
||||
return { articles_updated: 5, merged: 2 }
|
||||
},
|
||||
})
|
||||
const reloads = listCalls().length
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(groups()).toHaveLength(1)
|
||||
expect(textOf(groups()[0]!.querySelector('header'))).toContain('Group 1')
|
||||
expect(textOf(copiesOf(groups()[0]!)[0])).toContain('IMG_4471.jpg')
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'media-merged-12',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Copies merged',
|
||||
description: '2 copies merged. 5 articles updated.',
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(announcer()).toBe('2 copies merged. 5 articles updated.')
|
||||
expect(listCalls().length).toBe(reloads + 1)
|
||||
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
it('done with the last group shows "No duplicates" and drops the count badge', async () => {
|
||||
let merged = false
|
||||
await openMerge('IMG_4471.jpg', {
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(merged ? [] : [7, 9]), {
|
||||
all: merged ? 10 : 12,
|
||||
unused: 2,
|
||||
duplicates: merged ? 0 : 1,
|
||||
})
|
||||
: listBody(fixture()),
|
||||
merge: () => {
|
||||
merged = true
|
||||
return { articles_updated: 1, merged: 1 }
|
||||
},
|
||||
})
|
||||
|
||||
findButton('Merge 1 copy', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(document.body.textContent).toContain('No duplicates')
|
||||
expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull()
|
||||
expect(announcer()).toBe('1 copy merged. 1 article updated.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500 response', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])(
|
||||
'%s keeps the dialog open with the error alert and enables both buttons',
|
||||
async (_name, merge) => {
|
||||
await openMerge('floorboards.jpg', { merge })
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
const box = dialog()!
|
||||
expect(textOf(box.querySelector('[role="alert"]'))).toBe(
|
||||
"Couldn't merge the copies. Nothing was changed. Try again in a moment.",
|
||||
)
|
||||
expect(findButton('Cancel', box)!.disabled).toBe(false)
|
||||
expect(findButton('Merge 2 copies', box)!.disabled).toBe(false)
|
||||
expect(groups()).toHaveLength(2)
|
||||
},
|
||||
)
|
||||
|
||||
it('a 409 keeps the dialog open with the conflict alert and reloads the list', async () => {
|
||||
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(409)) })
|
||||
const before = listCalls().length
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(
|
||||
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.',
|
||||
)
|
||||
expect(listCalls().length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('a 404 closes the dialog, shows the info toast and reloads the list', async () => {
|
||||
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(404)) })
|
||||
const before = listCalls().length
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'media-not-found-12',
|
||||
color: 'info',
|
||||
title: 'That image no longer exists',
|
||||
}),
|
||||
)
|
||||
expect(listCalls().length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('disables every Merge into this one while offline', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
|
||||
(button) => textOf(button) === 'Merge into this one',
|
||||
)
|
||||
expect(buttons).toHaveLength(5)
|
||||
expect(buttons.every((button) => button.disabled)).toBe(true)
|
||||
expect(callsTo('POST', /merge/)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user