mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
d98d597581
A restaurant got nothing: no wiki article, and a coordinate search around it returns whatever was photographed nearby rather than the place itself. Three changes, all on free sources: OpenStreetMap facts. A tagged restaurant carries its cuisine, its opening hours, a link to its menu, outdoor seating, step-free access and diet tags. Those arrive with the details lookup the dialog already makes, so showing them as chips costs nothing and is the single biggest gain for exactly the places that were empty before. Only positive facts are listed - "no outdoor seating" is noise. Commons categories. Where a place has a wikimedia_commons tag, its category is pictures OF it; geosearch falls back in when the category is empty. Wikivoyage before Wikipedia. Same API, same licence, but it describes a place for someone about to visit rather than opening with area in square kilometres. Two sentences instead of three, because this sits beside a form. The column also follows the section-heading idiom Vacay and the dashboard share (uppercase overline, wide tracking), its header matches the Saved places panel beside it, and a lead picture with a row underneath replaces the two-column grid that left a hole on every odd count. Credits wrap over two lines instead of truncating - a cut-off name credits nobody. Saved places stops after ten with a Show more button; search and filters still run over everything, not just the visible page. Form spacing is one rhythm (12px between groups, 8px inside, 4px label to field) and Notes matches Description in height. The OSM notice fits on one line and no longer claims there are no photos, which stopped being true when the column started fetching them from Wikimedia. The clipboard hint is gone; paste still works.
253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
// FE-COMP-COLPICKER-001 to FE-COMP-COLPICKER-016
|
|
import React from 'react'
|
|
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
|
|
import type { Collection, CollectionDetailResponse, CollectionPlace } from '@trek/shared'
|
|
import { collectionsApi } from '../../api/collections'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
|
import { buildUser } from '../../../tests/helpers/factories'
|
|
import { useTranslation } from '../../i18n/TranslationContext'
|
|
import CollectionPicker from './CollectionPicker'
|
|
|
|
type Props = React.ComponentProps<typeof CollectionPicker>
|
|
function Harness(props: Omit<Props, 't'>): React.ReactElement {
|
|
const { t } = useTranslation()
|
|
return <CollectionPicker {...props} t={t} />
|
|
}
|
|
|
|
const listA: Collection = { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }
|
|
const listB: Collection = { id: 2, owner_id: 1, name: 'Rome', color: null }
|
|
|
|
function place(over: Partial<CollectionPlace> & { id: number; collection_id: number; name: string }): CollectionPlace {
|
|
return { status: 'idea', ...over }
|
|
}
|
|
|
|
// Zebra Cafe sits far from the bias box, Alpha Bar right inside it — so the
|
|
// alphabetical and the proximity order are deliberately opposites.
|
|
const zebra = place({ id: 10, collection_id: 1, name: 'Zebra Cafe', address: 'Shibuya 1', lat: 35.6, lng: 139.7 })
|
|
const alpha = place({ id: 11, collection_id: 2, name: 'Alpha Bar', address: 'Trastevere 9', lat: 41.9, lng: 12.5, category: { id: 3, name: 'Bar', color: '#f59e0b', icon: 'beer' } })
|
|
const noCoords = place({ id: 12, collection_id: 2, name: 'Mystery Spot', status: 'visited' })
|
|
|
|
function detail(collection: Collection, places: CollectionPlace[]): CollectionDetailResponse {
|
|
return { collection, places }
|
|
}
|
|
|
|
function setup(over: Partial<Omit<Props, 't'>> = {}) {
|
|
const props: Omit<Props, 't'> = { onSelect: vi.fn(), ...over }
|
|
const view = render(<Harness {...props} />)
|
|
return { ...view, props }
|
|
}
|
|
|
|
describe('CollectionPicker', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
// Keeps PlaceAvatar from reaching for provider photos in jsdom.
|
|
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false })
|
|
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [listA, listB], incomingInvites: [] })
|
|
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
|
|
id === 1 ? detail(listA, [zebra]) : detail(listB, [alpha, noCoords]),
|
|
)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-017: shows the first ten and offers the rest behind a button', async () => {
|
|
// Saved places accumulate across every list, so the panel used to scroll
|
|
// without end.
|
|
const many = Array.from({ length: 23 }, (_, i) =>
|
|
place({ id: 100 + i, collection_id: 1, name: `Place ${String(i).padStart(2, '0')}` }),
|
|
)
|
|
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
|
|
id === 1 ? detail(listA, many) : detail(listB, []),
|
|
)
|
|
setup()
|
|
|
|
expect(await screen.findByText('Place 00')).toBeInTheDocument()
|
|
expect(screen.getByText('Place 09')).toBeInTheDocument()
|
|
expect(screen.queryByText('Place 10')).not.toBeInTheDocument()
|
|
|
|
const more = () => screen.getByRole('button', { name: /show .* more/i })
|
|
fireEvent.click(more())
|
|
expect(screen.getByText('Place 19')).toBeInTheDocument()
|
|
expect(screen.queryByText('Place 20')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(more())
|
|
expect(screen.getByText('Place 22')).toBeInTheDocument()
|
|
// Nothing left to ask for.
|
|
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-018: searching resets the page and looks at every place, not just the visible ten', async () => {
|
|
const many = Array.from({ length: 23 }, (_, i) =>
|
|
place({ id: 100 + i, collection_id: 1, name: `Place ${String(i).padStart(2, '0')}` }),
|
|
)
|
|
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
|
|
id === 1 ? detail(listA, many) : detail(listB, []),
|
|
)
|
|
setup()
|
|
await screen.findByText('Place 00')
|
|
|
|
// "Place 22" is well past the first page; the filter has to reach it.
|
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'Place 22' } })
|
|
|
|
expect(screen.getByText('Place 22')).toBeInTheDocument()
|
|
expect(screen.queryByText('Place 00')).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-001: shows a spinner until the lists have loaded', async () => {
|
|
const { container } = setup()
|
|
expect(container.querySelector('.animate-spin')).toBeTruthy()
|
|
expect(await screen.findByText('Zebra Cafe')).toBeInTheDocument()
|
|
expect(container.querySelector('.animate-spin')).toBeFalsy()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-002: merges the places of every list and shows their addresses', async () => {
|
|
setup()
|
|
expect(await screen.findByText('Zebra Cafe')).toBeInTheDocument()
|
|
expect(screen.getByText('Alpha Bar')).toBeInTheDocument()
|
|
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
|
|
expect(screen.getByText('Shibuya 1')).toBeInTheDocument()
|
|
expect(screen.getByText('Saved places')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-003: sorts alphabetically without a bias box', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
const names = screen.getAllByRole('button', { name: /Alpha Bar|Mystery Spot|Zebra Cafe/ }).map(b => b.textContent)
|
|
expect(names).toEqual(['Alpha BarTrastevere 9', 'Mystery Spot', 'Zebra CafeShibuya 1'])
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-004: a bias box sorts by proximity and pushes coordinate-less places last', async () => {
|
|
setup({ bias: { low: { lat: 35.5, lng: 139.6 }, high: { lat: 35.7, lng: 139.8 } } })
|
|
await screen.findByText('Zebra Cafe')
|
|
const names = screen.getAllByRole('button', { name: /Alpha Bar|Mystery Spot|Zebra Cafe/ }).map(b => b.textContent)
|
|
expect(names).toEqual(['Zebra CafeShibuya 1', 'Alpha BarTrastevere 9', 'Mystery Spot'])
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-005: picking a place hands the whole record to onSelect', async () => {
|
|
const { props } = setup()
|
|
fireEvent.click(await screen.findByRole('button', { name: /Zebra Cafe/ }))
|
|
expect(props.onSelect).toHaveBeenCalledTimes(1)
|
|
expect(props.onSelect).toHaveBeenCalledWith(zebra)
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-006: the search box matches on name', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'zebra' } })
|
|
expect(screen.getByText('Zebra Cafe')).toBeInTheDocument()
|
|
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-007: the search box also matches on address', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'trastevere' } })
|
|
expect(screen.getByText('Alpha Bar')).toBeInTheDocument()
|
|
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-008: a search with no hits falls back to the empty copy', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'nothing here' } })
|
|
expect(screen.getByText('No saved places to add')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-009: the list dropdown counts each list and filters to it', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
|
|
|
const menu = screen.getByRole('listbox')
|
|
expect(within(menu).getByRole('option', { name: /All lists/ })).toHaveTextContent('3')
|
|
expect(within(menu).getByRole('option', { name: /Rome/ })).toHaveTextContent('2')
|
|
|
|
fireEvent.click(within(menu).getByRole('option', { name: /Tokyo 2026/ }))
|
|
expect(screen.getByText('Zebra Cafe')).toBeInTheDocument()
|
|
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
|
|
// The trigger adopts the picked option and the menu closes.
|
|
expect(screen.getByRole('button', { name: /Tokyo 2026/ })).toBeInTheDocument()
|
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-010: the status dropdown filters by saved status', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
fireEvent.click(screen.getByRole('button', { name: /^All$/ }))
|
|
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: /Visited/ }))
|
|
|
|
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
|
|
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-011: the selected option is flagged for assistive tech', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
|
const menu = screen.getByRole('listbox')
|
|
expect(within(menu).getByRole('option', { name: /All lists/ })).toHaveAttribute('aria-selected', 'true')
|
|
expect(within(menu).getByRole('option', { name: /Rome/ })).toHaveAttribute('aria-selected', 'false')
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-012: a click outside closes an open dropdown', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
const trigger = screen.getByRole('button', { name: /All lists/ })
|
|
fireEvent.click(trigger)
|
|
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
|
|
fireEvent.mouseDown(document.body)
|
|
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-013: a mousedown inside the dropdown keeps it open', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
|
fireEvent.mouseDown(screen.getByRole('listbox'))
|
|
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-014: Escape closes an open dropdown', async () => {
|
|
setup()
|
|
await screen.findByText('Zebra Cafe')
|
|
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
|
// A different key leaves it alone.
|
|
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
|
fireEvent.keyDown(document, { key: 'a' })
|
|
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-015: a list whose detail request fails is skipped, the rest still load', async () => {
|
|
vi.mocked(collectionsApi.get).mockImplementation(async (id: number) => {
|
|
if (id === 1) throw new Error('403')
|
|
return detail(listB, [alpha])
|
|
})
|
|
setup()
|
|
expect(await screen.findByText('Alpha Bar')).toBeInTheDocument()
|
|
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-016: a failing list request degrades to the empty state without filters', async () => {
|
|
vi.mocked(collectionsApi.list).mockRejectedValue(new Error('offline'))
|
|
setup()
|
|
expect(await screen.findByText('No saved places to add')).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-COLPICKER-017: with no lists at all the filter row stays hidden', async () => {
|
|
vi.mocked(collectionsApi.list).mockResolvedValue({ collections: [], incomingInvites: [] })
|
|
setup()
|
|
expect(await screen.findByText('No saved places to add')).toBeInTheDocument()
|
|
expect(collectionsApi.get).not.toHaveBeenCalled()
|
|
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
|
|
})
|
|
})
|