Files
TREK/client/tests/unit/studio/contentPhotos.test.tsx

302 lines
12 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { BookDocument, BookPageSetup } from '@trek/shared'
import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared'
import { fireEvent, render, screen, waitFor, within } from '../../helpers/render'
import { StudioSidebar, type JourneySource } from '../../../src/components/Studio/StudioSidebar'
import type { StudioUploader } from '../../../src/components/Studio/studioUpload'
import { useStudioStore } from '../../../src/store/studioStore'
/**
* Content > Photos, narrowed to a day, and the upload cell (#2064).
*
* A journey of three hundred pictures showed them as one grid, so laying out
* the page for one day meant scrolling past every other day to find its
* pictures. What these check is that the browser can be narrowed to one
* entry, to the pictures no entry claims, and to what just arrived; that the
* search still applies on top; and that a picture uploaded here lands where
* the panel says it will, the entry the filter names or the gallery when it
* names none.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
})
type Entry = JourneySource['entries'][number]
const entry = (over: Partial<Entry>): Entry => ({
id: 1, title: 'Reykjavik', story: null, location: null, date: '2026-06-01',
lat: null, lng: null, mood: null, weather: null, pros: [], cons: [], photoIds: [],
...over,
})
/** Two days with pictures, one written day without, and one picture only the gallery holds. */
const source: JourneySource = {
entries: [
entry({ id: 1, title: 'Reykjavik', date: '2026-06-01', photoIds: [11, 12] }),
entry({ id: 2, title: 'Vik', location: 'Vik i Myrdal', date: '2026-06-02', photoIds: [13] }),
entry({ id: 3, title: 'A written day', date: '2026-06-03' }),
],
photos: [
{ photoId: 11, caption: null, entryIds: [1] },
{ photoId: 12, caption: 'Harbour', entryIds: [1] },
{ photoId: 13, caption: null, entryIds: [2] },
{ photoId: 14, caption: 'Loose', entryIds: [] },
],
photoEntries: { 11: 'reykjavik', 12: 'reykjavik', 13: 'vik vik i myrdal' },
}
/** One inner spread, tied to a day or to none. */
const book = (entryId: number | null = null): BookDocument => normalizeBookDocument({
version: 1,
title: 'Iceland',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [{ id: 'sp1', role: 'inner', entryId, background: null, elements: [], parked: [] }],
})
const nothing: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 })
beforeEach(() => {
useStudioStore.getState().load(book())
})
/** Render the sidebar and open Content, which lands on the Photos tab. */
function openContent(src = source, over: { canEdit?: boolean; onUpload?: StudioUploader } = {}) {
const view = render(
<StudioSidebar
page={page}
pxPerMm={96 / 25.4}
bookView={false}
source={src}
stats={null}
path={[]}
t={(k: string) => k}
locale="en-US"
canEdit={over.canEdit ?? true}
onUpload={over.onUpload ?? nothing}
onToggleStop={async () => true}
/>,
)
fireEvent.click(screen.getByLabelText('journey.studio.content'))
return view
}
/** The pictures the grid shows now, by id, in grid order. */
const shown = () => [...document.querySelectorAll('.st-photo-cell:not(.is-upload) img')]
.map(img => Number(img.getAttribute('src')!.match(/\/photos\/(\d+)\//)![1]))
const openMenu = () => fireEvent.click(screen.getByTitle('journey.studio.filterPhotos'))
const chipLabel = () => document.querySelector('.st-filter-name')!.textContent
const picture = (name: string) => new File(['x'], name, { type: 'image/jpeg' })
describe('the filter menu', () => {
it('lists every entry that has pictures, with how many', () => {
openContent()
openMenu()
const items = screen.getAllByRole('menuitemradio').map(b => b.textContent)
expect(items[0]).toContain('journey.studio.filterAll')
expect(items[0]).toContain('4')
expect(items[1]).toContain('journey.studio.filterLoose')
expect(items[1]).toContain('1')
expect(items.find(s => s!.includes('Reykjavik'))).toContain('2')
expect(items.find(s => s!.includes('Vik i Myrdal'))).toContain('1')
// A day with words and no pictures is not a filter worth offering.
expect(items.some(s => s!.includes('A written day'))).toBe(false)
})
it('narrows the grid to one day, and the cross widens it again', () => {
openContent()
openMenu()
fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ }))
expect(shown()).toEqual([11, 12])
expect(chipLabel()).toBe('Reykjavik')
fireEvent.click(screen.getByLabelText('common.clear'))
expect(shown()).toEqual([11, 12, 13, 14])
expect(chipLabel()).toBe('journey.studio.filterAll')
})
it('shows only the pictures no entry claims', () => {
openContent()
openMenu()
fireEvent.click(screen.getByRole('menuitemradio', { name: /journey\.studio\.filterLoose/ }))
expect(shown()).toEqual([14])
})
it('combines with the search rather than replacing it', () => {
openContent()
openMenu()
fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ }))
fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'harbour' } })
expect(shown()).toEqual([12])
})
/* Four reasons for an empty grid, each said in its own words. */
it('says why the grid is empty', () => {
openContent()
openMenu()
fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ }))
fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'myrdal' } })
expect(screen.getByText('journey.studio.noMatches')).toBeInTheDocument()
})
it('says so when every picture belongs to a day', () => {
openContent({ ...source, photos: source.photos.filter(p => p.entryIds.length) })
openMenu()
fireEvent.click(screen.getByRole('menuitemradio', { name: /journey\.studio\.filterLoose/ }))
expect(shown()).toEqual([])
expect(screen.getByText('journey.studio.noLoosePhotos')).toBeInTheDocument()
})
})
describe('the way in from an entry', () => {
it('opens the pictures of the day from its card', () => {
openContent()
fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ }))
const card = screen.getByText('Reykjavik').closest('.st-entry') as HTMLElement
fireEvent.click(within(card).getByText('journey.studio.entryPhotos'))
expect(shown()).toEqual([11, 12])
expect(chipLabel()).toBe('Reykjavik')
})
it('offers no such chip on a day without pictures', () => {
openContent()
fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ }))
const card = screen.getByText('A written day').closest('.st-entry') as HTMLElement
expect(within(card).queryByText('journey.studio.entryPhotos')).toBeNull()
})
it('offers the page\'s own day while the filter is not on it', () => {
useStudioStore.getState().load(book(1))
openContent()
fireEvent.click(screen.getByText('journey.studio.filterThisPage'))
expect(shown()).toEqual([11, 12])
expect(screen.queryByText('journey.studio.filterThisPage')).toBeNull()
})
it('offers nothing for a page tied to a day without pictures', () => {
useStudioStore.getState().load(book(3))
openContent()
expect(screen.queryByText('journey.studio.filterThisPage')).toBeNull()
})
})
describe('the upload cell', () => {
it('is not there for a viewer', () => {
openContent(source, { canEdit: false })
expect(document.querySelector('.st-photo-cell.is-upload')).toBeNull()
expect(document.querySelector('input[type="file"]')).toBeNull()
expect(screen.queryByText('journey.studio.uploadToGallery')).toBeNull()
})
it('sends into the gallery when no day is chosen', async () => {
const onUpload = vi.fn(nothing)
openContent(source, { onUpload })
expect(screen.getByText('journey.studio.uploadToGallery')).toBeInTheDocument()
const file = picture('a.jpg')
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } })
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function)))
})
it('sends into the day the filter names', async () => {
const onUpload = vi.fn(nothing)
openContent(source, { onUpload })
openMenu()
fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ }))
expect(screen.getByText('journey.studio.uploadToEntry')).toBeInTheDocument()
const file = picture('b.jpg')
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } })
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], 1, expect.any(Function)))
})
/*
* The gallery sorts what arrives to its end, out of sight under three
* hundred pictures; the filter is what brings it into view. The source
* here already holds the new id, as the store would once the upload landed.
*/
it('shows what just arrived once it has', async () => {
const onUpload = vi.fn<StudioUploader>(async () => ({ photoIds: [99], failed: 0, skippedVideos: 0 }))
const arrived: JourneySource = {
...source,
photos: [...source.photos, { photoId: 99, caption: null, entryIds: [] }],
}
openContent(arrived, { onUpload })
fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'harbour' } })
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [picture('c.jpg')] } })
await waitFor(() => expect(chipLabel()).toBe('journey.studio.filterRecent'))
expect(shown()).toEqual([99])
// The search is let go of too, or the new picture would be hidden by it.
expect((screen.getByLabelText('journey.studio.searchContent') as HTMLInputElement).value).toBe('')
})
it('takes a drop anywhere on the panel', async () => {
const onUpload = vi.fn(nothing)
openContent(source, { onUpload })
const file = picture('d.jpg')
fireEvent.drop(document.querySelector('.st-panel-scroll')!, {
dataTransfer: { files: [file], types: ['Files'] },
})
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function)))
})
})
describe('photos from a provider (#2271)', () => {
it('offers one cell per connected provider, asks with the filtered entry, and shows what landed', async () => {
const onBrowseProvider = vi.fn(async () => [14])
render(
<StudioSidebar
page={page} pxPerMm={96 / 25.4} bookView={false} source={source} stats={null} path={[]}
t={(k: string) => k} locale="en-US" canEdit onUpload={nothing} onToggleStop={async () => true}
providers={[{ id: 'immich', name: 'Immich' }]} onBrowseProvider={onBrowseProvider}
/>,
)
fireEvent.click(screen.getByLabelText('journey.studio.content'))
fireEvent.click(screen.getByTitle('journey.studio.fromProviderHint'))
expect(onBrowseProvider).toHaveBeenCalledWith('immich', null)
await waitFor(() => expect(shown()).toEqual([14]))
})
it('a closed picker changes nothing, and a viewer gets no provider cell', async () => {
const onBrowseProvider = vi.fn(async () => [] as number[])
const { unmount } = render(
<StudioSidebar
page={page} pxPerMm={96 / 25.4} bookView={false} source={source} stats={null} path={[]}
t={(k: string) => k} locale="en-US" canEdit onUpload={nothing} onToggleStop={async () => true}
providers={[{ id: 'immich', name: 'Immich' }]} onBrowseProvider={onBrowseProvider}
/>,
)
fireEvent.click(screen.getByLabelText('journey.studio.content'))
const before = shown()
fireEvent.click(screen.getByTitle('journey.studio.fromProviderHint'))
await waitFor(() => expect(onBrowseProvider).toHaveBeenCalled())
expect(shown()).toEqual(before)
unmount()
openContent(source, { canEdit: false })
expect(screen.queryByTitle('journey.studio.fromProviderHint')).toBeNull()
})
})