feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
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)
})
})