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

+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hashFile } from '../fileHash'
const SHA256_OF_ABC = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
function fileOf(bytes: number[]) {
return new File([new Uint8Array(bytes)], 'a.png', { type: 'image/png' })
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('hashFile', () => {
it('resolves the lowercase 64-character hex of the digest', async () => {
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
expect(Array.from(new Uint8Array(data))).toEqual([97, 98, 99])
return new Uint8Array(
SHA256_OF_ABC.match(/../g)!.map((pair) => Number.parseInt(pair, 16)),
).buffer
})
vi.stubGlobal('crypto', { subtle: { digest } })
const hex = await hashFile(fileOf([97, 98, 99]))
expect(digest).toHaveBeenCalledWith('SHA-256', expect.anything())
expect(hex).toBe(SHA256_OF_ABC)
expect(hex).toMatch(/^[0-9a-f]{64}$/)
})
it('resolves null when crypto.subtle is unavailable', async () => {
vi.stubGlobal('crypto', { subtle: undefined })
expect(await hashFile(fileOf([1]))).toBeNull()
})
it('resolves null when the digest rejects', async () => {
vi.stubGlobal('crypto', { subtle: { digest: vi.fn().mockRejectedValue(new Error('no')) } })
expect(await hashFile(fileOf([1]))).toBeNull()
})
it('imports nothing from the Media page', async () => {
const source = (await import('../fileHash?raw')).default
expect(source).not.toMatch(/import .*Media/)
expect(source).not.toMatch(/from '@\/(components|views|composables)/)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { extensionMatchesType, validateImageFile } from '../imageUpload'
function fileOf(name: string, type: string, size = 100) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: size })
return file
}
describe('validateImageFile', () => {
it('checks the size first, 10 MB allowed and one byte more too large', () => {
expect(validateImageFile(fileOf('a.png', 'image/png', 10485760))).toEqual({ ok: true })
expect(validateImageFile(fileOf('a.png', 'image/png', 10485761))).toEqual({
ok: false,
kind: 'size',
})
expect(validateImageFile(fileOf('a.tif', 'image/tiff', 10485761))).toEqual({
ok: false,
kind: 'size',
})
})
it('needs an allowed MIME type and an allowed extension', () => {
expect(validateImageFile(fileOf('a.gif', 'image/gif'))).toEqual({ ok: false, kind: 'type' })
expect(validateImageFile(fileOf('a.jpg', 'text/plain'))).toEqual({
ok: false,
kind: 'type',
})
})
})
describe('extensionMatchesType', () => {
it.each([
['a.png', 'image/png', true],
['a.JPG', 'image/jpeg', true],
['a.jpeg', 'image/jpeg', true],
['a.webp', 'image/webp', true],
['a.svg', 'image/svg+xml', true],
['pic.jpg', 'image/png', false],
['a.svg', 'image/jpeg', false],
['a.tif', 'image/tiff', false],
])('%s as %s is %s', (name, type, expected) => {
expect(extensionMatchesType(fileOf(name, type))).toBe(expected)
})
})
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { MediaItem } from '@/composables/useMedia'
import { groupDuplicates, mergeArticleCount } from '../mediaDuplicates'
function copy(id: number, usedIn: number, createdAt: string, overrides: Partial<MediaItem> = {}) {
return {
id,
filename: `copy-${id}.jpg`,
url: `/media/copy-${id}.jpg`,
size: 2516582,
content_type: 'image/jpeg',
width: 3600,
height: 2400,
sha256: 'aa',
uploaded_by: 'Inès Duarte',
created_at: createdAt,
used_in: usedIn,
duplicate: true,
...overrides,
} satisfies MediaItem
}
describe('groupDuplicates', () => {
it.each([
[
'the most used copy',
[
copy(1, 4, '2026-06-14T12:00:00Z'),
copy(2, 3, '2026-09-02T12:00:00Z'),
copy(3, 2, '2026-08-30T12:00:00Z'),
],
1,
],
[
'the oldest copy at equal use',
[copy(1, 2, '2026-08-29T12:00:00Z'), copy(2, 2, '2026-08-21T12:00:00Z')],
2,
],
[
'the oldest unused copy',
[copy(1, 0, '2026-09-03T12:00:00Z'), copy(2, 0, '2026-09-01T12:00:00Z')],
2,
],
[
'the lowest id at equal use and date',
[copy(9, 1, '2026-09-03T12:00:00Z'), copy(4, 1, '2026-09-03T12:00:00Z')],
4,
],
])('suggests %s', (_name, copies, suggestedId) => {
const [group] = groupDuplicates(copies)
expect(group!.suggested.id).toBe(suggestedId)
expect(group!.copies[0]!.id).toBe(suggestedId)
})
it('groups by sha256, orders copies and puts the oldest suggested copy first', () => {
const groups = groupDuplicates([
copy(7, 2, '2026-08-29T12:00:00Z', { sha256: 'bb', size: 3250585 }),
copy(5, 3, '2026-09-02T12:00:00Z'),
copy(9, 1, '2026-08-21T12:00:00Z', { sha256: 'bb', size: 3250585 }),
copy(12, 4, '2026-06-14T12:00:00Z'),
copy(6, 2, '2026-08-30T12:00:00Z'),
])
expect(groups.map((group) => group.copies.map((item) => item.id))).toEqual([
[12, 5, 6],
[7, 9],
])
expect(groups.map((group) => group.header)).toEqual([
'3 identical images · 2.4 MB each · 3600 × 2400',
'2 identical images · 3.1 MB each · 3600 × 2400',
])
})
it('reads Vector for SVG groups and ignores single copies', () => {
const groups = groupDuplicates([
copy(1, 0, '2026-09-01T12:00:00Z', { width: null, height: null, size: 8192 }),
copy(2, 0, '2026-09-02T12:00:00Z', { width: null, height: null, size: 8192 }),
copy(3, 0, '2026-09-02T12:00:00Z', { sha256: 'zz' }),
])
expect(groups).toHaveLength(1)
expect(groups[0]!.header).toBe('2 identical images · 8 KB each · Vector')
})
})
describe('mergeArticleCount', () => {
it('sums the use of every copy except the kept one', () => {
const copies = [
copy(12, 4, '2026-06-14T12:00:00Z'),
copy(5, 3, '2026-09-02T12:00:00Z'),
copy(6, 2, '2026-08-30T12:00:00Z'),
]
expect(mergeArticleCount(copies, 12)).toBe(5)
expect(mergeArticleCount(copies, 6)).toBe(7)
})
})
+2 -1
View File
@@ -14,7 +14,7 @@ describe('parseMediaControls', () => {
[{ q: 'wall' }, { q: 'wall' }],
[{ view: 'list' }, { view: 'list' }],
[{ filter: 'bogus', sort: 'zzz' }, {}],
[{ filter: 'duplicates' }, {}],
[{ filter: 'duplicates' }, { filter: 'duplicates' }],
[{ view: 'grid' }, {}],
[{ sort: 'newest' }, {}],
])('maps %j', (query, expected) => {
@@ -42,6 +42,7 @@ describe('toRequestQuery', () => {
it.each([
[{}, 'filter=all&sort=newest&limit=100'],
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&limit=100'],
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'],
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'],
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&limit=100'],
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
describeUpload,
parseRetryAfter,
rejectionText,
rejectionTitle,
summaryLine,
type UploadRowStatus,
} from '../mediaUploadRows'
function rows(...statuses: UploadRowStatus[]) {
return statuses.map((status) => ({ status }))
}
describe('summaryLine', () => {
it.each([
[2, 1, 2, '2 uploaded · 1 already in your library · 2 need attention'],
[3, 0, 0, '3 uploaded'],
[0, 1, 0, '1 already in your library'],
[1, 0, 1, '1 uploaded · 1 need attention'],
])('%i added, %i duplicate, %i attention reads "%s"', (added, duplicate, attention, line) => {
expect(summaryLine({ added, duplicate, attention })).toBe(line)
})
})
describe('describeUpload', () => {
it('counts settled rows while working', () => {
const header = describeUpload(
rows('added', 'duplicate', 'rejected', 'failed', 'uploading', 'waiting'),
)
expect(header).toEqual({
phase: 'working',
title: 'Uploading 6 images',
sub: '4 of 6 done',
summary: null,
})
})
it('uses singular forms', () => {
expect(describeUpload(rows('uploading')).title).toBe('Uploading 1 image')
expect(describeUpload(rows('added')).sub).toBe('1 image')
})
it('settles with a summary', () => {
const header = describeUpload(rows('added', 'added', 'duplicate', 'rejected', 'failed'))
expect(header).toEqual({
phase: 'settled',
title: 'Upload finished',
sub: '5 images',
summary: '2 uploaded · 1 already in your library · 2 need attention',
})
})
it('reads Nothing was uploaded without a summary when nothing was added', () => {
expect(describeUpload(rows('rejected', 'rejected', 'rejected'))).toEqual({
phase: 'settled',
title: 'Nothing was uploaded',
sub: '3 files were rejected',
summary: null,
})
expect(describeUpload(rows('rejected', 'failed'))).toMatchObject({
title: 'Nothing was uploaded',
sub: '2 files need attention',
summary: null,
})
expect(describeUpload(rows('failed'))).toMatchObject({ sub: '1 file needs attention' })
expect(describeUpload(rows('rejected'))).toMatchObject({ sub: '1 file was rejected' })
})
})
describe('rejection copy', () => {
it.each([
['size', 'scan.tif', 14.2 * 1024 * 1024, 'scan.tif is 14.2 MB'],
['size', 'photo.png', 10.1 * 1024 * 1024, 'photo.png is 10.1 MB'],
['type', 'notes.pdf', 215040, "notes.pdf isn't an image"],
['unreadable', 'broken.png', 3145728, "broken.png couldn't be read"],
] as const)('titles a %s rejection of %s', (kind, name, bytes, title) => {
expect(rejectionTitle(kind, name, bytes)).toBe(title)
})
it('picks the text per kind', () => {
expect(rejectionText('size')).toBe(
'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.',
)
expect(rejectionText('type')).toBe(rejectionText('size'))
expect(rejectionText('unreadable')).toBe(
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again.",
)
})
})
describe('parseRetryAfter', () => {
it('reads whole seconds and falls back to 60', () => {
expect(parseRetryAfter('42')).toBe(42)
expect(parseRetryAfter(null)).toBe(60)
expect(parseRetryAfter('soon')).toBe(60)
expect(parseRetryAfter('')).toBe(60)
})
})