mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
209 lines
9.8 KiB
TypeScript
209 lines
9.8 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-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()
|
|
})
|
|
})
|