mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
de128cc4ae
Unfinished, continued in the next session: t() resolves key.one/.few/... by the language's own plural rule, the parity check knows which forms a language needs, 16 singular/plural key pairs are merged in every locale and every count === 1 branch in the client uses the plural form. Still open: the forms each locale needs for the remaining count strings, the two new labels in every locale, and 35 tests that expect the old English wording.
518 lines
26 KiB
TypeScript
518 lines
26 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
|
import MPlacesBrowser from '../../../../src/mobile/screens/trip/places/MPlacesBrowser'
|
|
import { collectionsApi } from '../../../../src/api/collections'
|
|
import { toursApi } from '../../../../src/api/client'
|
|
import type { CollectionListResponse, TourListItem } from '@trek/shared'
|
|
import { useAddonStore } from '../../../../src/store/addonStore'
|
|
import { useTripStore } from '../../../../src/store/tripStore'
|
|
import { resetAllStores, seedStore } from '../../../helpers/store'
|
|
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
|
import type { AssignmentsMap, Category, Day, Place } from '../../../../src/types'
|
|
|
|
// FE-MOB-PBROW-001 to FE-MOB-PBROW-036
|
|
|
|
const CATEGORIES = [
|
|
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
|
|
// No colour — the panel and the row fall back to the muted token.
|
|
{ id: 2, name: 'Food', color: '', icon: 'utensils' },
|
|
] as unknown as Category[]
|
|
|
|
const DAYS = [
|
|
{ id: 21, trip_id: 1, day_number: 1, date: '2026-05-01' },
|
|
{ id: 22, trip_id: 1, day_number: 2, date: '2026-05-02' },
|
|
] as unknown as Day[]
|
|
|
|
function place(over: Partial<Place>): Place {
|
|
return {
|
|
id: 0, trip_id: 1, name: '', address: null, description: null, category_id: null,
|
|
lat: 48.2, lng: 16.3, image_url: null, google_place_id: null, osm_id: null,
|
|
route_geometry: null, route_color: null,
|
|
...over,
|
|
} as unknown as Place
|
|
}
|
|
|
|
const LOUVRE = place({ id: 1, name: 'Louvre', address: 'Rue de Rivoli', category_id: 1 })
|
|
const EIFFEL = place({ id: 2, name: 'Eiffel Tower', description: 'Iron lady' })
|
|
const SEINE = place({ id: 3, name: 'Seine Track', category_id: 2, route_geometry: 'abc', route_color: '#ff0000' })
|
|
const TOUR = place({ id: 4, name: 'Ridge walk', tour_place_id: 4 })
|
|
|
|
const PLACES = [LOUVRE, EIFFEL, SEINE]
|
|
|
|
// Louvre is planned on day 22 (day_number 2), the rest is still in the pool.
|
|
const ASSIGNMENTS: AssignmentsMap = {
|
|
'22': [{ id: 90, day_id: 22, place_id: 1, order_index: 0, place: { id: 1, name: 'Louvre' } }],
|
|
} as unknown as AssignmentsMap
|
|
|
|
function makePlanner(overrides: Partial<TripPlanner> = {}): TripPlanner {
|
|
return buildPlanner({
|
|
places: PLACES,
|
|
categories: CATEGORIES,
|
|
assignments: ASSIGNMENTS,
|
|
days: DAYS,
|
|
...overrides,
|
|
} as Partial<TripPlanner>)
|
|
}
|
|
|
|
function renderBrowser(planner: TripPlanner = makePlanner(), shell: MTripShellApi = buildShell()) {
|
|
const view = render(<MPlacesBrowser planner={planner} shell={shell} />)
|
|
return { ...view, planner, shell }
|
|
}
|
|
|
|
/** Row button of a place — the whole left side of the row is one button. */
|
|
const row = (name: string) => screen.getByRole('button', { name: new RegExp(name) })
|
|
|
|
describe('MPlacesBrowser', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }] })
|
|
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [], incomingInvites: [] } as CollectionListResponse)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-001: lists every place with its count divider', () => {
|
|
renderBrowser()
|
|
expect(screen.getByText('places.count:3')).toBeInTheDocument()
|
|
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
|
expect(screen.getByText('Rue de Rivoli')).toBeInTheDocument()
|
|
// No address → the description takes the sub line.
|
|
expect(screen.getByText('Iron lady')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-002: badges a planned place with its first day and offers no quick-add', () => {
|
|
renderBrowser()
|
|
expect(screen.getByText('planner.dayN:2')).toBeInTheDocument()
|
|
// Two unplanned places → two quick-add buttons.
|
|
expect(screen.getAllByRole('button', { name: 'places.assignToDay' })).toHaveLength(2)
|
|
})
|
|
|
|
it('FE-MOB-PBROW-003: quick-add opens the place-actions sheet with the day picker', () => {
|
|
const { shell } = renderBrowser()
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'places.assignToDay' })[0])
|
|
expect(shell.openSheet).toHaveBeenCalledWith('bract', { placeId: 2, dayPicker: true })
|
|
})
|
|
|
|
it('FE-MOB-PBROW-004: a row tap opens the place-actions sheet without the day picker', () => {
|
|
const { shell } = renderBrowser()
|
|
fireEvent.click(row('Louvre'))
|
|
expect(shell.openSheet).toHaveBeenCalledWith('bract', { placeId: 1, dayPicker: false })
|
|
})
|
|
|
|
it('FE-MOB-PBROW-005: the filter chips write the pool filter to the trip store', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
|
|
expect(useTripStore.getState().placesFilter).toBe('unplanned')
|
|
expect(screen.getByText('places.count:2')).toBeInTheDocument()
|
|
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.planned' }))
|
|
expect(useTripStore.getState().placesFilter).toBe('planned')
|
|
expect(screen.getByText('places.count:1')).toBeInTheDocument()
|
|
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.all' }))
|
|
expect(useTripStore.getState().placesFilter).toBe('all')
|
|
})
|
|
|
|
it('FE-MOB-PBROW-006: the tracks chip only appears with a track and filters to it', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.filterTracks' }))
|
|
expect(screen.getByText('places.count:1')).toBeInTheDocument()
|
|
expect(screen.getByText('Seine Track')).toBeInTheDocument()
|
|
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// The fall back to "all" once the last track is gone lives in useTripPlanner
|
|
// (FE-TP-HOOK-177), which stays mounted while this browser is not.
|
|
it('FE-MOB-PBROW-007: hides the tracks chip when no place carries a track', () => {
|
|
renderBrowser(makePlanner({ places: [LOUVRE, EIFFEL] } as Partial<TripPlanner>))
|
|
expect(screen.queryByRole('button', { name: 'places.filterTracks' })).not.toBeInTheDocument()
|
|
expect(screen.getByText('places.count:2')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-008: entering the browser from the edit segment seeds the unplanned pool', () => {
|
|
renderBrowser(makePlanner(), buildShell({ browseFromEdit: true }))
|
|
expect(useTripStore.getState().placesFilter).toBe('unplanned')
|
|
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-009: the search box matches name and address', () => {
|
|
renderBrowser()
|
|
const input = screen.getByPlaceholderText('places.search')
|
|
fireEvent.change(input, { target: { value: 'rivoli' } })
|
|
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
|
expect(screen.queryByText('Seine Track')).not.toBeInTheDocument()
|
|
|
|
fireEvent.change(input, { target: { value: 'seine' } })
|
|
expect(screen.getByText('Seine Track')).toBeInTheDocument()
|
|
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-010: the category panel toggles the shared filter set and badges its size', () => {
|
|
renderBrowser()
|
|
const catBtn = screen.getByRole('button', { name: 'places.filters' })
|
|
expect(catBtn).toHaveAttribute('aria-expanded', 'false')
|
|
fireEvent.click(catBtn)
|
|
expect(catBtn).toHaveAttribute('aria-expanded', 'true')
|
|
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
|
|
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['1'])
|
|
expect(screen.getByRole('checkbox', { name: 'Sights' })).toHaveAttribute('aria-checked', 'true')
|
|
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
|
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
|
expect(catBtn).toHaveTextContent('1')
|
|
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
|
|
expect(useTripStore.getState().placesCategoryFilter.size).toBe(0)
|
|
})
|
|
|
|
it('FE-MOB-PBROW-032: the panel rating floor narrows the list, badges the button and lands in the store', () => {
|
|
const planner = makePlanner({
|
|
places: [{ ...LOUVRE, rating_avg: 4.5 }, { ...EIFFEL, rating_avg: 3 }, SEINE],
|
|
} as Partial<TripPlanner>)
|
|
renderBrowser(planner)
|
|
const panelBtn = screen.getByRole('button', { name: 'places.filters' })
|
|
fireEvent.click(panelBtn)
|
|
const floors = screen.getByRole('group', { name: 'Filter by rating' })
|
|
fireEvent.click(within(floors).getByRole('button', { name: '4+' }))
|
|
expect(useTripStore.getState().placesRatingFilter).toBe(4)
|
|
expect(within(floors).getByRole('button', { name: '4+' })).toHaveAttribute('aria-pressed', 'true')
|
|
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
|
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Seine Track')).not.toBeInTheDocument()
|
|
expect(panelBtn).toHaveTextContent('1')
|
|
|
|
fireEvent.click(within(floors).getByRole('button', { name: 'All' }))
|
|
expect(useTripStore.getState().placesRatingFilter).toBe('all')
|
|
expect(screen.getByText('Seine Track')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-035: the panel badge counts kinds of filter, like the map badge', () => {
|
|
renderBrowser()
|
|
const panelBtn = screen.getByRole('button', { name: 'places.filters' })
|
|
fireEvent.click(panelBtn)
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))
|
|
// Two categories are one kind of filter.
|
|
expect(panelBtn).toHaveTextContent('1')
|
|
fireEvent.click(within(screen.getByRole('group', { name: 'Filter by rating' })).getByRole('button', { name: '4+' }))
|
|
expect(panelBtn).toHaveTextContent('2')
|
|
// The pool has its own chips in plain sight, so the panel badge leaves it out.
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
|
|
expect(panelBtn).toHaveTextContent('2')
|
|
})
|
|
|
|
it('FE-MOB-PBROW-036: a new rating floor starts a fresh selection, as a new pool does', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
|
|
fireEvent.click(within(screen.getByRole('group', { name: 'Filter by rating' })).getByRole('button', { name: '1+' }))
|
|
expect(screen.queryByText('places.selectionCount:1')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-033: a floor set from the map sheet already thins the list on arrival', () => {
|
|
seedStore(useTripStore, { placesRatingFilter: 5 })
|
|
renderBrowser(makePlanner({ places: [{ ...LOUVRE, rating_avg: 5 }, EIFFEL] } as Partial<TripPlanner>))
|
|
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
|
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-011: the uncategorized row filters the places without a category', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'No Category' }))
|
|
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized'])
|
|
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
|
|
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-012: the uncategorized row is dropped when every place has a category', () => {
|
|
const planner = makePlanner({ places: [LOUVRE, SEINE] } as Partial<TripPlanner>)
|
|
renderBrowser(planner)
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
|
|
expect(screen.queryByRole('checkbox', { name: 'No Category' })).not.toBeInTheDocument()
|
|
expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-013: select mode picks rows and reports the count', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
|
|
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(row('Eiffel Tower'))
|
|
expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument()
|
|
fireEvent.click(row('Louvre'))
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
// Row taps no longer open the actions sheet while selecting.
|
|
expect(screen.queryByRole('button', { name: 'places.assignToDay' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-014: select all / deselect all covers exactly the visible pool', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
|
|
expect(screen.getByText('places.selectionCount:3')).toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.deselectAll' }))
|
|
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-015: switching the pool filter or searching clears the selection', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
|
|
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
|
|
|
|
fireEvent.click(row('Eiffel Tower'))
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
fireEvent.change(screen.getByPlaceholderText('places.search'), { target: { value: 'ei' } })
|
|
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-016: leaving select mode drops the selection', () => {
|
|
renderBrowser()
|
|
const toggle = screen.getByRole('button', { name: 'common.select' })
|
|
fireEvent.click(toggle)
|
|
fireEvent.click(row('Louvre'))
|
|
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
|
fireEvent.click(toggle)
|
|
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-017: the bulk actions stay disabled until something is selected', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
expect(screen.getByRole('button', { name: 'places.changeCategory' })).toBeDisabled()
|
|
expect(screen.getByRole('button', { name: 'inspector.saveToCollection' })).toBeDisabled()
|
|
expect(screen.getByRole('button', { name: 'places.deleteSelected' })).toBeDisabled()
|
|
expect(screen.getByRole('button', { name: 'common.selectAll' })).toBeEnabled()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-018: the bulk category sheet applies the pick and leaves select mode once it resolved', async () => {
|
|
const { planner } = renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.changeCategory' }))
|
|
|
|
expect(screen.getByRole('dialog', { name: 'Change category' })).toBeInTheDocument()
|
|
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Food' })) })
|
|
expect(planner.confirmChangeCategory).toHaveBeenCalledWith([1], 2)
|
|
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-018b: a failed bulk category change keeps the selection for a retry', async () => {
|
|
const planner = makePlanner()
|
|
;(planner.confirmChangeCategory as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('nope'))
|
|
renderBrowser(planner)
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.changeCategory' }))
|
|
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Food' })) })
|
|
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-019: dismissing the category sheet keeps the selection untouched', async () => {
|
|
const { planner } = renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.changeCategory' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
|
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Change category' })).not.toBeInTheDocument())
|
|
expect(planner.confirmChangeCategory).not.toHaveBeenCalled()
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-020: the delete confirmation removes the selection in one call', async () => {
|
|
const { planner } = renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(row('Seine Track'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.deleteSelected' }))
|
|
|
|
expect(screen.getByText('trip.confirm.deletePlaces:2')).toBeInTheDocument()
|
|
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) })
|
|
expect(planner.confirmDeletePlaces).toHaveBeenCalledWith([1, 3])
|
|
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('routes a Tour row to read-only detail instead of mobile Place bulk management', () => {
|
|
const planner = makePlanner({
|
|
places: [LOUVRE, TOUR],
|
|
isTourPlace: vi.fn((placeId: number) => placeId === TOUR.id),
|
|
})
|
|
renderBrowser(planner)
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(row('Ridge walk'))
|
|
|
|
expect(planner.handlePlaceClick).toHaveBeenCalledWith(TOUR.id)
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'places.deleteSelected' })).toBeEnabled()
|
|
expect(screen.getByRole('button', { name: 'places.changeCategory' })).toBeEnabled()
|
|
expect(screen.getByRole('button', { name: 'common.deselectAll' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('keeps Tour-backed Places out of the Places pool and Tours mode browse/attach only', async () => {
|
|
const ridge: TourListItem = {
|
|
place_id: TOUR.id, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
|
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
|
max_hiking_difficulty: 2, planned: false, caution: false,
|
|
}
|
|
seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }, { id: 'tours', enabled: true }] })
|
|
const list = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [ridge] })
|
|
const planner = makePlanner({
|
|
places: [...PLACES, TOUR],
|
|
tours: [ridge],
|
|
isTourPlace: vi.fn((placeId: number) => placeId === TOUR.id),
|
|
})
|
|
const { shell } = renderBrowser(planner)
|
|
|
|
await waitFor(() => expect(screen.getByText('places.count:3')).toBeInTheDocument())
|
|
expect(screen.queryByText('Ridge walk')).not.toBeInTheDocument()
|
|
// The planner owns the tour list; the browser does not fetch a second one.
|
|
expect(list).not.toHaveBeenCalled()
|
|
|
|
fireEvent.click(screen.getByRole('tab', { name: /^Tours$/i }))
|
|
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
|
|
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'mobileTrip.importPlaces' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'common.select' })).not.toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /tours.addToDay|Add to day/i })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-020b: a failed bulk delete keeps the selection for a retry', async () => {
|
|
const planner = makePlanner()
|
|
;(planner.confirmDeletePlaces as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('nope'))
|
|
renderBrowser(planner)
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.deleteSelected' }))
|
|
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) })
|
|
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-021: cancelling the delete keeps the selection intact', () => {
|
|
const { planner } = renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.deleteSelected' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
|
expect(planner.confirmDeletePlaces).not.toHaveBeenCalled()
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-022: the save-to-collection action opens the list picker', async () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'inspector.saveToCollection' }))
|
|
await waitFor(() => expect(collectionsApi.list).toHaveBeenCalled())
|
|
expect(screen.getByRole('dialog', { name: 'Save 1 to a list' })).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Save 1 to a list' })).not.toBeInTheDocument())
|
|
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-023: without the collections addon neither the action nor the sheet exists', () => {
|
|
seedStore(useAddonStore, { addons: [] })
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
expect(screen.queryByRole('button', { name: 'inspector.saveToCollection' })).not.toBeInTheDocument()
|
|
expect(collectionsApi.list).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-024: the add button opens a blank place form', () => {
|
|
const { planner } = renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
|
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
|
|
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
|
|
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
|
|
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
it('FE-MOB-PBROW-025: the ellipsis opens the import sheet', () => {
|
|
const { shell } = renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.importPlaces' }))
|
|
expect(shell.openSheet).toHaveBeenCalledWith('import')
|
|
})
|
|
|
|
it('FE-MOB-PBROW-026: a read-only member keeps the filters but loses every mutation', () => {
|
|
renderBrowser(makePlanner({ can: vi.fn(() => false) } as Partial<TripPlanner>))
|
|
expect(screen.getByRole('button', { name: 'places.all' })).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'mobileTrip.importPlaces' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'common.select' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-027: the empty unplanned pool celebrates instead of reporting no matches', () => {
|
|
renderBrowser(makePlanner({ places: [LOUVRE] } as Partial<TripPlanner>), buildShell({ browseFromEdit: true }))
|
|
expect(screen.getByText('places.allPlanned')).toBeInTheDocument()
|
|
expect(screen.queryByText('places.noneFound')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-034: an unplanned pool emptied by the rating floor reports no matches, not "all planned"', () => {
|
|
seedStore(useTripStore, { placesRatingFilter: 5 })
|
|
renderBrowser(makePlanner({ places: [EIFFEL] } as Partial<TripPlanner>), buildShell({ browseFromEdit: true }))
|
|
expect(screen.getByText('places.noneFound')).toBeInTheDocument()
|
|
expect(screen.queryByText('places.allPlanned')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-028: a search without hits reports no matches', () => {
|
|
renderBrowser()
|
|
fireEvent.change(screen.getByPlaceholderText('places.search'), { target: { value: 'zzz' } })
|
|
expect(screen.getByText('places.noneFound')).toBeInTheDocument()
|
|
expect(screen.getByText('places.count:0')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-029: a deleted place drops out of the selection count', async () => {
|
|
const shell = buildShell()
|
|
const { rerender } = renderBrowser(makePlanner(), shell)
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
|
|
expect(screen.getByText('places.selectionCount:3')).toBeInTheDocument()
|
|
|
|
await act(async () => {
|
|
rerender(<MPlacesBrowser planner={makePlanner({ places: [LOUVRE, EIFFEL] } as Partial<TripPlanner>)} shell={shell} />)
|
|
})
|
|
expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-031: a markdown description in a row reads as formatted text, not as syntax (#2337)', () => {
|
|
const CHARLIE = place({ id: 4, name: 'Checkpoint Charlie', description: '# Checkpoint Charlie\n\nThe **most famous** crossing.' })
|
|
renderBrowser(makePlanner({ places: [CHARLIE], assignments: {} } as Partial<TripPlanner>))
|
|
|
|
expect(screen.getByRole('heading', { name: 'Checkpoint Charlie' })).toBeInTheDocument()
|
|
expect(screen.queryByText(/^# Checkpoint/)).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-PBROW-030: a selection that only matches the visible pool in size is not "all selected"', () => {
|
|
renderBrowser()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
|
fireEvent.click(row('Louvre'))
|
|
fireEvent.click(row('Eiffel Tower'))
|
|
|
|
// Narrow to the two categorised places: two visible, two selected — but not
|
|
// the same two, so the toolbar must still offer select-all.
|
|
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))
|
|
|
|
expect(screen.getByText('places.count:2')).toBeInTheDocument()
|
|
expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'common.selectAll' })).toBeInTheDocument()
|
|
})
|
|
})
|