Files
TREK/client/src/components/Planner/PlaceDetailsColumn.test.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

791 lines
30 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/',
)
})
})
/**
* FE-PDC-040..049 — the desktop dialog's look (variant="dialog").
*
* The phone sheet keeps the default look, which every case above pins. The
* dialog draws the same content on white cards under eyebrows, with a quiet
* hint card for the empty states, no native tooltips, and no dot between two
* credits.
*/
describe('PlaceDetailsColumn — dialog variant', () => {
const renderDialog = (props: Partial<React.ComponentProps<typeof PlaceDetailsColumn>> = {}) =>
renderColumn({ variant: 'dialog', ...props })
it('FE-PDC-040: waits with a hint card under the column eyebrow while nothing is picked', () => {
const { container } = renderDialog({ selection: null })
expect(screen.getByRole('heading', { name: 'places.details.title' })).toBeInTheDocument()
expect(screen.getByText('places.details.empty')).toBeInTheDocument()
expect(container.querySelector('aside')).toHaveClass('sm:w-80')
expect(placeEnrichment).not.toHaveBeenCalled()
})
it('FE-PDC-041: says it is loading, then shows the content on cards', async () => {
let settle: (v: MapsPlaceEnrichmentResult) => void = () => {}
placeEnrichment.mockReturnValue(new Promise<MapsPlaceEnrichmentResult>((r) => { settle = r }))
renderDialog()
expect(screen.getByText('places.details.loading')).toBeInTheDocument()
settle(RESULT)
expect(await screen.findByText('Ein Museum in Köln.')).toBeInTheDocument()
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument()
})
it('FE-PDC-042: credits a picture and its licence side by side, without a dot or a native tooltip', async () => {
const { onPickImage } = renderDialog()
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).not.toHaveAttribute('title')
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('·')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url)
})
it('FE-PDC-043: a picture without a licence link or source page is still credited', async () => {
placeEnrichment.mockResolvedValue({
photos: [{ ...COMMONS_PHOTO, sourceUrl: null, licenseUrl: null }],
facts: [],
description: null,
})
renderDialog({ selectedImageUrl: COMMONS_PHOTO.url })
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('CC BY-SA 4.0')).toBeInTheDocument()
})
it('FE-PDC-044: the adopt button is a white button, blocked with the reason under it', async () => {
const first = renderDialog()
const adopt = await screen.findByRole('button', { name: 'places.details.adopt' })
expect(adopt).toHaveClass('bg-surface-card')
expect(adopt).not.toHaveAttribute('aria-describedby')
fireEvent.click(adopt)
expect(first.onAdoptDescription).toHaveBeenCalledWith('Ein Museum in Köln.')
first.unmount()
renderDialog({ hasDescription: true })
const blocked = await screen.findByRole('button', { name: 'places.details.adopt' })
expect(blocked).toBeDisabled()
expect(blocked).not.toHaveAttribute('title')
// The line under it is what assistive tech reads as the reason.
expect(blocked).toHaveAccessibleDescription('places.details.adoptBlocked')
})
it('FE-PDC-045: names the description source and its licence apart, not joined by a dot', async () => {
renderDialog()
const link = await screen.findByRole('link', { name: /Wikipedia/ })
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
expect(link.parentElement?.textContent).not.toContain('·')
})
it('FE-PDC-046: facts are white pills without native tooltips; only a safe url becomes a link', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
description: null,
facts: [
{ kind: 'cuisine', value: 'regional', url: null },
{ kind: 'menu', value: null, url: 'https://example.org/menu' },
{ kind: 'takeaway', value: null, url: 'javascript:alert(1)' },
],
})
renderDialog()
const chip = await screen.findByText('regional')
expect(chip.parentElement).not.toHaveAttribute('title')
const menu = screen.getByRole('link', { name: /places.details.fact.menu/ })
expect(menu).toHaveAttribute('href', 'https://example.org/menu')
expect(menu).not.toHaveAttribute('title')
expect(screen.queryByRole('link', { name: /places.details.fact.takeaway/ })).not.toBeInTheDocument()
})
it('FE-PDC-047: hours and the rating keep working on the cards', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: null,
rating: { value: 3.8, count: 873 },
hours: { weekdayDescriptions: ['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: ?'], periods: null, specialDays: null },
})
renderDialog()
expect(await screen.findByText('3.8')).toBeInTheDocument()
expect(screen.getByText('(873)')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getAllByRole('listitem')).toHaveLength(7)
})
it('FE-PDC-048: a chain description says so on its card', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: { text: 'Eine Kette.', source: 'website', sourceUrl: null, license: null, aboutBrand: true },
})
renderDialog()
expect(await screen.findByText('places.details.aboutBrand')).toBeInTheDocument()
expect(screen.getByText('places.details.aboutBrandNote')).toBeInTheDocument()
expect(screen.getByText('Website')).toBeInTheDocument()
})
it('FE-PDC-049: nothing found, switched off and failed each say so in a hint card', async () => {
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [] })
const nothing = renderDialog()
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
nothing.unmount()
__clearEnrichmentCacheForTests()
sessionStorage.clear()
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
const off = renderDialog()
expect(await screen.findByText('places.details.disabled')).toBeInTheDocument()
off.unmount()
__clearEnrichmentCacheForTests()
sessionStorage.clear()
placeEnrichment.mockRejectedValue(new Error('network'))
renderDialog()
expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument()
})
})