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,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)/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user