/** * 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> = {}) { const onPickImage = vi.fn() const onAdoptDescription = vi.fn() const utils = render( , ) 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((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( , ) 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 = {}) => { 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 = {}) => ({ 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> = {}) => 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((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() }) })