test(places): the picture chosen in the editor, the gallery and the lightbox

Tests for the picker, the shared image field, staging and saving the picture,
the gallery hook and trigger, the lightbox's dialog, focus and reading
direction, the credit and the enrichment cache; the inspector, phone sheet,
details column, form and wiring tests follow the picture into the editor.
The picker's upload tile and credit line are components of their own, the
heading goes with an empty picker, the gallery's state names its place, and
'No picture' only shows when a picture is being removed.
This commit is contained in:
Maurice
2026-10-23 20:06:09 +02:00
parent 35566690e9
commit bebfeeff97
25 changed files with 1687 additions and 199 deletions
@@ -1,4 +1,4 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-021
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027
// Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({
@@ -247,4 +247,81 @@ describe('PhotoLightbox', () => {
expect(img.style.transform).toContain('scale(2)');
expect(onClose).not.toHaveBeenCalled();
});
it('FE-COMP-LIGHTBOX-022: is a labelled modal dialog', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} label="Show photos" />);
const dialog = screen.getByRole('dialog', { name: 'Show photos' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
it('FE-COMP-LIGHTBOX-023: takes the focus when it opens and gives it back to the opener when it closes', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
try {
const { unmount } = render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} label="Gallery" />);
expect(screen.getByRole('dialog', { name: 'Gallery' })).toHaveFocus();
unmount();
expect(opener).toHaveFocus();
} finally {
opener.remove();
}
});
it('FE-COMP-LIGHTBOX-024: names its arrows', () => {
render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Next photo' }));
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Previous photo' }));
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
describe('right to left', () => {
beforeEach(() => {
document.documentElement.dir = 'rtl';
});
afterEach(() => {
document.documentElement.dir = '';
});
it('FE-COMP-LIGHTBOX-025: the arrow keys follow the reading direction', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} />);
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-026: a swipe to the right turns to the next photo', () => {
const { container } = render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
const surface = container.firstElementChild as HTMLElement;
fireEvent.touchStart(surface, { touches: [{ clientX: 100, clientY: 200 }] });
fireEvent.touchEnd(surface, { changedTouches: [{ clientX: 200, clientY: 210 }] });
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.touchStart(surface, { touches: [{ clientX: 200, clientY: 200 }] });
fireEvent.touchEnd(surface, { changedTouches: [{ clientX: 100, clientY: 210 }] });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
});
it('FE-COMP-LIGHTBOX-027: a caption with a source page links to it in a new tab, one without stays text', () => {
render(
<PhotoLightbox
photos={[
{ id: 'a', src: '/a.jpg', caption: 'Alice, CC BY 4.0', captionHref: 'https://commons.wikimedia.org/wiki/File:A.jpg' },
{ id: 'b', src: '/b.jpg', caption: 'Google', captionHref: null },
]}
onClose={vi.fn()}
/>,
);
const link = screen.getByRole('link', { name: 'Alice, CC BY 4.0' });
expect(link).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:A.jpg');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
@@ -110,8 +110,8 @@ describe('PlaceDetailsColumn', () => {
})
})
it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
renderColumn()
it('FE-PDC-004: renders author and licence under the Commons picture in play', async () => {
renderColumn({ selectedImageUrl: COMMONS_PHOTO.url })
const credit = await screen.findByText('Alice')
// Links to the file description page, where the full terms are.
@@ -122,9 +122,11 @@ describe('PlaceDetailsColumn', () => {
it('FE-PDC-005: names the source when a picture has no author, instead of inventing one', async () => {
placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null, facts: [] })
renderColumn()
renderColumn({ selectedImageUrl: GOOGLE_PHOTO.url })
expect(await screen.findByText('Google')).toBeInTheDocument()
// The tile's name carries the credit too, after a colon.
expect(screen.getByRole('button', { name: 'places.details.pickImage: Google' })).toBeInTheDocument()
})
it('FE-PDC-006: picking a picture reports its proxy URL', async () => {
@@ -611,17 +613,21 @@ describe('PlaceDetailsColumn — picture layout', () => {
withPhotos(3)
const { onPickImage } = renderColumn()
await screen.findByText('Author 0')
// Nothing is picked yet, and the line under the grid says so.
await screen.findByText('places.image.none')
expect(screen.queryByText('Author 0')).not.toBeInTheDocument()
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.mouseEnter(tiles[2])
expect(screen.getByText('Author 2')).toBeInTheDocument()
expect(onPickImage).not.toHaveBeenCalled()
fireEvent.mouseLeave(tiles[2])
expect(screen.queryByText('Author 2')).not.toBeInTheDocument()
})
it('FE-PDC-037: credits the picture in play, licence included', async () => {
withPhotos(2)
renderColumn()
renderColumn({ selectedImageUrl: '/api/maps/place-photo/p0/bytes' })
expect(await screen.findByText('Author 0')).toBeInTheDocument()
expect(screen.getByText('Author 0').closest('a')).toHaveAttribute(
@@ -672,9 +678,12 @@ describe('PlaceDetailsColumn — dialog variant', () => {
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).not.toHaveAttribute('title')
// Hovering the tile moves the credit line to it.
fireEvent.mouseEnter(tile)
const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a[href*="creativecommons"]' })
expect(licence.closest('p')).toHaveTextContent('Alice')
expect(licence.closest('p')?.textContent).not.toContain('·')
const line = licence.parentElement?.parentElement as HTMLElement
expect(line).toHaveTextContent('Alice')
expect(line.textContent).not.toContain('·')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url)
@@ -788,3 +797,41 @@ describe('PlaceDetailsColumn — dialog variant', () => {
expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument()
})
})
/**
* FE-PDC-050..051 — the editor's own pictures beside the sources' ones.
*/
describe('PlaceDetailsColumn — the place\'s own pictures', () => {
const own = (over: Record<string, unknown> = {}) => ({
currentUrl: '/uploads/places/own.jpg',
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
...over,
})
it('FE-PDC-050: offers the upload and the current picture even while nothing is selected', () => {
const mine = own()
renderColumn({ selection: null, image: { own: mine, chosen: { kind: 'current' } } })
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'places.uploadImage' })).toBeInTheDocument()
const current = screen.getByRole('button', { name: 'places.image.current' })
expect(current).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(current)
expect(mine.onRemove).toHaveBeenCalledTimes(1)
})
it('FE-PDC-051: the sources\' photos join the own ones once they arrive, the choice following the image prop', async () => {
const { onPickImage } = renderColumn({ image: { own: own(), chosen: { kind: 'url', url: COMMONS_PHOTO.url } } })
const tile = await screen.findByRole('button', { name: 'places.details.pickImage: Alice' })
expect(tile).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'places.image.current' })).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(null)
})
})
@@ -4,7 +4,6 @@ import {
ArrowRight,
Bike,
Building2,
Check,
ChefHat,
ChevronDown,
ChevronUp,
@@ -34,7 +33,7 @@ import { LABEL } from '../shared/dialogParts'
import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts'
import type { TranslationFn } from '../../types'
import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker'
import { chosenImage, type ChosenImage } from './placeImageChoice'
import { chosenImage, offersPictures, type ChosenImage } from './placeImageChoice'
import { cacheKeyFor, readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment'
export { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './placeEnrichment'
@@ -372,8 +371,8 @@ function PictureBlock({
dialog: boolean
t: TranslationFn
}): React.ReactElement | null {
if (!offersPictures(photos, own)) return null
const picker = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} />
if (photos.length === 0 && !own) return null
return (
<Block label={t('places.details.pickImage')} dialog={dialog}>
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{picker}</div> : picker}
@@ -0,0 +1,65 @@
// FE-PLANNER-PLFORMMEDIA-001 to FE-PLANNER-PLFORMMEDIA-003: the files waiting for a new
// place, and the place's picture in the form where no details column stands beside it.
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia';
import type { PlaceImageOwn } from './PlaceImagePicker';
vi.mock('../../api/authUrl', () => ({ fetchImageAsBlob: vi.fn(async () => 'blob:x') }));
const t = ((key: string) => key) as never;
describe('PendingFiles', () => {
it('FE-PLANNER-PLFORMMEDIA-001: the attach button opens the input, which adds the files', () => {
const fileRef = createRef<HTMLInputElement>();
const onAdd = vi.fn();
render(<PendingFiles files={[]} fileRef={fileRef} onAdd={onAdd} onRemove={vi.fn()} t={t} />);
expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument();
const openPicker = vi.spyOn(fileRef.current as HTMLInputElement, 'click');
fireEvent.click(screen.getByRole('button', { name: 'files.attach' }));
expect(openPicker).toHaveBeenCalledTimes(1);
fireEvent.change(fileRef.current as HTMLInputElement, { target: { files: [new File(['x'], 'a.pdf')] } });
expect(onAdd).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLFORMMEDIA-002: each waiting file is listed by name and removed by its index', () => {
const onRemove = vi.fn();
render(
<PendingFiles
files={[new File(['x'], 'ticket.pdf'), new File(['y'], 'map.png')]}
fileRef={createRef<HTMLInputElement>()}
onAdd={vi.fn()}
onRemove={onRemove}
t={t}
/>
);
expect(screen.getByTestId('pending-files')).toHaveTextContent('ticket.pdf');
expect(screen.getByText('map.png')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[1]);
expect(onRemove).toHaveBeenCalledWith(1);
});
});
describe('PlaceImageSection', () => {
it("FE-PLANNER-PLFORMMEDIA-003: the picker sits under the field's label with the place's own pictures", () => {
const own: PlaceImageOwn = {
currentUrl: '/uploads/places/c.jpg',
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
};
const onPickUrl = vi.fn();
render(<PlaceImageSection image={{ own, chosen: { kind: 'none' } }} onPickUrl={onPickUrl} t={t} />);
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'places.uploadImage' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'places.image.current' }));
expect(onPickUrl).toHaveBeenCalledWith('/uploads/places/c.jpg');
});
});
@@ -6,7 +6,7 @@ import { Tooltip } from '../shared/Tooltip';
import { LABEL } from '../shared/dialogParts';
import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker';
import { COLUMN_CARD, WHITE_BUTTON } from './placeDialogParts';
import type { ChosenImage } from './placeImageChoice';
import { offersPictures, type ChosenImage } from './placeImageChoice';
/** Files chosen before the place exists: they are uploaded once the save has given it an id. */
export function PendingFiles({
@@ -72,6 +72,7 @@ export function PlaceImageSection({
onPickUrl: (url: string | null) => void;
t: TranslationFn;
}) {
if (!offersPictures([], image.own)) return null;
return (
<div className="min-w-0">
<div className={LABEL}>{t('places.details.pickImage')}</div>
@@ -1,8 +1,8 @@
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-010: the place form's openings and save
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-011: the place form's openings and save
// payload, as the desktop dialog and the phone sheet both build them.
import { describe, expect, it } from 'vitest';
import type { Assignment, Place } from '../../types';
import type { Assignment, Place, TripFile } from '../../types';
import {
DEFAULT_FORM,
duplicateName,
@@ -155,4 +155,18 @@ describe('placeFormPayload', () => {
const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] }));
expect(payload.at).toEqual([48.86, null]);
});
it('FE-PLANNER-PLFORMH-011: a waiting picture travels as the file, or as the attached file id, or not at all', () => {
expect(placeFormPayload(form, [], null)._pendingImage).toBeUndefined();
const picture = new File(['x'], 'front.png');
expect(
placeFormPayload(form, [], null, undefined, { kind: 'upload', file: picture, preview: 'blob:x' })._pendingImage
).toBe(picture);
const attached = { id: 12, mime_type: 'image/png' } as TripFile;
expect(placeFormPayload(form, [], null, undefined, { kind: 'attached', file: attached })._pendingImage).toEqual({
fileId: 12,
});
// A dropped picture is the field's null, and it is saved as such.
expect(placeFormPayload({ ...form, image_url: null }, [], null).image_url).toBeNull();
});
});
@@ -0,0 +1,89 @@
// FE-PLANNER-PLACEFORM-125 to FE-PLANNER-PLACEFORM-127: the place's picture in the desktop
// dialog. Nothing is sent before the save; the waiting picture travels with it.
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace, buildTripFile } from '../../../tests/helpers/factories';
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import PlaceFormModal from './PlaceFormModal';
vi.mock('../shared/CustomTimePicker', async () => ({
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
}));
vi.mock('../../api/authUrl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/authUrl')>()),
fetchImageAsBlob: vi.fn(async () => 'blob:attached'),
}));
const imageInput = () => document.querySelector('input[type="file"][accept^="image"]') as HTMLInputElement;
beforeEach(() => {
resetAllStores();
seedPlaceFormStores();
// Without the details column the picker sits in the form itself.
seedStore(useAuthStore, { placesEnrichEnabled: false });
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:form-preview');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PlaceFormModal, the picture', () => {
it('FE-PLANNER-PLACEFORM-125: a new place gets a picture from the device, uploaded once the save made it', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
render(<PlaceFormModal {...placeFormProps} onSave={onSave} />);
expect(screen.getByText('Pick a picture')).toBeInTheDocument();
const picture = new File(['x'], 'front.png', { type: 'image/png' });
fireEvent.change(imageInput(), { target: { files: [picture] } });
const tile = await screen.findByRole('button', { name: 'Upload image', pressed: true });
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:form-preview');
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Louvre', _pendingImage: picture }));
});
it('FE-PLANNER-PLACEFORM-126: an edited place keeps its picture as chosen, or takes an attached image by its id', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ id: 61, name: 'Gallery', image_url: '/uploads/places/61.jpg' });
useTripStore.setState({
files: [buildTripFile({ id: 88, place_id: 61, mime_type: 'image/jpeg', original_name: 'hall.jpg' })],
} as never);
render(<PlaceFormModal {...placeFormProps} place={place} onSave={onSave} />);
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: 'hall.jpg' }));
expect(screen.getByRole('button', { name: 'hall.jpg' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'false');
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _pendingImage: { fileId: 88 } }));
});
it('FE-PLANNER-PLACEFORM-127: removing the picture saves the place without one, and nothing waits', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ id: 62, name: 'Bridge', image_url: '/uploads/places/62.jpg' });
render(<PlaceFormModal {...placeFormProps} place={place} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: 'Remove image' }));
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText('No picture')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const payload = onSave.mock.calls[0][0];
expect(payload.image_url).toBeNull();
expect(payload._pendingImage).toBeUndefined();
});
});
@@ -913,7 +913,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => {
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
@@ -925,7 +925,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' });
fireEvent.change(fileInput, { target: { files: [file] } });
@@ -1470,7 +1470,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await user.click(screen.getByRole('button', { name: /Attach/i }));
expect(clickSpy).toHaveBeenCalled();
@@ -2422,7 +2422,7 @@ describe('PlaceFormModal on the dialog frame', () => {
it('FE-PLANNER-PLACEFORM-103: a pending file is removed by its named button', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
fireEvent.change(document.querySelector('input[type="file"][multiple]') as HTMLInputElement, {
target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] },
});
await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' }));
@@ -0,0 +1,258 @@
// FE-PLANNER-PLIMGPICK-001 to FE-PLANNER-PLIMGPICK-016: the editor's picture picker, the one
// place a place's picture is chosen: the upload tile, the current picture, the images
// attached to the place, the sources' photos, the credit line and "Remove image".
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { PlacePhotoCandidate } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { fetchImageAsBlob } from '../../api/authUrl';
import { normalizeImageFile } from '../../utils/convertHeic';
import PlaceImagePicker, { PhotoCredit, type PlaceImageOwn } from './PlaceImagePicker';
import type { ChosenImage } from './placeImageChoice';
vi.mock('../../api/authUrl', () => ({ fetchImageAsBlob: vi.fn() }));
vi.mock('../../utils/convertHeic', () => ({
normalizeImageFile: vi.fn(async (file: File) => new File([file], `normalized-${file.name}`, { type: 'image/jpeg' })),
}));
/** Echo the key back, which keeps the assertions readable. */
const t = ((key: string) => key) as never;
const CURRENT = '/uploads/places/current.jpg';
const COMMONS: PlacePhotoCandidate = {
key: 'w~p0',
url: '/api/maps/place-photo/w~p0/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg',
source: 'wikimedia',
} as PlacePhotoCandidate;
const GOOGLE: PlacePhotoCandidate = {
key: 'w~p1',
url: '/api/maps/place-photo/w~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google',
} as PlacePhotoCandidate;
const ATTACHED = buildTripFile({ id: 41, original_name: 'facade.png', mime_type: 'image/png', url: '/api/files/41' });
function own(over: Partial<PlaceImageOwn> = {}): PlaceImageOwn {
return {
currentUrl: null,
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
...over,
};
}
function renderPicker(
props: Partial<{
photos: PlacePhotoCandidate[];
chosen: ChosenImage;
own: PlaceImageOwn;
dialog: boolean;
}> = {}
) {
const onPickUrl = vi.fn();
const utils = render(
<PlaceImagePicker
photos={props.photos ?? []}
chosen={props.chosen ?? { kind: 'none' }}
onPickUrl={onPickUrl}
own={props.own}
dialog={props.dialog}
t={t}
/>
);
return { ...utils, onPickUrl };
}
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
beforeEach(() => {
vi.mocked(fetchImageAsBlob).mockReset();
vi.mocked(fetchImageAsBlob).mockResolvedValue('blob:attached');
vi.mocked(normalizeImageFile).mockClear();
});
describe('PlaceImagePicker', () => {
it('FE-PLANNER-PLIMGPICK-001: renders nothing while there is no picture to offer and no upload', () => {
const bare = renderPicker();
expect(bare.container).toBeEmptyDOMElement();
bare.unmount();
const readOnly = renderPicker({ own: own({ canUpload: false }) });
expect(readOnly.container).toBeEmptyDOMElement();
});
it('FE-PLANNER-PLIMGPICK-002: the upload tile opens the file picker and hands the converted file over', async () => {
const mine = own();
renderPicker({ own: mine });
const tile = screen.getByRole('button', { name: 'places.uploadImage' });
expect(tile).toHaveAttribute('aria-pressed', 'false');
const input = fileInput();
expect(input).toHaveAttribute('accept', 'image/*,.heic,.heif');
const openPicker = vi.spyOn(input, 'click');
fireEvent.click(tile);
expect(openPicker).toHaveBeenCalledTimes(1);
const picture = new File(['x'], 'photo.heic', { type: 'image/heic' });
fireEvent.change(input, { target: { files: [picture] } });
await waitFor(() => expect(mine.onUpload).toHaveBeenCalledTimes(1));
expect(normalizeImageFile).toHaveBeenCalledWith(picture);
expect(vi.mocked(mine.onUpload).mock.calls[0][0].name).toBe('normalized-photo.heic');
await waitFor(() => expect(tile).not.toBeDisabled());
});
it('FE-PLANNER-PLIMGPICK-003: an emptied file choice hands nothing over', () => {
const mine = own();
renderPicker({ own: mine });
fireEvent.change(fileInput(), { target: { files: [] } });
expect(normalizeImageFile).not.toHaveBeenCalled();
expect(mine.onUpload).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLIMGPICK-004: a picture waiting for the save shows as its preview, chosen and credited as yours', () => {
renderPicker({ own: own({ pendingPreview: 'blob:preview' }), chosen: { kind: 'upload' } });
const tile = screen.getByRole('button', { name: 'places.uploadImage' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:preview');
expect(screen.getByText('places.image.own')).toBeInTheDocument();
// A waiting upload can be dropped again.
expect(screen.getByRole('button', { name: 'places.removeImage' })).toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-005: the current picture is chosen by default, and a second tap removes it', () => {
const mine = own({ currentUrl: CURRENT });
renderPicker({ own: mine, chosen: { kind: 'current' } });
const tile = screen.getByRole('button', { name: 'places.image.current' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(tile.querySelector('img')).toHaveAttribute('src', CURRENT);
expect(screen.getByText('places.image.current')).toBeInTheDocument();
fireEvent.click(tile);
expect(mine.onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLIMGPICK-006: once dropped, the current picture is taken back with a tap', () => {
const mine = own({ currentUrl: CURRENT });
const { onPickUrl } = renderPicker({ own: mine, chosen: { kind: 'none' } });
// The line under the grid says what the save will do.
expect(screen.getByText('places.image.none')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'places.removeImage' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'places.image.current' }));
expect(onPickUrl).toHaveBeenCalledWith(CURRENT);
expect(mine.onRemove).not.toHaveBeenCalled();
});
it("FE-PLANNER-PLIMGPICK-007: a current picture that is one of the sources' photos is not shown twice", () => {
renderPicker({ photos: [COMMONS], own: own({ currentUrl: COMMONS.url }), chosen: { kind: 'current' } });
expect(screen.queryByRole('button', { name: 'places.image.current' })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /places.details.pickImage/ })).toHaveLength(1);
});
it('FE-PLANNER-PLIMGPICK-008: an attached image loads through the file route and is picked with a tap', async () => {
const mine = own({ attached: [ATTACHED] });
renderPicker({ own: mine });
const tile = screen.getByRole('button', { name: 'facade.png' });
await waitFor(() => expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:attached'));
expect(fetchImageAsBlob).toHaveBeenCalledWith('/api/files/41');
fireEvent.click(tile);
expect(mine.onPickAttached).toHaveBeenCalledWith(ATTACHED);
});
it('FE-PLANNER-PLIMGPICK-009: a chosen attached image is ticked and named under the grid; one that fails to load stays blank', async () => {
vi.mocked(fetchImageAsBlob).mockRejectedValue(new Error('gone'));
renderPicker({ own: own({ attached: [ATTACHED] }), chosen: { kind: 'attached', fileId: 41 } });
const tile = screen.getByRole('button', { name: 'facade.png' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('facade.png')).toBeInTheDocument();
await waitFor(() => expect(fetchImageAsBlob).toHaveBeenCalled());
expect(tile.querySelector('img')).toBeNull();
// Focusing it names it as well, and the name stays once it is left.
fireEvent.focus(tile);
expect(screen.getByText('facade.png')).toBeInTheDocument();
fireEvent.blur(tile);
expect(screen.getByText('facade.png')).toBeInTheDocument();
});
it("FE-PLANNER-PLIMGPICK-010: a source's photo is picked with a tap and let go with a second", () => {
const first = renderPicker({ photos: [COMMONS, GOOGLE] });
fireEvent.click(screen.getByRole('button', { name: 'places.details.pickImage: Alice' }));
expect(first.onPickUrl).toHaveBeenCalledWith(COMMONS.url);
first.unmount();
const chosen = renderPicker({ photos: [COMMONS, GOOGLE], chosen: { kind: 'url', url: GOOGLE.url } });
const tile = screen.getByRole('button', { name: 'places.details.pickImage: Google' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(tile);
expect(chosen.onPickUrl).toHaveBeenCalledWith(null);
});
it('FE-PLANNER-PLIMGPICK-011: the chosen photo is credited with its author and licence, each linked', () => {
renderPicker({ photos: [COMMONS, GOOGLE], chosen: { kind: 'url', url: COMMONS.url } });
expect(screen.getByText('Alice').closest('a')).toHaveAttribute('href', COMMONS.sourceUrl);
expect(screen.getByText('CC BY-SA 4.0').closest('a')).toHaveAttribute('href', COMMONS.licenseUrl);
// Hovering another tile moves the credit line to it, leaving moves it back.
const google = screen.getByRole('button', { name: 'places.details.pickImage: Google' });
fireEvent.mouseEnter(google);
expect(screen.getByText('Google')).toBeInTheDocument();
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
fireEvent.mouseLeave(google);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-012: without the own pictures the first photo is credited and nothing can be removed', () => {
renderPicker({ photos: [COMMONS], chosen: { kind: 'url', url: '/not/among/them.jpg' } });
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'places.removeImage' })).not.toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-013: "Remove image" leaves the place without a picture', () => {
const mine = own({ currentUrl: CURRENT });
renderPicker({ own: mine, photos: [COMMONS], chosen: { kind: 'url', url: COMMONS.url } });
fireEvent.click(screen.getByRole('button', { name: 'places.removeImage' }));
expect(mine.onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLIMGPICK-014: the sheet gives each photo a native tooltip, the dialog does not', () => {
const sheet = renderPicker({ photos: [COMMONS, GOOGLE] });
expect(screen.getByRole('button', { name: 'places.details.pickImage: Alice' })).toHaveAttribute(
'title',
'Alice, CC BY-SA 4.0'
);
expect(screen.getByRole('button', { name: 'places.details.pickImage: Google' })).toHaveAttribute('title', 'Google');
sheet.unmount();
renderPicker({ photos: [COMMONS], dialog: true });
expect(screen.getByRole('button', { name: 'places.details.pickImage: Alice' })).not.toHaveAttribute('title');
});
});
describe('PhotoCredit', () => {
it('FE-PLANNER-PLIMGPICK-015: a photo without links is still credited in plain text', () => {
render(<PhotoCredit photo={{ ...COMMONS, sourceUrl: null, licenseUrl: null }} />);
expect(screen.getByText('Alice').closest('a')).toBeNull();
expect(screen.getByText('CC BY-SA 4.0').closest('a')).toBeNull();
});
it('FE-PLANNER-PLIMGPICK-016: a photo without a licence shows only its credit', () => {
const { container } = render(<PhotoCredit photo={GOOGLE} />);
expect(container).toHaveTextContent(/^Google$/);
});
});
@@ -5,7 +5,7 @@ import { fetchImageAsBlob } from '../../api/authUrl';
import type { TranslationFn, TripFile } from '../../types';
import { normalizeImageFile } from '../../utils/convertHeic';
import { Tooltip } from '../shared/Tooltip';
import type { ChosenImage } from './placeImageChoice';
import { offersPictures, type ChosenImage } from './placeImageChoice';
import { creditOf } from './placePhotoCredit';
/** The place's own pictures beside the sources' ones: what it has, what it can get, what waits for the save. */
@@ -70,77 +70,24 @@ export default function PlaceImagePicker({
t,
}: PlaceImagePickerProps): React.ReactElement | null {
const [hovered, setHovered] = useState<Credit>(null);
const fileRef = useRef<HTMLInputElement>(null);
const [reading, setReading] = useState(false);
const current = own?.currentUrl && !photos.some((p) => p.url === own.currentUrl) ? own.currentUrl : null;
const attached = own?.attached ?? [];
const hasAnything =
photos.length > 0 || !!current || attached.length > 0 || !!own?.pendingPreview || !!own?.canUpload;
if (!hasAnything) return null;
if (!offersPictures(photos, own)) return null;
const pick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !own) return;
setReading(true);
try {
own.onUpload(await normalizeImageFile(file));
} finally {
setReading(false);
}
};
const credit = (): Credit => {
if (hovered) return hovered;
if (chosen.kind === 'url') {
const photo = photos.find((p) => p.url === chosen.url);
if (photo) return { kind: 'photo', photo };
}
if (chosen.kind === 'current') return { kind: 'own', label: t('places.image.current') };
if (chosen.kind === 'upload') return { kind: 'own', label: t('places.image.own') };
if (chosen.kind === 'attached') {
const file = attached.find((f) => f.id === chosen.fileId);
if (file) return { kind: 'own', label: file.original_name };
}
if (chosen.kind === 'none' && (own?.currentUrl || photos.length > 0))
return { kind: 'own', label: t('places.image.none') };
return photos[0] ? { kind: 'photo', photo: photos[0] } : null;
};
const shown = credit();
const shown = hovered ?? creditFor(chosen, photos, attached, own, t);
const removable = !!own && chosen.kind !== 'none';
return (
<>
<div className="grid grid-cols-3 gap-1.5">
{own?.canUpload && (
<Tooltip label={t('places.uploadImage')} placement="top">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={reading}
aria-pressed={chosen.kind === 'upload'}
aria-label={t('places.uploadImage')}
className={`${TILE} ${ring(chosen.kind === 'upload', dialog)} ${own.pendingPreview ? '' : 'border border-dashed border-edge bg-surface-card'}`}
onMouseEnter={() => own.pendingPreview && setHovered({ kind: 'own', label: t('places.image.own') })}
onMouseLeave={() => setHovered(null)}
>
{own.pendingPreview ? (
<>
<img src={own.pendingPreview} alt="" className="h-full w-full object-cover" />
{chosen.kind === 'upload' && <Picked />}
</>
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1 px-1 text-center text-caption leading-tight text-content-muted group-hover:text-content">
{reading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" strokeWidth={2} />
)}
{t('places.uploadImage')}
</span>
)}
</button>
</Tooltip>
<UploadTile
own={own}
selected={chosen.kind === 'upload'}
dialog={dialog}
onHover={(on) => setHovered(on ? { kind: 'own', label: t('places.image.own') } : null)}
t={t}
/>
)}
{current && (
<button
@@ -178,43 +125,132 @@ export default function PlaceImagePicker({
/>
))}
</div>
{own?.canUpload && (
<input
ref={fileRef}
type="file"
accept="image/*,.heic,.heif"
className="hidden"
onChange={(e) => void pick(e)}
/>
)}
{(shown || removable) && (
<div
className={
dialog
? 'mt-2 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint'
: 'mt-1.5 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint'
}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{shown?.kind === 'photo' && <PhotoCredit photo={shown.photo} />}
{shown?.kind === 'own' && <span className="min-w-0 truncate">{shown.label}</span>}
</span>
{removable && (
<button
type="button"
onClick={own.onRemove}
className="inline-flex flex-none items-center gap-1 rounded-md px-1.5 py-0.5 text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
<ImageOff className="h-3 w-3" />
{t('places.removeImage')}
</button>
)}
</div>
<CreditLine shown={shown} onRemove={removable ? own?.onRemove : undefined} dialog={dialog} t={t} />
)}
</>
);
}
/** The tile that takes a picture from the device, or shows the one waiting for the save. */
function UploadTile({
own,
selected,
dialog,
onHover,
t,
}: {
own: PlaceImageOwn;
selected: boolean;
dialog: boolean;
onHover: (on: boolean) => void;
t: TranslationFn;
}): React.ReactElement {
const fileRef = useRef<HTMLInputElement>(null);
const [reading, setReading] = useState(false);
const pick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setReading(true);
try {
own.onUpload(await normalizeImageFile(file));
} finally {
setReading(false);
}
};
const preview = own.pendingPreview;
return (
<>
<Tooltip label={t('places.uploadImage')} placement="top">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={reading}
aria-pressed={selected}
aria-label={t('places.uploadImage')}
className={`${TILE} ${ring(selected, dialog)} ${preview ? '' : 'border border-dashed border-edge bg-surface-card'}`}
onMouseEnter={() => onHover(!!preview)}
onMouseLeave={() => onHover(false)}
>
{preview ? (
<>
<img src={preview} alt="" className="h-full w-full object-cover" />
{selected && <Picked />}
</>
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1 px-1 text-center text-caption leading-tight text-content-muted group-hover:text-content">
{reading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" strokeWidth={2} />
)}
{t('places.uploadImage')}
</span>
)}
</button>
</Tooltip>
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" className="hidden" onChange={(e) => void pick(e)} />
</>
);
}
/** Who the chosen picture is from, and the way to leave the place without one. */
function CreditLine({
shown,
onRemove,
dialog,
t,
}: {
shown: Credit;
onRemove?: () => void;
dialog: boolean;
t: TranslationFn;
}): React.ReactElement {
return (
<div
className={`${dialog ? 'mt-2' : 'mt-1.5'} flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint`}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{shown?.kind === 'photo' && <PhotoCredit photo={shown.photo} />}
{shown?.kind === 'own' && <span className="min-w-0 truncate">{shown.label}</span>}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="inline-flex flex-none items-center gap-1 rounded-md px-1.5 py-0.5 text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
<ImageOff className="h-3 w-3" />
{t('places.removeImage')}
</button>
)}
</div>
);
}
/** The credit line for the chosen tile: a source's photo, the place's own picture, or nothing to say. */
function creditFor(
chosen: ChosenImage,
photos: PlacePhotoCandidate[],
attached: TripFile[],
own: PlaceImageOwn | undefined,
t: TranslationFn
): Credit {
if (chosen.kind === 'url') {
const photo = photos.find((p) => p.url === chosen.url);
if (photo) return { kind: 'photo', photo };
}
if (chosen.kind === 'current') return { kind: 'own', label: t('places.image.current') };
if (chosen.kind === 'upload') return { kind: 'own', label: t('places.image.own') };
if (chosen.kind === 'attached') {
const file = attached.find((f) => f.id === chosen.fileId);
if (file) return { kind: 'own', label: file.original_name };
}
if (chosen.kind === 'none' && own?.currentUrl) return { kind: 'own', label: t('places.image.none') };
return photos[0] ? { kind: 'photo', photo: photos[0] } : null;
}
function PhotoTile({
photo,
selected,
@@ -23,6 +23,7 @@ vi.mock('../../api/client', async (importOriginal) => {
mapsApi: {
details: vi.fn().mockResolvedValue({ place: null }),
placePhotoCredit: vi.fn().mockResolvedValue({ credit: null }),
placeEnrichment: vi.fn().mockResolvedValue({ photos: [], facts: [], description: null }),
},
};
});
@@ -54,6 +55,7 @@ beforeAll(() => {
import PlaceInspector from './PlaceInspector';
import { mapsApi } from '../../api/client';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
// ── Shared fixtures ───────────────────────────────────────────────────────────
@@ -100,6 +102,8 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } });
vi.mocked(mapsApi.details).mockResolvedValue({ place: null });
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({ photos: [], facts: [], description: null } as never);
__clearEnrichmentCacheForTests();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -970,18 +974,21 @@ describe('PlaceInspector', () => {
}
});
// ── Custom thumbnail upload (#1136) ──────────────────────────────────────────
// ── The avatar opens the place's photos (#1136) ─────────────────────────────
it('FE-PLANNER-INSPECTOR-049: onUploadImage in trip mode renders the upload-capable avatar', () => {
render(<PlaceInspector {...defaultProps} onUploadImage={vi.fn()} />);
// The place carries no image yet, so the avatar offers "Upload image".
expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-050: without onUploadImage the avatar has no upload control', () => {
it('FE-PLANNER-INSPECTOR-049: the avatar opens the photos and offers no upload, the picture is changed in the editor', () => {
render(<PlaceInspector {...defaultProps} />);
expect(screen.getByRole('button', { name: 'Show photos' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
});
it('FE-PLANNER-INSPECTOR-050: with no picture and nobody to ask for one the avatar stays a plain picture', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
render(<PlaceInspector {...defaultProps} />);
expect(screen.queryByRole('button', { name: 'Show photos' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
});
// ── Track colour (#776) ──────────────────────────────────────────────────────
@@ -1415,15 +1422,23 @@ describe('PlaceInspector', () => {
expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull();
});
// ── Custom thumbnail callbacks (#1136) ───────────────────────────────────────
// ── The avatar's gallery (#1136) ────────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => {
it('FE-PLANNER-INSPECTOR-088: the custom image opens at once in the gallery, and the avatar has no remove control', async () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const onUpdatePlace = vi.fn();
const onUploadImage = vi.fn(async () => {});
const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' });
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} onUploadImage={onUploadImage} />);
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }));
expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null });
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} />);
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = await screen.findByRole('dialog', { name: 'Show photos' });
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/x.jpg');
expect(mapsApi.placeEnrichment).not.toHaveBeenCalled();
expect(onUpdatePlace).not.toHaveBeenCalled();
fireEvent.click(within(gallery).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Show photos' })).toBeNull());
});
it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => {
@@ -1545,13 +1560,27 @@ describe('PlaceInspector', () => {
}
});
it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => {
const onUploadImage = vi.fn(async () => {});
render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
const input = document.querySelector('input[accept*="image"]') as HTMLInputElement;
const file = new File(['x'], 'thumb.png', { type: 'image/png' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(place.id, file));
it("FE-PLANNER-INSPECTOR-089: the avatar asks the sources for the place's photos on click and shows them credited", async () => {
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({
photos: [{
key: 'gp~p0', url: '/api/maps/place-photo/gp~p0/bytes', attribution: 'Alice', license: 'CC BY 4.0',
licenseUrl: null, sourceUrl: 'https://commons.wikimedia.org/wiki/File:T.jpg', source: 'wikimedia',
}],
facts: [],
description: null,
} as never);
render(<PlaceInspector {...defaultProps} />);
// Nothing is asked before the click: selecting a place is not worth the provider fan-out.
expect(mapsApi.placeEnrichment).not.toHaveBeenCalled();
expect(document.querySelector('input[type="file"][accept*="image"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = await screen.findByRole('dialog', { name: 'Show photos' });
expect(mapsApi.placeEnrichment).toHaveBeenCalledWith(expect.objectContaining({ lat: place.lat, lng: place.lng, name: 'Eiffel Tower' }));
expect(within(gallery).getByRole('img')).toHaveAttribute('src', '/api/maps/place-photo/gp~p0/bytes');
expect(within(gallery).getByRole('link', { name: 'Alice, CC BY 4.0' })).toHaveAttribute(
'href', 'https://commons.wikimedia.org/wiki/File:T.jpg',
);
});
it('FE-PLANNER-INSPECTOR-098: deselecting and reselecting a place survives a rerender', async () => {
@@ -0,0 +1,102 @@
// FE-PLANNER-PHOTOGALLERY-001 to FE-PLANNER-PHOTOGALLERY-003: the place's picture as the
// way into its photos, on the desktop inspector and the phone sheet alike.
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import PlacePhotoGallery from './PlacePhotoGallery';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
const placeEnrichment = vi.fn();
vi.mock('../../api/client', () => ({ mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) } }));
vi.mock('../Journey/VideoPlayerLazy', () => ({ default: () => null }));
const PLACE = buildPlace({ id: 3, name: 'Tower', lat: 48.85, lng: 2.29, image_url: '/uploads/places/tower.jpg' });
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { placesEnrichEnabled: true });
placeEnrichment.mockReset();
sessionStorage.clear();
__clearEnrichmentCacheForTests();
});
describe('PlacePhotoGallery', () => {
it('FE-PLANNER-PHOTOGALLERY-001: with nothing to open the picture stays a plain picture', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
render(
<PlacePhotoGallery place={{ ...PLACE, image_url: null }} language="en" label="Show photos">
<span data-testid="avatar" />
</PlacePhotoGallery>
);
expect(screen.getByTestId('avatar')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-PLANNER-PHOTOGALLERY-002: the trigger is a labelled button that says it is busy while the sources answer', async () => {
let settle: (v: MapsPlaceEnrichmentResult) => void = () => {};
placeEnrichment.mockReturnValue(new Promise<MapsPlaceEnrichmentResult>((r) => (settle = r)));
render(
<PlacePhotoGallery
place={{ ...PLACE, image_url: null }}
language="en"
label="Show photos"
className="rounded-full"
>
<span data-testid="avatar" />
</PlacePhotoGallery>
);
const trigger = screen.getByRole('button', { name: 'Show photos' });
expect(trigger).toHaveClass('rounded-full');
expect(trigger).toContainElement(screen.getByTestId('avatar'));
expect(trigger).toHaveAttribute('aria-busy', 'false');
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-busy', 'true');
expect(trigger).toHaveClass('opacity-70');
await act(async () =>
settle({
photos: [
{
key: 'k0',
url: '/api/maps/place-photo/k0/bytes',
attribution: 'Alice',
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'wikimedia',
},
],
facts: [],
description: null,
} as unknown as MapsPlaceEnrichmentResult)
);
expect(trigger).toHaveAttribute('aria-busy', 'false');
const gallery = screen.getByRole('dialog', { name: 'Show photos' });
expect(within(gallery).getByRole('img', { name: 'Alice' })).toHaveAttribute(
'src',
'/api/maps/place-photo/k0/bytes'
);
});
it('FE-PLANNER-PHOTOGALLERY-003: the gallery opens on the body, over everything, and closes again', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { container } = render(
<PlacePhotoGallery place={PLACE} language="en" label="Show photos">
<span data-testid="avatar" />
</PlacePhotoGallery>
);
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = screen.getByRole('dialog', { name: 'Show photos' });
expect(gallery.parentElement).toBe(document.body);
expect(container).not.toContainElement(gallery);
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/tower.jpg');
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Show photos' })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,62 @@
// FE-PLANNER-ENRICHCACHE-001 to FE-PLANNER-ENRICHCACHE-005: the session cache the editor's
// details column and the view's photo gallery share, so a place is asked about once.
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__clearEnrichmentCacheForTests,
cacheKeyFor,
readCachedEnrichment,
storeEnrichment,
type PlaceDetailsSelection,
} from './placeEnrichment';
const SELECTION: PlaceDetailsSelection = { placeId: 'way:1', lat: 50.9, lng: 6.96, name: 'Museum Ludwig' };
const RESULT = { photos: [], facts: [], description: null } as unknown as MapsPlaceEnrichmentResult;
beforeEach(() => {
sessionStorage.clear();
__clearEnrichmentCacheForTests();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('placeEnrichment', () => {
it('FE-PLANNER-ENRICHCACHE-001: the key names the version, the place (or its coordinates) and the language', () => {
expect(cacheKeyFor(SELECTION, 'de')).toBe('enrich_v4_way:1_de');
expect(cacheKeyFor({ lat: 1.5, lng: 2, name: 'Pin' }, 'en')).toBe('enrich_v4_coords:1.5:2_en');
});
it('FE-PLANNER-ENRICHCACHE-002: nothing is cached before it is stored, then both caches hold it', () => {
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
storeEnrichment(SELECTION, 'de', RESULT);
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
expect(JSON.parse(sessionStorage.getItem('enrich_v4_way:1_de') ?? '')).toEqual(RESULT);
// Another language is another answer.
expect(readCachedEnrichment(SELECTION, 'en')).toBeUndefined();
});
it('FE-PLANNER-ENRICHCACHE-003: an answer kept in the session outlives the in-memory cache', () => {
storeEnrichment(SELECTION, 'de', RESULT);
__clearEnrichmentCacheForTests();
expect(readCachedEnrichment(SELECTION, 'de')).toEqual(RESULT);
// Taken back into memory: the session is not read a second time.
sessionStorage.clear();
expect(readCachedEnrichment(SELECTION, 'de')).toEqual(RESULT);
});
it('FE-PLANNER-ENRICHCACHE-004: a broken session entry reads as nothing cached', () => {
sessionStorage.setItem(cacheKeyFor(SELECTION, 'de'), '{not json');
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
});
it('FE-PLANNER-ENRICHCACHE-005: a full or blocked session storage still keeps the answer in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(() => storeEnrichment(SELECTION, 'de', RESULT)).not.toThrow();
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
});
});
@@ -0,0 +1,65 @@
// FE-PLANNER-PLIMGCHOICE-001 to FE-PLANNER-PLIMGCHOICE-006: the editor's picture choice
// as a pure model, read the same way by the desktop dialog and the phone sheet.
import { describe, expect, it } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { chosenImage, pendingImagePayload, pickableImages, type PendingPlaceImage } from './placeImageChoice';
const CURRENT = '/uploads/places/current.jpg';
describe('pickableImages', () => {
it('FE-PLANNER-PLIMGCHOICE-001: keeps the jpeg, png, gif and webp files and drops the rest', () => {
const jpg = buildTripFile({ mime_type: 'image/jpeg' });
const png = buildTripFile({ mime_type: 'image/png' });
const gif = buildTripFile({ mime_type: 'image/gif' });
const webp = buildTripFile({ mime_type: 'image/webp' });
const heic = buildTripFile({ mime_type: 'image/heic' });
const svg = buildTripFile({ mime_type: 'image/svg+xml' });
const pdf = buildTripFile({ mime_type: 'application/pdf' });
const unknown = buildTripFile({ mime_type: undefined as unknown as string });
expect(pickableImages([jpg, pdf, png, heic, gif, svg, webp, unknown])).toEqual([jpg, png, gif, webp]);
expect(pickableImages([])).toEqual([]);
});
});
describe('chosenImage', () => {
const upload: PendingPlaceImage = { kind: 'upload', file: new File(['x'], 'a.png'), preview: 'blob:a' };
const attached: PendingPlaceImage = { kind: 'attached', file: buildTripFile({ id: 77, mime_type: 'image/png' }) };
it('FE-PLANNER-PLIMGCHOICE-002: a waiting picture wins over whatever the field holds', () => {
expect(chosenImage('https://img.example/1.jpg', upload, CURRENT)).toEqual({ kind: 'upload' });
expect(chosenImage(null, attached, CURRENT)).toEqual({ kind: 'attached', fileId: 77 });
});
it('FE-PLANNER-PLIMGCHOICE-003: null drops the picture, whether the place had one or not', () => {
expect(chosenImage(null, null, CURRENT)).toEqual({ kind: 'none' });
expect(chosenImage(null, null, null)).toEqual({ kind: 'none' });
});
it("FE-PLANNER-PLIMGCHOICE-004: a URL is a source's photo, unless it is the picture the place already has", () => {
expect(chosenImage('https://img.example/1.jpg', null, CURRENT)).toEqual({
kind: 'url',
url: 'https://img.example/1.jpg',
});
expect(chosenImage(CURRENT, null, CURRENT)).toEqual({ kind: 'current' });
});
it('FE-PLANNER-PLIMGCHOICE-005: an untouched field keeps the current picture, or none on a place without one', () => {
expect(chosenImage(undefined, null, CURRENT)).toEqual({ kind: 'current' });
expect(chosenImage(undefined, null, null)).toEqual({ kind: 'none' });
expect(chosenImage(undefined, null, undefined)).toEqual({ kind: 'none' });
expect(chosenImage('', null, CURRENT)).toEqual({ kind: 'current' });
});
});
describe('pendingImagePayload', () => {
it("FE-PLANNER-PLIMGCHOICE-006: the save carries the file itself, or the attached file's id, or nothing", () => {
const file = new File(['x'], 'a.png');
expect(pendingImagePayload({ kind: 'upload', file, preview: 'blob:a' })).toBe(file);
expect(pendingImagePayload({ kind: 'attached', file: buildTripFile({ id: 9, mime_type: 'image/png' }) })).toEqual({
fileId: 9,
});
expect(pendingImagePayload(null)).toBeUndefined();
});
});
@@ -1,4 +1,6 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import type { TripFile } from '../../types';
import type { PlaceImageOwn } from './PlaceImagePicker';
const PICKABLE = /^image\/(jpeg|png|gif|webp)$/;
@@ -46,3 +48,10 @@ export function pendingImagePayload(pending: PendingPlaceImage | null): PendingI
if (!pending) return undefined;
return pending.kind === 'upload' ? pending.file : { fileId: pending.file.id };
}
/** Whether the picker has a tile to show: its sources' photos, or a picture of the place's own, or an upload. */
export function offersPictures(photos: readonly PlacePhotoCandidate[], own: PlaceImageOwn | undefined): boolean {
if (photos.length > 0) return true;
if (!own) return false;
return !!own.currentUrl || own.attached.length > 0 || !!own.pendingPreview || own.canUpload;
}
@@ -0,0 +1,39 @@
// FE-PLANNER-PHOTOCREDIT-001 to FE-PLANNER-PHOTOCREDIT-003: who a source's photo is credited to,
// and the one-line caption the gallery shows under it.
import type { PlacePhotoCandidate } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { creditOf, photoCaption, sourceLabelFor } from './placePhotoCredit';
function photo(over: Partial<PlacePhotoCandidate> = {}): PlacePhotoCandidate {
return {
key: 'k',
url: '/api/maps/place-photo/k/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: null,
sourceUrl: null,
source: 'wikimedia',
...over,
} as PlacePhotoCandidate;
}
describe('placePhotoCredit', () => {
it('FE-PLANNER-PHOTOCREDIT-001: each provider is named by its proper name', () => {
expect(sourceLabelFor('google')).toBe('Google');
expect(sourceLabelFor('wikipedia')).toBe('Wikipedia');
expect(sourceLabelFor('wikimedia')).toBe('Wikimedia Commons');
});
it('FE-PLANNER-PHOTOCREDIT-002: the author is credited, and the provider when there is none', () => {
expect(creditOf(photo())).toBe('Alice');
expect(creditOf(photo({ attribution: null, source: 'google' }))).toBe('Google');
expect(creditOf(photo({ attribution: '', source: 'wikipedia' }))).toBe('Wikipedia');
});
it('FE-PLANNER-PHOTOCREDIT-003: the caption joins the credit and the licence with a comma, not a dot', () => {
expect(photoCaption(photo())).toBe('Alice, CC BY-SA 4.0');
expect(photoCaption(photo({ attribution: null, license: null, source: 'google' }))).toBe('Google');
expect(photoCaption(photo()).includes('·')).toBe(false);
});
});
@@ -1,9 +1,11 @@
// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-014: the place form state behind the desktop
// dialog and the phone sheet, and the inline new category of both ('dialog' and 'sheet').
// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-019: the place form state behind the desktop
// dialog and the phone sheet, its waiting picture, and the inline new category of both
// ('dialog' and 'sheet').
import { act, renderHook } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import type { Category, Place } from '../../types';
import { DEFAULT_FORM } from './PlaceFormModal.helpers';
import { useNewCategory, usePlaceForm, type NewCategoryOptions } from './usePlaceForm';
@@ -163,6 +165,88 @@ function category(id: number): Category {
return { id, name: 'Food', color: '#6366f1', icon: 'MapPin' } as unknown as Category;
}
describe('usePlaceForm, the waiting picture', () => {
let previews: number;
let createObjectURL: MockInstance<typeof URL.createObjectURL>;
let revokeObjectURL: MockInstance<typeof URL.revokeObjectURL>;
beforeEach(() => {
previews = 0;
createObjectURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:preview-${++previews}`);
revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('FE-PLANNER-PLFORM-015: nothing waits on a fresh form', () => {
const { result } = setup();
expect(result.current.pendingImage).toBeNull();
});
it('FE-PLANNER-PLFORM-016: a picture from the device waits with its preview and keeps the field untouched', () => {
const { result } = setup();
act(() => result.current.pickImage('https://img.example/1.jpg'));
const picture = file('front.png', 'image/png');
act(() => result.current.stageUpload(picture));
expect(createObjectURL).toHaveBeenCalledWith(picture);
expect(result.current.pendingImage).toEqual({ kind: 'upload', file: picture, preview: 'blob:preview-1' });
// A source's photo picked before gives way to it.
expect(result.current.form.image_url).toBeUndefined();
});
it('FE-PLANNER-PLFORM-017: replacing the waiting upload frees its preview, whatever replaces it', () => {
const { result } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
act(() => result.current.stageUpload(file('b.png', 'image/png')));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
expect(result.current.pendingImage).toMatchObject({ kind: 'upload', preview: 'blob:preview-2' });
const attached = buildTripFile({ id: 8, mime_type: 'image/png' });
act(() => result.current.stageAttached(attached));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2');
expect(result.current.pendingImage).toEqual({ kind: 'attached', file: attached });
expect(result.current.form.image_url).toBeUndefined();
// An attached image has no preview of ours to free.
revokeObjectURL.mockClear();
act(() => result.current.pickImage('https://img.example/2.jpg'));
expect(revokeObjectURL).not.toHaveBeenCalled();
expect(result.current.pendingImage).toBeNull();
expect(result.current.form.image_url).toBe('https://img.example/2.jpg');
});
it('FE-PLANNER-PLFORM-018: removing the picture drops what waits and empties the field for the save', () => {
const { result } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
act(() => result.current.removeImage());
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
expect(result.current.pendingImage).toBeNull();
expect(result.current.form.image_url).toBeNull();
// A fresh opening starts without a waiting picture as well.
act(() => result.current.stageUpload(file('b.png', 'image/png')));
act(() => result.current.openForm(DEFAULT_FORM, null, null));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2');
expect(result.current.pendingImage).toBeNull();
});
it('FE-PLANNER-PLFORM-019: closing the form frees the preview still waiting, and only that one', () => {
const { result, unmount } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
revokeObjectURL.mockClear();
unmount();
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
const empty = setup();
revokeObjectURL.mockClear();
empty.unmount();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
});
describe('useNewCategory', () => {
function setupCategory(options: Pick<NewCategoryOptions, 'variant' | 'create'>) {
const onCreated = vi.fn();
@@ -0,0 +1,225 @@
// FE-PLANNER-PLGALLERY-001 to FE-PLANNER-PLGALLERY-011: the pictures of one place for the
// view's full screen gallery: its own picture first, the sources' photos asked for on
// open, the shared session cache, and an answer that comes back for a place already left.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { MapsPlaceEnrichmentResult, PlacePhotoCandidate } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import type { Place } from '../../types';
import { __clearEnrichmentCacheForTests, readCachedEnrichment, storeEnrichment } from './placeEnrichment';
import { usePlaceGallery } from './usePlaceGallery';
const placeEnrichment = vi.fn();
vi.mock('../../api/client', () => ({ mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) } }));
// One toast for every render, the way the real hook memoises it.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const CUSTOM = '/uploads/places/own.jpg';
const PHOTO: PlacePhotoCandidate = {
key: 'gp~p0',
url: '/api/maps/place-photo/gp~p0/bytes',
attribution: 'Alice',
license: 'CC BY 4.0',
licenseUrl: null,
sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg',
source: 'wikimedia',
} as PlacePhotoCandidate;
const GOOGLE: PlacePhotoCandidate = {
key: 'gp~p1',
url: '/api/maps/place-photo/gp~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google',
} as PlacePhotoCandidate;
const result = (photos: PlacePhotoCandidate[]) =>
({ photos, facts: [], description: null }) as unknown as MapsPlaceEnrichmentResult;
const LAKE = buildPlace({ id: 1, name: 'Lake', lat: 47.5, lng: 11.2, google_place_id: 'gp-lake' });
const CASTLE = buildPlace({ id: 2, name: 'Castle', lat: 48.1, lng: 11.6, osm_id: 'way:2' });
function deferred<T>() {
let resolve: (v: T) => void = () => {};
let reject: (e: unknown) => void = () => {};
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function setup(place: Place, language = 'de') {
return renderHook(({ p, lang }) => usePlaceGallery(p, lang), { initialProps: { p: place, lang: language } });
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { placesEnrichEnabled: true });
placeEnrichment.mockReset();
placeEnrichment.mockResolvedValue(result([PHOTO]));
sessionStorage.clear();
__clearEnrichmentCacheForTests();
Object.values(toast).forEach((fn) => fn.mockClear());
});
describe('usePlaceGallery', () => {
it('FE-PLANNER-PLGALLERY-001: it can open with a picture of its own, or with coordinates the sources can be asked about', () => {
expect(setup({ ...LAKE, image_url: CUSTOM }).result.current.canOpen).toBe(true);
expect(setup(LAKE).result.current.canOpen).toBe(true);
expect(setup({ ...LAKE, lat: null, lng: null } as unknown as Place).result.current.canOpen).toBe(false);
expect(setup({ ...LAKE, lat: 'north' as unknown as number }).result.current.canOpen).toBe(false);
seedStore(useAuthStore, { placesEnrichEnabled: false });
expect(setup(LAKE).result.current.canOpen).toBe(false);
expect(setup({ ...LAKE, image_url: CUSTOM }).result.current.canOpen).toBe(true);
});
it('FE-PLANNER-PLGALLERY-002: with the sources switched off its own picture opens at once, alone', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { result: r } = setup({ ...LAKE, image_url: CUSTOM });
expect(r.current.photos).toEqual([{ id: 'custom', src: CUSTOM, caption: null, captionHref: null }]);
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(placeEnrichment).not.toHaveBeenCalled();
act(() => r.current.close());
expect(r.current.isOpen).toBe(false);
});
it('FE-PLANNER-PLGALLERY-003: nothing of its own and nobody to ask says so instead of opening', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { result: r } = setup(LAKE);
act(() => r.current.open());
expect(r.current.isOpen).toBe(false);
expect(toast.info).toHaveBeenCalledWith('places.details.noPhotos');
});
it('FE-PLANNER-PLGALLERY-004: the sources are asked on open, and their photos join with a linked credit', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r } = setup(LAKE);
expect(placeEnrichment).not.toHaveBeenCalled();
act(() => r.current.open());
expect(r.current.loading).toBe(true);
expect(r.current.isOpen).toBe(false);
expect(placeEnrichment).toHaveBeenCalledWith({
placeId: 'gp-lake',
lat: 47.5,
lng: 11.2,
name: 'Lake',
lang: 'de',
});
await act(async () => pending.resolve(result([PHOTO, GOOGLE])));
expect(r.current.loading).toBe(false);
expect(r.current.isOpen).toBe(true);
expect(r.current.photos).toEqual([
{ id: 'gp~p0', src: PHOTO.url, caption: 'Alice, CC BY 4.0', captionHref: PHOTO.sourceUrl },
{ id: 'gp~p1', src: GOOGLE.url, caption: 'Google', captionHref: null },
]);
// Kept for the editor's details column and the next open.
expect(readCachedEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de')).toBeTruthy();
});
it('FE-PLANNER-PLGALLERY-005: a second open uses the answer it already has', async () => {
const { result: r } = setup(LAKE);
act(() => r.current.open());
await waitFor(() => expect(r.current.isOpen).toBe(true));
act(() => r.current.close());
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(placeEnrichment).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLGALLERY-006: its own picture leads, and a source photo of the same picture is not shown twice', async () => {
placeEnrichment.mockResolvedValue(result([PHOTO, GOOGLE]));
const { result: r } = setup({ ...LAKE, image_url: PHOTO.url });
act(() => r.current.open());
// Open at once on its own picture, while the sources are still asked.
expect(r.current.isOpen).toBe(true);
expect(r.current.loading).toBe(true);
await waitFor(() => expect(r.current.loading).toBe(false));
expect(r.current.photos.map((p) => p.id)).toEqual(['custom', 'gp~p1']);
});
it('FE-PLANNER-PLGALLERY-007: when the sources have nothing either, it says so, the second time too', async () => {
placeEnrichment.mockResolvedValue(result([]));
const { result: r } = setup(CASTLE);
act(() => r.current.open());
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('places.details.noPhotos'));
expect(r.current.isOpen).toBe(false);
// The OSM id is what the server is asked about when there is no Google one.
expect(placeEnrichment.mock.calls[0][0]).toMatchObject({ placeId: 'way:2' });
act(() => r.current.open());
expect(toast.info).toHaveBeenCalledTimes(2);
expect(placeEnrichment).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLGALLERY-008: an answer this session already holds opens without asking', () => {
storeEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de', result([PHOTO]));
const { result: r } = setup(LAKE);
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(r.current.loading).toBe(false);
expect(placeEnrichment).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLGALLERY-009: a failed request is reported, unless its own picture is already on screen', async () => {
placeEnrichment.mockRejectedValue(new Error('network'));
const bare = setup(LAKE);
act(() => bare.result.current.open());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('places.details.error'));
expect(bare.result.current.loading).toBe(false);
bare.unmount();
toast.error.mockClear();
const pictured = setup({ ...CASTLE, image_url: CUSTOM });
act(() => pictured.result.current.open());
await waitFor(() => expect(pictured.result.current.loading).toBe(false));
expect(pictured.result.current.isOpen).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLGALLERY-010: switching place while the sources answer drops that answer: nothing opens, nothing joins', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r, rerender } = setup(LAKE);
act(() => r.current.open());
expect(r.current.loading).toBe(true);
rerender({ p: CASTLE, lang: 'de' });
expect(r.current.loading).toBe(false);
await act(async () => pending.resolve(result([PHOTO])));
expect(r.current.isOpen).toBe(false);
expect(r.current.loading).toBe(false);
expect(r.current.photos).toEqual([]);
expect(toast.error).not.toHaveBeenCalled();
// The answer still belongs to the first place, so it is kept for it.
expect(readCachedEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de')).toBeTruthy();
});
it('FE-PLANNER-PLGALLERY-011: a failure that arrives after the switch is not reported over the next place', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r, rerender } = setup(LAKE);
act(() => r.current.open());
act(() => r.current.close());
rerender({ p: CASTLE, lang: 'de' });
await act(async () => pending.reject(new Error('network')));
expect(toast.error).not.toHaveBeenCalled();
expect(r.current.isOpen).toBe(false);
});
});
@@ -40,12 +40,20 @@ export function usePlaceGallery(place: Place, language: string): UsePlaceGallery
const placesEnrichEnabled = useAuthStore((s) => s.placesEnrichEnabled);
const { t } = useTranslation();
const toast = useToast();
const [candidates, setCandidates] = useState<PlacePhotoCandidate[] | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
// The place a request was made for: an answer that comes back after the user
// moved on to another place is dropped instead of opening over it.
const requestRef = useRef(0);
// Each piece of state names the place it belongs to: a different place reads
// as nothing loaded, nothing open, so the previous answer never flashes.
const [candidatesFor, setCandidatesFor] = useState<{ placeId: number; list: PlacePhotoCandidate[] } | null>(null);
const [openFor, setOpenFor] = useState<number | null>(null);
const [loadingFor, setLoadingFor] = useState<number | null>(null);
const placeId = place.id;
const candidates = candidatesFor?.placeId === placeId ? candidatesFor.list : null;
const isOpen = openFor === placeId;
const loading = loadingFor === placeId;
// The place on screen now, for an answer that arrives after the user moved on.
const shownRef = useRef(placeId);
useEffect(() => {
shownRef.current = placeId;
}, [placeId]);
const selection = useMemo<PlaceDetailsSelection | null>(() => {
if (place.lat == null || place.lng == null) return null;
@@ -60,15 +68,6 @@ export function usePlaceGallery(place: Place, language: string): UsePlaceGallery
};
}, [place.google_place_id, place.amap_poi_id, place.osm_id, place.lat, place.lng, place.name]);
// A different place owns different pictures: drop the previous answer so the
// next open never flashes them.
useEffect(() => {
requestRef.current += 1;
setCandidates(null);
setIsOpen(false);
setLoading(false);
}, [place.id]);
const photos = useMemo<GalleryPhoto[]>(() => {
const list: GalleryPhoto[] = [];
if (place.image_url) list.push({ id: 'custom', src: place.image_url, caption: null, captionHref: null });
@@ -86,26 +85,26 @@ export function usePlaceGallery(place: Place, language: string): UsePlaceGallery
const canOpen = !!place.image_url || (placesEnrichEnabled && !!selection);
const close = useCallback(() => setIsOpen(false), []);
const close = useCallback(() => setOpenFor(null), []);
const open = useCallback(() => {
// The custom image is already on screen — open now and let any provider
// pictures join it as they arrive.
if (place.image_url) setIsOpen(true);
if (place.image_url) setOpenFor(placeId);
if (!placesEnrichEnabled || !selection) {
if (!place.image_url) toast.info(t('places.details.noPhotos'));
return;
}
if (candidates !== null) {
if (place.image_url || candidates.length) setIsOpen(true);
if (place.image_url || candidates.length) setOpenFor(placeId);
else toast.info(t('places.details.noPhotos'));
return;
}
const apply = (list: PlacePhotoCandidate[]) => {
setCandidates(list);
if (place.image_url || list.length) setIsOpen(true);
setCandidatesFor({ placeId, list });
if (place.image_url || list.length) setOpenFor(placeId);
else toast.info(t('places.details.noPhotos'));
};
@@ -115,22 +114,23 @@ export function usePlaceGallery(place: Place, language: string): UsePlaceGallery
return;
}
const request = ++requestRef.current;
const current = () => request === requestRef.current;
setLoading(true);
// An answer for a place the user has left is kept for that place, but opens nothing and says nothing.
const current = () => shownRef.current === placeId;
setLoadingFor(placeId);
mapsApi
.placeEnrichment({ ...selection, lang: language })
.then((result) => {
storeEnrichment(selection, language, result);
if (current()) apply(result.photos);
else setCandidatesFor({ placeId, list: result.photos });
})
.catch((err: unknown) => {
if (current() && !place.image_url) toast.error(translateApiError(t, err, 'places.details.error'));
})
.finally(() => {
if (current()) setLoading(false);
setLoadingFor((now) => (now === placeId ? null : now));
});
}, [place.image_url, placesEnrichEnabled, selection, candidates, language, t, toast]);
}, [place.image_url, placeId, placesEnrichEnabled, selection, candidates, language, t, toast]);
return { photos, open, close, isOpen, loading, canOpen };
}
@@ -0,0 +1,106 @@
// FE-PLANNER-PLIMGFIELD-001 to FE-PLANNER-PLIMGFIELD-004: the editor's picture field the
// desktop dialog and the phone sheet share: the place's attached images, the waiting
// picture's preview, and which tile stands for the choice.
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildReservation, buildTripFile } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTripStore } from '../../store/tripStore';
import type { TripFile } from '../../types';
import type { PendingPlaceImage } from './placeImageChoice';
import { usePlaceImageField } from './usePlaceImageField';
const PHOTO = buildTripFile({ id: 1, place_id: 5, mime_type: 'image/jpeg', original_name: 'front.jpg' });
const PDF = buildTripFile({ id: 2, place_id: 5, mime_type: 'application/pdf' });
const ELSEWHERE = buildTripFile({ id: 3, place_id: 6, mime_type: 'image/png' });
const ON_BOOKING = buildTripFile({
id: 4,
place_id: null,
reservation_id: 90,
mime_type: 'image/webp',
} as Partial<TripFile>);
const handlers = () => ({ stageUpload: vi.fn(), stageAttached: vi.fn(), removeImage: vi.fn() });
beforeEach(() => {
resetAllStores();
useTripStore.setState({
files: [PHOTO, PDF, ELSEWHERE, ON_BOOKING],
reservations: [buildReservation({ id: 90, place_id: 5 })],
} as never);
});
describe('usePlaceImageField', () => {
it("FE-PLANNER-PLIMGFIELD-001: offers the place's attached images, its booking's included, and its current picture", () => {
const h = handlers();
const { result } = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: undefined,
pendingImage: null,
canUploadFiles: true,
...h,
})
);
expect(result.current.own.attached).toEqual([PHOTO, ON_BOOKING]);
expect(result.current.own.currentUrl).toBe('/uploads/places/5.jpg');
expect(result.current.own.pendingPreview).toBeNull();
expect(result.current.own.canUpload).toBe(true);
expect(result.current.own.onUpload).toBe(h.stageUpload);
expect(result.current.own.onPickAttached).toBe(h.stageAttached);
expect(result.current.own.onRemove).toBe(h.removeImage);
expect(result.current.chosen).toEqual({ kind: 'current' });
});
it('FE-PLANNER-PLIMGFIELD-002: a new place has nothing attached and no picture, but can still be given one', () => {
const { result } = renderHook(() =>
usePlaceImageField({ place: null, imageUrl: undefined, pendingImage: null, canUploadFiles: true, ...handlers() })
);
expect(result.current.own.attached).toEqual([]);
expect(result.current.own.currentUrl).toBeNull();
expect(result.current.own.canUpload).toBe(true);
expect(result.current.chosen).toEqual({ kind: 'none' });
});
it('FE-PLANNER-PLIMGFIELD-003: a waiting upload shows as its preview and is the choice', () => {
const pending: PendingPlaceImage = { kind: 'upload', file: new File(['x'], 'a.png'), preview: 'blob:a' };
const { result } = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: null },
imageUrl: undefined,
pendingImage: pending,
canUploadFiles: false,
...handlers(),
})
);
expect(result.current.own.pendingPreview).toBe('blob:a');
expect(result.current.own.canUpload).toBe(false);
expect(result.current.chosen).toEqual({ kind: 'upload' });
});
it('FE-PLANNER-PLIMGFIELD-004: a waiting attached image has no preview, and a dropped picture reads as none', () => {
const attached = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: undefined,
pendingImage: { kind: 'attached', file: PHOTO },
canUploadFiles: true,
...handlers(),
})
);
expect(attached.result.current.own.pendingPreview).toBeNull();
expect(attached.result.current.chosen).toEqual({ kind: 'attached', fileId: 1 });
const dropped = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: null,
pendingImage: null,
canUploadFiles: true,
...handlers(),
})
);
expect(dropped.result.current.chosen).toEqual({ kind: 'none' });
});
});
@@ -1046,14 +1046,16 @@ describe('TripPlannerPage — day detail and inspector', () => {
expect(useTripStore.getState().assignments['7'][0].participants).toEqual([{ user_id: 5 }])
await act(async () => { await props('inspector').onUpdatePlace(1, { name: 'X' }) })
await act(async () => { await props('inspector').onUploadImage(1, new File(['x'], 'a.png')) })
// The picture is chosen in the editor now: the inspector gets no way to upload or take one.
expect(props('inspector').onUploadImage).toBeUndefined()
expect(props('inspector').onImageFromFile).toBeUndefined()
await act(async () => { await props('inspector').onRate(1, 4) })
await act(async () => { await props('inspector').onFileUpload(new FormData()) })
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
expect(tripActions.addFile).toHaveBeenCalled()
expect(tripActions.updatePlace).toHaveBeenCalledWith(42, 1, { name: 'X' })
expect(tripActions.uploadPlaceImage).toHaveBeenCalled()
expect(tripActions.uploadPlaceImage).not.toHaveBeenCalled()
expect(tripActions.ratePlace).toHaveBeenCalledWith(42, 1, 4)
})
@@ -1095,7 +1097,7 @@ describe('TripPlannerPage — day detail and inspector', () => {
expect(hookState.handleDeletePlace).toHaveBeenCalledWith(1)
await act(async () => { await props('inspector').onUpdatePlace(1, { name: 'Y' }) })
await act(async () => { await props('inspector').onUploadImage(1, new File(['x'], 'a.png')) })
expect(props('inspector').onUploadImage).toBeUndefined()
await act(async () => { await props('inspector').onRate(1, 3) })
await act(async () => { await props('inspector').onFileUpload(new FormData()) })
@@ -0,0 +1,78 @@
// FE-TP-PLIMGSAVE-001 to FE-TP-PLIMGSAVE-006: the waiting picture taken off what the place
// form saves, and put in place once the place has an id.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setForcedOffline } from '../../sync/networkMode';
import type { Place } from '../../types';
import { savePendingImage, takePendingImage } from './placeImageSave';
const needsConnection = () => 'places.image.needsConnection';
function actions() {
return {
uploadPlaceImage: vi.fn(async () => ({ id: 1 }) as Place),
setPlaceImageFromFile: vi.fn(async () => ({ id: 1 }) as Place),
};
}
afterEach(() => {
setForcedOffline(false);
});
describe('takePendingImage', () => {
it('FE-TP-PLIMGSAVE-001: takes the picture off the data, online', () => {
const file = new File(['x'], 'a.png');
const data: { name: string; _pendingImage?: File | { fileId: number } } = { name: 'Lake', _pendingImage: file };
expect(takePendingImage(data, needsConnection)).toBe(file);
expect('_pendingImage' in data).toBe(false);
expect(data).toEqual({ name: 'Lake' });
});
it('FE-TP-PLIMGSAVE-002: without a picture nothing is taken, offline included', () => {
setForcedOffline(true);
const data: { name: string; _pendingImage?: File } = { name: 'Lake' };
expect(takePendingImage(data, needsConnection)).toBeUndefined();
expect(data).toEqual({ name: 'Lake' });
});
it('FE-TP-PLIMGSAVE-003: offline, a waiting picture refuses the save with the reason', () => {
setForcedOffline(true);
const data = { _pendingImage: { fileId: 4 } };
expect(() => takePendingImage(data, needsConnection)).toThrow('places.image.needsConnection');
});
});
describe('savePendingImage', () => {
it('FE-TP-PLIMGSAVE-004: a file from the device is uploaded, an attached file is taken by its id', async () => {
const a = actions();
const onError = vi.fn();
const file = new File(['x'], 'a.png');
await savePendingImage(a, 42, 7, file, onError);
expect(a.uploadPlaceImage).toHaveBeenCalledWith(42, 7, file);
expect(a.setPlaceImageFromFile).not.toHaveBeenCalled();
await savePendingImage(a, 42, 7, { fileId: 13 }, onError);
expect(a.setPlaceImageFromFile).toHaveBeenCalledWith(42, 7, 13);
expect(onError).not.toHaveBeenCalled();
});
it('FE-TP-PLIMGSAVE-005: nothing waiting, or no place to put it on, sends nothing', async () => {
const a = actions();
await savePendingImage(a, 42, 7, undefined, vi.fn());
await savePendingImage(a, 42, undefined, new File(['x'], 'a.png'), vi.fn());
expect(a.uploadPlaceImage).not.toHaveBeenCalled();
expect(a.setPlaceImageFromFile).not.toHaveBeenCalled();
});
it('FE-TP-PLIMGSAVE-006: a refused picture goes to onError instead of failing the save', async () => {
const a = actions();
const refused = new Error('too large');
a.uploadPlaceImage.mockRejectedValueOnce(refused);
a.setPlaceImageFromFile.mockRejectedValueOnce(refused);
const onError = vi.fn();
await expect(savePendingImage(a, 42, 7, new File(['x'], 'a.png'), onError)).resolves.toBeUndefined();
await expect(savePendingImage(a, 42, 7, { fileId: 2 }, onError)).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledWith(refused);
});
});
@@ -1,4 +1,4 @@
// FE-TP-PLACEEDITS-001 to FE-TP-PLACEEDITS-017
// FE-TP-PLACEEDITS-001 to FE-TP-PLACEEDITS-020
//
// Writes to the trip's places, driven straight through usePlaceEdits. The store holds
// the places and day lists the delete and category handlers capture for their undo,
@@ -51,6 +51,8 @@ function makeActions() {
setAssignmentLegMode: vi.fn(async () => undefined),
ratePlace: vi.fn(async () => undefined),
addFile: vi.fn(async () => undefined),
uploadPlaceImage: vi.fn(async () => undefined),
setPlaceImageFromFile: vi.fn(async () => undefined),
deletePlace: vi.fn(async (): Promise<{ tourPlaceIds?: number[] } | undefined> => undefined),
deletePlacesMany: vi.fn(async (): Promise<{ tourPlaceIds?: number[] } | undefined> => undefined),
updatePlacesMany: vi.fn(async () => undefined),
@@ -481,4 +483,57 @@ describe('usePlaceEdits', () => {
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
}
})
it('FE-TP-PLACEEDITS-018: an edited place gets its waiting picture after the update, uploaded or taken from its files', async () => {
const { result } = renderEdits({ editingPlace: lake })
const picture = new File(['x'], 'front.png', { type: 'image/png' })
await act(async () => { await result.current.handleSavePlace({ name: 'Lake', _pendingImage: picture }) })
// The picture rides its own route, never the place's update.
expect(actions.updatePlace).toHaveBeenCalledWith(42, 1, { name: 'Lake' })
expect(actions.uploadPlaceImage).toHaveBeenCalledWith(42, 1, picture)
expect(actions.updatePlace.mock.invocationCallOrder[0]).toBeLessThan(actions.uploadPlaceImage.mock.invocationCallOrder[0])
await act(async () => { await result.current.handleSavePlace({ name: 'Lake', _pendingImage: { fileId: 33 } }) })
expect(actions.setPlaceImageFromFile).toHaveBeenCalledWith(42, 1, 33)
expect(toast.success).toHaveBeenCalledWith('trip.toast.placeUpdated')
})
it('FE-TP-PLACEEDITS-019: a new place gets its picture once the create has given it an id, and a refused one is reported', async () => {
const { result } = renderEdits()
const picture = new File(['x'], 'front.png', { type: 'image/png' })
actions.uploadPlaceImage.mockRejectedValueOnce(new Error('too large'))
let saved: unknown
await act(async () => { saved = await result.current.handleSavePlace({ name: 'Pump', _pendingImage: picture }) })
expect(actions.addPlace).toHaveBeenCalledWith(42, { name: 'Pump' })
expect(actions.uploadPlaceImage).toHaveBeenCalledWith(42, 900, picture)
// The place is saved all the same; only the picture failed.
expect(saved).toMatchObject({ id: 900 })
expect(toast.error).toHaveBeenCalledWith('places.imageUploadError')
expect(toast.success).toHaveBeenCalledWith('trip.toast.placeAdded')
// A create the store could not make has no place to put an attached file on.
actions.addPlace.mockResolvedValueOnce(null)
await act(async () => { await result.current.handleSavePlace({ name: 'Ghost', _pendingImage: { fileId: 5 } }) })
expect(actions.setPlaceImageFromFile).not.toHaveBeenCalled()
})
it('FE-TP-PLACEEDITS-020: offline, a save with a waiting picture is refused before anything is written', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true })
try {
const { result } = renderEdits({ editingPlace: lake })
let refused: unknown
await act(async () => {
await result.current.handleSavePlace({ name: 'Lake', _pendingImage: { fileId: 5 } }).catch((err: unknown) => { refused = err })
})
expect(refused).toEqual(new Error('places.image.needsConnection'))
expect(actions.updatePlace).not.toHaveBeenCalled()
expect(actions.setPlaceImageFromFile).not.toHaveBeenCalled()
} finally {
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
}
})
})
@@ -11,7 +11,7 @@ import { server } from '../../../helpers/msw/server'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-047, plus the 009b, 025b and 029b variants
// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-048, plus the 009b, 025b and 029b variants
// planner.t echoes the key, so every label/placeholder is asserted as its key.
const CATEGORIES = [
@@ -394,7 +394,7 @@ describe('MPlaceEditSheet', () => {
it('FE-MOB-PLEDIT-025b: attaches files chosen through the picker', async () => {
const { planner } = setup()
const file = new File(['x'], 'map.pdf', { type: 'application/pdf' })
const picker = document.querySelector('input[type="file"]') as HTMLInputElement
const picker = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
fireEvent.change(picker, { target: { files: [file] } })
expect(await screen.findByText('map.pdf')).toBeInTheDocument()
@@ -666,6 +666,32 @@ describe('MPlaceEditSheet', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false })
setup({ editingPlace: EDITED })
expect(screen.queryByText('places.details.title')).not.toBeInTheDocument()
// The picture is still chosen in the sheet, by the form's own picker.
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument()
})
it('FE-MOB-PLEDIT-048: a picture picked from the device waits for the save and travels with it', async () => {
const preview = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:sheet-preview')
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
try {
seedStore(useAuthStore, { placesEnrichEnabled: false })
const { planner } = setup({ editingPlace: EDITED })
const picture = new File(['x'], 'gate.png', { type: 'image/png' })
fireEvent.change(document.querySelector('input[type="file"][accept^="image"]') as HTMLInputElement, {
target: { files: [picture] },
})
const tile = await screen.findByRole('button', { name: 'places.uploadImage', pressed: true })
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:sheet-preview')
// Nothing is sent before the save.
expect(planner.handleSavePlace).not.toHaveBeenCalled()
fireEvent.click(submit())
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ _pendingImage: picture }))
} finally {
preview.mockRestore()
revoke.mockRestore()
}
})
it('FE-MOB-PLEDIT-043: a plugin POI pick fills the form and asks the details block about its plugin id, not a Google one', async () => {
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../helpers/msw/server'
import { assignmentsApi } from '../../../../src/api/client'
import MPlaceSheet from '../../../../src/mobile/screens/trip/sheets/MPlaceSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
@@ -13,6 +15,12 @@ import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render
// FE-MOB-PLSH-001 to FE-MOB-PLSH-032
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
// The gallery says "no pictures" through the shared toast, the sheet's own writes through planner.toast.
const galleryToast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
vi.mock('../../../../src/components/shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/components/shared/Toast')>()),
useToast: () => galleryToast,
}))
const DAYS = [
{ id: 1, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
@@ -155,7 +163,9 @@ describe('MPlaceSheet', () => {
expect(screen.getByText('Museum')).toBeInTheDocument()
expect(screen.getByText('Habsburg collections')).toBeInTheDocument()
expect(screen.getByText('Book the skip-the-line ticket')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Change image' })).toBeInTheDocument()
// The picture opens the place's photos; it is changed in the editor.
expect(screen.getByRole('button', { name: 'Show photos' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-003: clears the place selection when closed', () => {
@@ -320,47 +330,54 @@ describe('MPlaceSheet', () => {
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Upload failed'))
})
it('FE-MOB-PLSH-019: uploads and removes the custom place image', async () => {
it('FE-MOB-PLSH-019: the picture opens a gallery of its own picture and the photos of its sources, and writes nothing', async () => {
server.use(
http.post('/api/maps/enrichment', () => HttpResponse.json({
photos: [{
key: 'khm~p0', url: '/api/maps/place-photo/khm~p0/bytes', attribution: 'Alice', license: 'CC BY 4.0',
licenseUrl: null, sourceUrl: 'https://commons.wikimedia.org/wiki/File:KHM.jpg', source: 'wikimedia',
}],
facts: [],
description: null,
})),
)
const { planner } = renderSheet()
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
const openPicker = vi.spyOn(input, 'click')
fireEvent.click(screen.getByRole('button', { name: 'Change image' }))
expect(openPicker).toHaveBeenCalledTimes(1)
expect(document.querySelector('input[type="file"][accept*="image"]')).toBeNull()
const picture = new File(['x'], 'front.png', { type: 'image/png' })
selectFiles(input, [picture])
await waitFor(() => expect(planner.tripActions.uploadPlaceImage).toHaveBeenCalledWith(5, 101, picture))
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }))
// The custom image is already there, so the gallery opens on it at once.
const gallery = await screen.findByRole('dialog', { name: 'Show photos' })
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/khm.jpg')
// The sources' photos join it as they arrive.
expect(await screen.findByText('1 / 2')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }))
await waitFor(() => expect(planner.tripActions.updatePlace).toHaveBeenCalledWith(5, 101, { image_url: null }))
expect(planner.tripActions.uploadPlaceImage).not.toHaveBeenCalled()
expect(planner.tripActions.updatePlace).not.toHaveBeenCalled()
})
it('FE-MOB-PLSH-020: reports a failing image upload and offers upload on a place without one', async () => {
const planner = makePlanner({ selectedPlace: { ...PLACE, image_url: null } })
vi.mocked(planner.tripActions.uploadPlaceImage).mockRejectedValue(new Error('unsupported'))
renderSheet(planner)
expect(screen.getByRole('button', { name: 'Upload image' })).toBeInTheDocument()
it('FE-MOB-PLSH-020: a place without a picture asks the sources first, and says so when they have none', async () => {
let asked = 0
server.use(
http.post('/api/maps/enrichment', () => {
asked += 1
return HttpResponse.json({ photos: [], facts: [], description: null })
}),
)
renderSheet(makePlanner({ selectedPlace: { ...PLACE, image_url: null } }))
expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument()
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
selectFiles(input, [new File(['x'], 'front.png', { type: 'image/png' })])
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Could not upload image'))
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }))
await waitFor(() => expect(galleryToast.info).toHaveBeenCalledWith('No pictures found for this place.'))
expect(asked).toBe(1)
expect(screen.queryByRole('dialog', { name: 'Show photos' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-021: blocks the picker while an image request runs and reports its failure', async () => {
const planner = makePlanner()
let rejectUpdate: (err: Error) => void = () => {}
vi.mocked(planner.tripActions.updatePlace).mockReturnValue(new Promise((_res, rej) => { rejectUpdate = rej }))
it('FE-MOB-PLSH-021: without a picture or a source to ask, the picture is a plain picture', () => {
const planner = makePlanner({ selectedPlace: { ...PLACE, image_url: null, lat: null, lng: null } })
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }))
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
const openPicker = vi.spyOn(input, 'click')
fireEvent.click(screen.getByRole('button', { name: 'Change image' }))
expect(openPicker).not.toHaveBeenCalled()
await act(async () => { rejectUpdate(new Error('nope')) })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Could not remove image'))
expect(screen.queryByRole('button', { name: 'Show photos' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-022: hands the place to the save-to-collection picker', () => {
@@ -414,6 +431,9 @@ describe('MPlaceSheet', () => {
seedStore(useAddonStore, { addons: [] })
renderSheet(makePlanner({ can: () => false, canUploadFiles: false }))
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument()
// Looking at the photos is no edit, so a reader keeps it.
expect(screen.getByRole('button', { name: 'Show photos' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove from Day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add to Day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Upload/ })).not.toBeInTheDocument()