Files
TREK/client/src/components/Planner/PlaceDetailsColumn.test.tsx
T

637 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* FE-PDC-001..015 — the detail column beside the add-place search field.
*
* mapsApi.placeEnrichment is mocked; these cover the load states, the caches,
* the abort on selection change, image picking and the attribution rendering
* that the Commons licences require.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import type { MapsPlaceEnrichmentResult } from '@trek/shared'
import PlaceDetailsColumn, { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './PlaceDetailsColumn'
const placeEnrichment = vi.fn()
vi.mock('../../api/client', () => ({ mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) } }))
/** Echo the key back, which keeps the assertions readable. */
const t = ((key: string) => key) as never
const SELECTION: PlaceDetailsSelection = { placeId: 'way:1', lat: 50.9, lng: 6.96, name: 'Museum Ludwig' }
const COMMONS_PHOTO = {
key: 'way:1~p0',
url: '/api/maps/place-photo/way%3A1~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:X.jpg',
source: 'wikimedia' as const,
}
const GOOGLE_PHOTO = {
key: 'way:1~p1',
url: '/api/maps/place-photo/way%3A1~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google' as const,
}
const RESULT: MapsPlaceEnrichmentResult = {
photos: [COMMONS_PHOTO],
facts: [],
description: {
text: 'Ein Museum in Köln.',
source: 'wikipedia',
sourceUrl: 'https://de.wikipedia.org/wiki/Museum_Ludwig',
license: 'CC BY-SA 4.0',
},
}
function renderColumn(props: Partial<React.ComponentProps<typeof PlaceDetailsColumn>> = {}) {
const onPickImage = vi.fn()
const onAdoptDescription = vi.fn()
const utils = render(
<PlaceDetailsColumn
selection={SELECTION}
onPickImage={onPickImage}
onAdoptDescription={onAdoptDescription}
hasDescription={false}
language="de"
t={t}
{...props}
/>,
)
return { ...utils, onPickImage, onAdoptDescription }
}
beforeEach(() => {
placeEnrichment.mockReset()
placeEnrichment.mockResolvedValue(RESULT)
sessionStorage.clear()
__clearEnrichmentCacheForTests()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('PlaceDetailsColumn', () => {
it('FE-PDC-001: shows the empty state and calls nothing while nothing is selected', () => {
renderColumn({ selection: null })
expect(screen.getByText('places.details.empty')).toBeInTheDocument()
expect(placeEnrichment).not.toHaveBeenCalled()
})
it('FE-PDC-002: shows a loading state, then the result', async () => {
let settle: (v: MapsPlaceEnrichmentResult) => void = () => {}
placeEnrichment.mockReturnValue(new Promise<MapsPlaceEnrichmentResult>((r) => { settle = r }))
renderColumn()
expect(screen.getByText('places.details.loading')).toBeInTheDocument()
settle(RESULT)
await waitFor(() => expect(screen.getByText('Ein Museum in Köln.')).toBeInTheDocument())
})
it('FE-PDC-003: sends the selection and the language to the endpoint', async () => {
renderColumn()
await waitFor(() => expect(placeEnrichment).toHaveBeenCalled())
expect(placeEnrichment.mock.calls[0][0]).toEqual({
placeId: 'way:1',
lat: 50.9,
lng: 6.96,
name: 'Museum Ludwig',
lang: 'de',
})
})
it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
renderColumn()
const credit = await screen.findByText('Alice')
// Links to the file description page, where the full terms are.
expect(credit.closest('a')).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:X.jpg')
const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a' })
expect(licence).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/')
})
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()
expect(await screen.findByText('Google')).toBeInTheDocument()
})
it('FE-PDC-006: picking a picture reports its proxy URL', async () => {
const { onPickImage } = renderColumn()
fireEvent.click(await screen.findByRole('button', { name: /places.details.pickImage/ }))
expect(onPickImage).toHaveBeenCalledWith('/api/maps/place-photo/way%3A1~p0/bytes')
})
it('FE-PDC-007: clicking the picked picture again clears the hero image', async () => {
const { onPickImage } = renderColumn({ selectedImageUrl: COMMONS_PHOTO.url })
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(null)
})
it('FE-PDC-008: adopting the description hands the text up', async () => {
const { onAdoptDescription } = renderColumn()
fireEvent.click(await screen.findByRole('button', { name: 'places.details.adopt' }))
expect(onAdoptDescription).toHaveBeenCalledWith('Ein Museum in Köln.')
})
it('FE-PDC-009: will not overwrite a description the user already wrote', async () => {
renderColumn({ hasDescription: true })
expect(await screen.findByRole('button', { name: 'places.details.adopt' })).toBeDisabled()
})
it('FE-PDC-010: shows where the description came from', async () => {
renderColumn()
const link = await screen.findByRole('link', { name: /Wikipedia/ })
expect(link).toHaveAttribute('href', 'https://de.wikipedia.org/wiki/Museum_Ludwig')
// The photo credit carries the same licence string, so scope to the source
// line that sits directly under the description.
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
})
it('FE-PDC-010b: credits a website description by host', async () => {
// The source labels used to be a ternary chain ending in 'Wikipedia', so a
// restaurant's own marketing copy would have been credited to an
// encyclopaedia. There is no fixed name for "the place's own website", and
// the host is the honest one.
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: {
text: 'Pizza in Rostock, seit 2015.',
source: 'website',
sourceUrl: 'https://www.losteria.net/rostock',
license: null,
},
})
renderColumn()
const link = await screen.findByRole('link', { name: /losteria\.net/ })
expect(link).toHaveAttribute('href', 'https://www.losteria.net/rostock')
expect(screen.queryByText(/Wikipedia/)).not.toBeInTheDocument()
})
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
renderColumn()
expect(await screen.findByText('places.details.disabled')).toBeInTheDocument()
})
it('FE-PDC-012: says so when a place yields nothing at all', async () => {
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [] })
renderColumn()
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
})
it('FE-PDC-016: shows the OpenStreetMap facts, which is all a restaurant gets', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
description: null,
facts: [
{ kind: 'cuisine', value: 'regional', url: null },
// A payload written before hours became a field of their own. The chip
// list drops it rather than showing a truncated week beside the block.
{ kind: 'openingHours', value: 'Mo-Sa 17:30+', url: null },
{ kind: 'menu', value: null, url: 'https://example.org/menu' },
{ kind: 'outdoorSeating', value: null, url: null },
],
})
renderColumn()
// A value is shown verbatim; a plain yes falls back to the translated label.
expect(await screen.findByText('regional')).toBeInTheDocument()
expect(screen.queryByText('Mo-Sa 17:30+')).not.toBeInTheDocument()
expect(screen.getByText('places.details.fact.outdoorSeating')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /places.details.fact.menu/ })).toHaveAttribute(
'href',
'https://example.org/menu',
)
})
it('FE-PDC-038: a fact url that is not http(s) stays a chip, never a link', async () => {
// fact.url comes straight from an OSM tag anyone can edit, so a
// javascript: value must not end up in an href.
placeEnrichment.mockResolvedValue({
photos: [],
description: null,
facts: [{ kind: 'menu', value: 'Menu', url: 'javascript:alert(1)' }],
})
renderColumn()
expect(await screen.findByText('Menu')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Menu/ })).not.toBeInTheDocument()
})
it('FE-PDC-017: renders facts even when there is neither a picture nor a description', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
description: null,
facts: [{ kind: 'cuisine', value: 'pizza', url: null }],
})
renderColumn()
expect(await screen.findByText('pizza')).toBeInTheDocument()
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
})
it('FE-PDC-018: names Wikivoyage as the source when the text came from there', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: {
text: 'Berlin ist das politische Zentrum Deutschlands.',
source: 'wikivoyage',
sourceUrl: 'https://de.wikivoyage.org/wiki/Berlin',
license: 'CC BY-SA 4.0',
},
})
renderColumn()
const link = await screen.findByRole('link', { name: /Wikivoyage/ })
expect(link).toHaveAttribute('href', 'https://de.wikivoyage.org/wiki/Berlin')
})
it('FE-PDC-013: shows an error state when the request fails', async () => {
placeEnrichment.mockRejectedValue(new Error('network'))
renderColumn()
// The load retries once after 1.5 s before showing the error, so the
// default 1 s findBy timeout is too short.
expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument()
expect(placeEnrichment).toHaveBeenCalledTimes(2)
})
it('FE-PDC-014: serves a second look at the same place from cache', async () => {
const first = renderColumn()
await screen.findByText('Ein Museum in Köln.')
first.unmount()
renderColumn()
await screen.findByText('Ein Museum in Köln.')
expect(placeEnrichment).toHaveBeenCalledTimes(1)
})
it('FE-PDC-015: aborts the pending request when the selection changes', async () => {
placeEnrichment.mockReturnValue(new Promise(() => {})) // never settles
const { rerender, onPickImage, onAdoptDescription } = renderColumn()
await waitFor(() => expect(placeEnrichment).toHaveBeenCalledTimes(1))
const firstSignal = placeEnrichment.mock.calls[0][1] as AbortSignal
expect(firstSignal.aborted).toBe(false)
rerender(
<PlaceDetailsColumn
selection={{ placeId: 'way:2', lat: 1, lng: 2, name: 'Somewhere else' }}
onPickImage={onPickImage}
onAdoptDescription={onAdoptDescription}
hasDescription={false}
language="de"
t={t}
/>,
)
await waitFor(() => expect(firstSignal.aborted).toBe(true))
expect(placeEnrichment).toHaveBeenCalledTimes(2)
})
})
/**
* FE-PDC-019..026 — opening hours and the rating.
*
* Both used to be chips in the fact row: a whole week joined with dots inside a
* `truncate` span, which in a 288px column read "Monday: 11:30 AM – 11:00 PM ·
* Tuesday:…", and a rating rendered as the text "3.8 (873)".
*/
describe('PlaceDetailsColumn — hours and rating', () => {
const WEEK = [
'Monday: 09:00-18:00',
'Tuesday: 09:00-18:00',
'Wednesday: 09:00-18:00',
'Thursday: 09:00-18:00',
'Friday: 09:00-18:00',
'Saturday: 10:00-14:00',
'Sunday: ?',
]
const withHours = (over: Record<string, unknown> = {}) => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: null,
hours: { weekdayDescriptions: WEEK, periods: null, specialDays: null },
...over,
})
}
// The collapsed row shows the CURRENT day in the place's timezone, so a test
// that names a specific line has to say which day it is standing on. Without
// this the suite passed Monday to Friday and failed every weekend, which reads
// as a broken build rather than a test that forgot to pin its clock.
// SELECTION is in Cologne, so midday UTC is safely the same date in Berlin.
const pinDay = (iso: string) => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date(iso))
}
afterEach(() => {
vi.useRealTimers()
})
it('FE-PDC-019: shows only today collapsed, not the whole week', async () => {
pinDay('2026-08-12T12:00:00Z') // Wednesday
withHours()
renderColumn()
// Tuesday and Wednesday carry the same text as Monday, so count instead of
// asserting absence: exactly one line is on screen before expanding.
await screen.findByText('inspector.openingHours')
expect(screen.queryAllByText('09:00-18:00')).toHaveLength(1)
expect(screen.queryByText('10:00-14:00')).not.toBeInTheDocument()
})
it('FE-PDC-019b: the collapsed row follows the day, it is not just the first line', async () => {
// The case that used to take the suite down every Saturday. Worth keeping as
// a test rather than only pinning 019: it is the assertion that the day
// lookup happens at all.
pinDay('2026-08-15T12:00:00Z') // Saturday
withHours()
renderColumn()
await screen.findByText('inspector.openingHours')
expect(screen.queryAllByText('10:00-14:00')).toHaveLength(1)
expect(screen.queryByText('09:00-18:00')).not.toBeInTheDocument()
})
it('FE-PDC-020: opens the full week on click', async () => {
withHours()
renderColumn()
fireEvent.click(await screen.findByRole('button', { expanded: false }))
// Assert on the list rather than on text counts: the collapsed header shows
// today's line too, so a count would depend on which day the suite runs.
const rows = screen.getAllByRole('listitem')
expect(rows).toHaveLength(7)
expect(rows[5]).toHaveTextContent('10:00-14:00')
// A day the tag said nothing about reads as a dash, not a question mark.
expect(rows[6]).toHaveTextContent('–')
})
it('FE-PDC-021: honours the 12-hour clock preference', async () => {
withHours()
renderColumn({ timeFormat: '12h' })
fireEvent.click(await screen.findByRole('button', { expanded: false }))
expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('9:00 AM-6:00 PM')
})
it('FE-PDC-022: says nothing about open or closed without usable periods', async () => {
// An hours line the parser could not read is not evidence that somewhere is
// shut, and a confident wrong badge is worse than a missing one.
withHours()
renderColumn()
await screen.findByText('inspector.openingHours')
expect(screen.queryByText('inspector.opened')).not.toBeInTheDocument()
expect(screen.queryByText('inspector.closed')).not.toBeInTheDocument()
})
it('FE-PDC-023: reports a place open around the clock as open', async () => {
// The 24/7 shape: one period that never closes. Airports and main stations
// are tagged this way, and they showed no hours at all before.
withHours({
hours: {
weekdayDescriptions: WEEK.map((_, i) => `${['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'][i]}: 00:00-24:00`),
periods: [{ open: { day: 0, hour: 0, minute: 0 }, close: null }],
specialDays: null,
},
})
renderColumn()
expect(await screen.findByText('inspector.opened')).toBeInTheDocument()
})
it('FE-PDC-024: renders the rating as stars with its count', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: null,
rating: { value: 3.8, count: 873 },
})
renderColumn({ locale: 'de-DE' })
expect(await screen.findByText('3.8')).toBeInTheDocument()
expect(screen.getByText('(873)')).toBeInTheDocument()
})
it('FE-PDC-025: leaves the brackets off when there is no count', async () => {
// Google's search results carry a rating but never a count.
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: null,
rating: { value: 4, count: null },
})
renderColumn()
expect(await screen.findByText('4.0')).toBeInTheDocument()
expect(screen.queryByText('()')).not.toBeInTheDocument()
})
it('FE-PDC-026: hours alone are content, not an empty column', async () => {
withHours()
renderColumn()
await screen.findByText('inspector.openingHours')
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
})
})
/**
* FE-PDC-027 — nothing came back for this place.
*
* It used to be a line of grey text plus, when no Google key was set, a panel
* telling the reader to go and ask an administrator for one. The mascot says the
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
*/
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
renderColumn()
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
})
it('FE-PDC-028: stays quiet when the free sources did find something', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
})
renderColumn()
await screen.findByText('Ein Museum.')
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
})
})
/**
* FE-PDC-030..031 — a description that is about the chain, not the place.
*
* The whole safety argument for showing it rests on the heading saying so.
*/
describe('PlaceDetailsColumn — chain description', () => {
it('FE-PDC-030: heads a brand text differently and says what it describes', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: {
text: 'L’Osteria ist eine deutsche Restaurantkette.',
source: 'wikipedia',
sourceUrl: 'https://de.wikipedia.org/wiki/L%E2%80%99Osteria',
license: 'CC BY-SA 4.0',
aboutBrand: true,
},
})
renderColumn()
expect(await screen.findByText('places.details.aboutBrand')).toBeInTheDocument()
expect(screen.getByText('places.details.aboutBrandNote')).toBeInTheDocument()
expect(screen.queryByText('places.details.description')).not.toBeInTheDocument()
})
it('FE-PDC-031: a description of the place itself keeps the plain heading', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
})
renderColumn()
expect(await screen.findByText('places.details.description')).toBeInTheDocument()
expect(screen.queryByText('places.details.aboutBrand')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.aboutBrandNote')).not.toBeInTheDocument()
})
})
/**
* FE-PDC-032..037 — the picture grid.
*
* A lead image plus a thumbnail rail was tried and dropped: it took too much of
* a column that also has to show facts, hours and a description. What must hold
* either way is that picking a picture stays one click.
*/
describe('PlaceDetailsColumn — picture layout', () => {
const photo = (n: number, over: Record<string, unknown> = {}) => ({
key: `way:1~p${n}`,
url: `/api/maps/place-photo/p${n}/bytes`,
attribution: `Author ${n}`,
license: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
sourceUrl: `https://commons.wikimedia.org/wiki/File:P${n}.jpg`,
source: 'wikimedia' as const,
...over,
})
const withPhotos = (n: number) =>
placeEnrichment.mockResolvedValue({
photos: Array.from({ length: n }, (_, i) => photo(i)),
facts: [],
description: null,
})
// alt="" makes an image presentational, so it carries no `img` role — these
// read the DOM directly, the way the strip cases above already do.
const shownImages = () => Array.from(document.querySelectorAll('img'))
it('FE-PDC-032: shows one tile per picture and nothing else', async () => {
withPhotos(4)
renderColumn()
await screen.findByText('places.details.pickImage')
expect(shownImages()).toHaveLength(4)
})
it('FE-PDC-033: a single picture is a single tile', async () => {
withPhotos(1)
renderColumn()
await screen.findByText('places.details.pickImage')
expect(shownImages()).toHaveLength(1)
})
it('FE-PDC-034: picking a picture is one click', async () => {
withPhotos(3)
const { onPickImage } = renderColumn()
await screen.findByText('places.details.pickImage')
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.click(tiles[1])
expect(onPickImage).toHaveBeenCalledWith('/api/maps/place-photo/p1/bytes')
expect(onPickImage).toHaveBeenCalledTimes(1)
})
it('FE-PDC-035: clicking the picked tile again clears the choice', async () => {
withPhotos(2)
const { onPickImage } = renderColumn({ selectedImageUrl: '/api/maps/place-photo/p1/bytes' })
await screen.findByText('places.details.pickImage')
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.click(tiles[1])
expect(onPickImage).toHaveBeenCalledWith(null)
})
it('FE-PDC-036: hovering a tile moves the credit to it without picking it', async () => {
withPhotos(3)
const { onPickImage } = renderColumn()
await screen.findByText('Author 0')
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.mouseEnter(tiles[2])
expect(screen.getByText('Author 2')).toBeInTheDocument()
expect(onPickImage).not.toHaveBeenCalled()
})
it('FE-PDC-037: credits the picture in play, licence included', async () => {
withPhotos(2)
renderColumn()
expect(await screen.findByText('Author 0')).toBeInTheDocument()
expect(screen.getByText('Author 0').closest('a')).toHaveAttribute(
'href',
'https://commons.wikimedia.org/wiki/File:P0.jpg',
)
expect(screen.getAllByText('CC BY-SA 4.0')[0].closest('a')).toHaveAttribute(
'href',
'https://creativecommons.org/licenses/by-sa/4.0/',
)
})
})