Files
TREK/client/tests/unit/mobile/collections/MCollections.test.tsx
T
Maurice 1428637694 fix(mobile): the filter pill hugs its chips and the list switcher clears the dock (#2104)
On wider phones the dashboard filter pill stretched across the leftover width instead of wrapping its chips. The collections list switcher could not scroll, so its lower entries and the new-list button hid behind the dock.
2026-09-02 21:06:51 +02:00

651 lines
29 KiB
TypeScript

// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-031
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../helpers/render'
import MCollections from '../../../../src/mobile/screens/collections/MCollections'
import { ALL_SAVED } from '../../../../src/store/collectionStore'
const mocks = vi.hoisted(() => ({ coll: {} as Record<string, unknown> }))
vi.mock('../../../../src/pages/collections/useCollections', () => ({
useCollections: () => mocks.coll,
}))
// Keep the Leaflet/MapLibre stack out of jsdom, but keep its contract.
vi.mock('../../../../src/components/Collections/CollectionMap', () => ({
default: (props: { places: { id: number }[]; onOpenPlace: (id: number) => void; onDeselect: () => void; dark: boolean }) => (
<div data-testid="map">
<span>{`markers:${props.places.length}`}</span>
<span>{`dark:${String(props.dark)}`}</span>
<button type="button" onClick={() => props.onOpenPlace(props.places[0].id)}>map-open</button>
<button type="button" onClick={props.onDeselect}>map-deselect</button>
</div>
),
}))
vi.mock('../../../../src/mobile/screens/collections/MCollPlaceSheet', () => ({
default: (props: {
place: { id: number } | null
onSave: (patch: Record<string, unknown>) => void
onUploadImage: (file: File) => void
onRate: (rating: number) => void
onCopyToTrip: () => void
onRemove: () => void
onSetStatus: (status: string) => void
onClose: () => void
}) =>
props.place ? (
<div>
<button type="button" onClick={() => props.onSave({ notes: 'moin' })}>place-save</button>
<button type="button" onClick={() => props.onUploadImage(new File(['x'], 'p.png'))}>place-upload</button>
<button type="button" onClick={() => props.onRate(4)}>place-rate</button>
<button type="button" onClick={props.onCopyToTrip}>place-copy</button>
<button type="button" onClick={props.onRemove}>place-remove</button>
<button type="button" onClick={() => props.onSetStatus('visited')}>place-status</button>
<button type="button" onClick={props.onClose}>place-close</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/collections/MCollAddSheet', () => ({
default: (props: { open: boolean; collectionId: number | null; collectionName: string; lists: { id: number }[]; onClose: () => void; onAdded: () => void }) =>
props.open ? (
<div>
<span>{`add-target:${String(props.collectionId)}:${props.collectionName}:${props.lists.length}`}</span>
<button type="button" onClick={props.onClose}>add-close</button>
<button type="button" onClick={props.onAdded}>add-added</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/collections/MCollShareSheet', () => ({
default: (props: { open: boolean; collectionId: number | null; onClose: () => void; onAfterLeave: () => void }) =>
props.open ? (
<div>
<span>{`share:${String(props.collectionId)}`}</span>
<button type="button" onClick={props.onClose}>share-close</button>
<button type="button" onClick={props.onAfterLeave}>share-left</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/collections/MCollEditSheet', () => ({
default: (props: { target: unknown; onClose: () => void; onCreated: (id: number) => void; onRequestDelete: (id: number) => void }) =>
props.target ? (
<div>
<span>{`editor:${typeof props.target === 'string' ? props.target : 'list'}`}</span>
<button type="button" onClick={props.onClose}>editor-close</button>
<button type="button" onClick={() => props.onCreated(77)}>editor-created</button>
<button type="button" onClick={() => props.onRequestDelete(3)}>editor-delete</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/collections/MCollLabelsSheet', () => ({
default: (props: {
open: boolean
mode: string
selectedCount: number
onCreate: (name: string) => void
onUpdate: (id: number, body: Record<string, unknown>) => void
onDelete: (id: number) => void
onAssign: (ids: number[]) => void
onSwitchToManage: () => void
onClose: () => void
}) =>
props.open ? (
<div>
<span>{`labels:${props.mode}:${props.selectedCount}`}</span>
<button type="button" onClick={() => props.onCreate('Food')}>labels-create</button>
<button type="button" onClick={() => props.onUpdate(1, { name: 'Drinks' })}>labels-update</button>
<button type="button" onClick={() => props.onDelete(1)}>labels-delete</button>
<button type="button" onClick={() => props.onAssign([1])}>labels-assign</button>
<button type="button" onClick={props.onSwitchToManage}>labels-manage</button>
<button type="button" onClick={props.onClose}>labels-close</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/collections/MCollTripPickerSheet', () => ({
default: (props: { open: boolean; count: number; onCopy: (id: number) => void; onClose: () => void }) =>
props.open ? (
<div>
<span>{`trippicker:${props.count}`}</span>
<button type="button" onClick={() => props.onCopy(9)}>trip-copy</button>
<button type="button" onClick={props.onClose}>trip-close</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/collections/MCollListPickerSheet', () => ({
default: (props: { mode: string | null; lists: { id: number; name: string }[]; count: number; onPick: (id: number) => void; onClose: () => void }) =>
props.mode ? (
<div>
<span>{`listpicker:${props.mode}:${props.count}:${props.lists.map(l => l.id).join(',')}`}</span>
<button type="button" onClick={() => props.onPick(2)}>listpicker-pick</button>
<button type="button" onClick={props.onClose}>listpicker-close</button>
</div>
) : null,
}))
const list = (id: number, name: string, over: Record<string, unknown> = {}) => ({
id, owner_id: 1, name, color: '#38BDF8', place_count: 2, is_owner: true, ...over,
})
const place = (id: number, name: string, over: Record<string, unknown> = {}) => ({
id, collection_id: 1, name, address: `${name} 1`, status: 'idea', ...over,
})
function makeHook(over: Record<string, unknown> = {}): Record<string, unknown> {
const noop = vi.fn()
return {
t: (key: string) => key,
language: 'en',
dark: false,
navigate: noop,
categories: [],
collections: [list(1, 'Hamburg')],
ownedLists: [list(1, 'Hamburg')],
sharedLists: [],
activeCollection: list(1, 'Hamburg'),
isAllSaved: false,
isOwner: true,
canEdit: true,
canDelete: true,
canShare: true,
activeId: 1,
places: [place(10, 'Elbphilharmonie')],
visiblePlaces: [place(10, 'Elbphilharmonie')],
mappable: [place(10, 'Elbphilharmonie', { lat: 53.5, lng: 10 })],
members: [],
incomingInvites: [],
counts: { all: 1, idea: 1, want: 0, visited: 0 },
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
ratingFilter: 'all',
search: '',
selectedPlaceId: null,
selectMode: false,
selectedIds: [],
labels: [],
labelFilter: [],
labelOptions: [],
loading: false,
placesLoading: false,
setView: vi.fn(), setStatusFilter: vi.fn(), setCategoryFilter: vi.fn(), setRatingFilter: vi.fn(),
setLabelFilter: vi.fn(), setSearch: vi.fn(), setSelectedPlaceId: vi.fn(), setSelectMode: vi.fn(),
toggleSelect: vi.fn(), updatePlace: vi.fn(), uploadPlaceImage: vi.fn(),
showLabelManager: false, setShowLabelManager: vi.fn(), labelPickerOpen: false, setLabelPickerOpen: vi.fn(),
handleCreateLabel: vi.fn(), handleUpdateLabel: vi.fn(), handleDeleteLabel: vi.fn(), handleBulkAssignLabels: vi.fn(),
editorTarget: null, setEditorTarget: vi.fn(), handleEditorCreated: vi.fn(),
showAddPlace: false, setShowAddPlace: vi.fn(), handlePlaceAdded: vi.fn(),
confirmDeleteList: null, setConfirmDeleteList: vi.fn(),
showShare: false, setShowShare: vi.fn(), handleAfterLeave: vi.fn(),
selectedPlace: null, handleCloseDetail: vi.fn(), handleDetailStatus: vi.fn(), handleDetailRemove: vi.fn(),
copyIds: null, openCopyForSelectedPlace: vi.fn(), openCopyForSelection: vi.fn(), closeCopy: vi.fn(), handleCopyToTrip: vi.fn(),
handleSelectList: vi.fn(), handleDeleteList: vi.fn(),
handleStatusChange: vi.fn(), handleRatePlace: vi.fn(), handleDeleteSelected: vi.fn(),
allVisibleSelected: false, handleSelectAll: vi.fn(),
listPickerMode: null, setListPickerMode: vi.fn(), handleMoveToList: vi.fn(), handleDuplicateToList: vi.fn(),
...over,
}
}
const fn = (key: string) => mocks.coll[key] as ReturnType<typeof vi.fn>
/** The list switcher is the first control in the header. */
const switcher = () => screen.getAllByRole('button')[0]
describe('MCollections', () => {
beforeEach(() => {
mocks.coll = makeHook()
})
it('FE-MOB-COLSCR-001: shows only the spinner while the first load is in flight', () => {
mocks.coll = makeHook({ loading: true, collections: [], places: [], visiblePlaces: [] })
render(<MCollections />)
expect(screen.queryByPlaceholderText('collections.search')).not.toBeInTheDocument()
expect(document.querySelector('.animate-spin')).not.toBeNull()
})
it('FE-MOB-COLSCR-002: with no lists at all it offers the first-list CTA', () => {
mocks.coll = makeHook({ collections: [], ownedLists: [], activeCollection: null, activeId: null, canShare: false })
render(<MCollections />)
expect(screen.getByText('collections.empty.firstTitle')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }))
expect(fn('setEditorTarget')).toHaveBeenCalledWith('new')
expect(screen.queryByPlaceholderText('collections.search')).not.toBeInTheDocument()
})
it('FE-MOB-COLSCR-003: the header shows the active list and its edit/share actions', () => {
render(<MCollections />)
expect(screen.getByText('Hamburg')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'collections.editListTitle' }))
expect(fn('setEditorTarget')).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }))
fireEvent.click(screen.getByRole('button', { name: 'collections.share.title' }))
expect(fn('setShowShare')).toHaveBeenCalledWith(true)
})
it('FE-MOB-COLSCR-004: the "All saved" union has no edit action and its own title', () => {
mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false })
render(<MCollections />)
expect(screen.getByText('collections.allSaved')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collections.editListTitle' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collections.share.title' })).not.toBeInTheDocument()
// No per-list labels on the union.
expect(screen.queryByRole('button', { name: /collections.labels.title/ })).not.toBeInTheDocument()
})
it('FE-MOB-COLSCR-005: without an active list the generic page title is used', () => {
mocks.coll = makeHook({ activeId: null, activeCollection: null, canShare: false })
render(<MCollections />)
expect(screen.getByText('collections.title')).toBeInTheDocument()
})
it('FE-MOB-COLSCR-006: the switcher dropdown lists owned + shared lists and opens the editor', () => {
mocks.coll = makeHook({
collections: [list(1, 'Hamburg'), list(2, 'Julien Tipps', { is_owner: false, color: null })],
ownedLists: [list(1, 'Hamburg')],
sharedLists: [list(2, 'Julien Tipps', { is_owner: false, color: null, place_count: undefined })],
})
render(<MCollections />)
fireEvent.click(switcher())
expect(screen.getByText('Julien Tipps')).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
fireEvent.click(screen.getByText('Julien Tipps'))
expect(fn('handleSelectList')).toHaveBeenCalledWith(2)
// Picking closes the dropdown again.
expect(screen.queryByText('Julien Tipps')).not.toBeInTheDocument()
})
it('FE-MOB-COLSCR-007: the dropdown switches to the union and creates a new list', () => {
render(<MCollections />)
fireEvent.click(switcher())
fireEvent.click(screen.getByRole('button', { name: /collections.allSaved/ }))
expect(fn('handleSelectList')).toHaveBeenCalledWith(ALL_SAVED)
fireEvent.click(switcher())
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }))
expect(fn('setEditorTarget')).toHaveBeenCalledWith('new')
})
it('FE-MOB-COLSCR-008: the view toggle and the search box drive the shared state', () => {
render(<MCollections />)
fireEvent.click(screen.getByRole('button', { name: 'collections.view.map' }))
expect(fn('setView')).toHaveBeenCalledWith('map')
fireEvent.click(screen.getByRole('button', { name: 'collections.view.list' }))
expect(fn('setView')).toHaveBeenCalledWith('list')
expect(screen.getByRole('button', { name: 'collections.view.list' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.change(screen.getByPlaceholderText('collections.search'), { target: { value: 'elb' } })
expect(fn('setSearch')).toHaveBeenCalledWith('elb')
})
it('FE-MOB-COLSCR-009: the status filter dropdown shows the counts and applies a status', () => {
// The union has no label chip, so the status chip is the only "all" chip.
mocks.coll = makeHook({
isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false,
counts: { all: 5, idea: 2, want: 2, visited: 1 },
})
render(<MCollections />)
fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' }))
expect(screen.getByText('5')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collections.status.want/ }))
expect(fn('setStatusFilter')).toHaveBeenCalledWith('want')
expect(screen.queryByText('5')).not.toBeInTheDocument()
})
it('FE-MOB-COLSCR-010: an active status filter is reflected in the chip label', () => {
mocks.coll = makeHook({
isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, statusFilter: 'visited',
})
render(<MCollections />)
const chip = screen.getByRole('button', { name: 'collections.status.visited' })
expect(chip.className).toContain('bg-m-act')
fireEvent.click(chip)
fireEvent.click(screen.getByRole('button', { name: /collections.status.filterAll/ }))
expect(fn('setStatusFilter')).toHaveBeenCalledWith('all')
})
it('FE-MOB-COLSCR-011: several active labels collapse to their count and can be toggled off or cleared', () => {
mocks.coll = makeHook({
labelOptions: [
{ id: 1, collection_id: 1, name: 'Food', color: '#f00', count: 2 },
{ id: 2, collection_id: 1, name: 'Bar', color: null, count: 1 },
],
labelFilter: [1, 2],
statusFilter: 'want',
})
render(<MCollections />)
// Two active labels → the chip shows how many, not their names.
fireEvent.click(screen.getByRole('button', { name: '2' }))
fireEvent.click(screen.getByRole('button', { name: /^Food\s*2$/ }))
expect(fn('setLabelFilter')).toHaveBeenCalledWith([2])
// The "all" row wipes the filter and closes the panel.
fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' }))
expect(fn('setLabelFilter')).toHaveBeenLastCalledWith([])
expect(screen.queryByRole('button', { name: /^Food\s*2$/ })).not.toBeInTheDocument()
})
it('FE-MOB-COLSCR-012: a single active label shows its name and another one is added to the filter', () => {
mocks.coll = makeHook({
labelOptions: [
{ id: 1, collection_id: 1, name: 'Food', color: null, count: 2 },
{ id: 2, collection_id: 1, name: 'Bar', color: '#0f0', count: 1 },
],
labelFilter: [2],
})
render(<MCollections />)
fireEvent.click(screen.getByRole('button', { name: 'Bar' }))
fireEvent.click(screen.getByRole('button', { name: /^Food\s*2$/ }))
expect(fn('setLabelFilter')).toHaveBeenCalledWith([2, 1])
})
it('FE-MOB-COLSCR-013: a list without labels says so, and the manage chip opens the manager', () => {
render(<MCollections />)
// Status chip and label chip both read "all" while nothing is filtered.
const chips = screen.getAllByRole('button', { name: 'collections.status.filterAll' })
expect(chips).toHaveLength(2)
fireEvent.click(chips[1])
expect(screen.getByText('collections.labels.empty')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }))
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true)
})
it('FE-MOB-COLSCR-014: the backdrop under an open dropdown closes it again', () => {
mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false })
render(<MCollections />)
fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' }))
// Chip plus the "all" row of the open panel.
expect(screen.getAllByRole('button', { name: /collections.status.filterAll/ })).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(screen.getAllByRole('button', { name: /collections.status.filterAll/ })).toHaveLength(1)
})
it('FE-MOB-COLSCR-031: the select chip enters select mode', () => {
render(<MCollections />)
fireEvent.click(screen.getByRole('button', { name: /collections.select$/ }))
expect(fn('setSelectMode')).toHaveBeenCalledWith(true)
})
it('FE-MOB-COLSCR-015: select mode exposes the bulk actions and disables them without a selection', () => {
mocks.coll = makeHook({ selectMode: true, selectedIds: [] })
render(<MCollections />)
expect(screen.getByRole('button', { name: /collections.copyToTrip/ })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /collections.selectAll/ }))
expect(fn('handleSelectAll')).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(fn('setSelectMode')).toHaveBeenCalledWith(false)
})
it('FE-MOB-COLSCR-016: with a selection every bulk action reaches its handler', () => {
mocks.coll = makeHook({
selectMode: true,
selectedIds: [10, 11],
allVisibleSelected: true,
labelOptions: [{ id: 1, collection_id: 1, name: 'Food', count: 1 }],
})
render(<MCollections />)
expect(screen.getByRole('button', { name: /collections.deselectAll/ })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collections.labels.assign/ }))
expect(fn('setLabelPickerOpen')).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByRole('button', { name: /collections.copyToTrip/ }))
expect(fn('openCopyForSelection')).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: /collections.moveToList/ }))
expect(fn('setListPickerMode')).toHaveBeenCalledWith('move')
fireEvent.click(screen.getByRole('button', { name: /collections.duplicateToList/ }))
expect(fn('setListPickerMode')).toHaveBeenCalledWith('copy')
fireEvent.click(screen.getByRole('button', { name: /common.delete/ }))
expect(fn('handleDeleteSelected')).toHaveBeenCalledTimes(1)
})
it('FE-MOB-COLSCR-017: a viewer without edit/delete rights loses move and delete', () => {
mocks.coll = makeHook({ selectMode: true, selectedIds: [10], canEdit: false, canDelete: false, isOwner: false })
render(<MCollections />)
expect(screen.queryByRole('button', { name: /collections.moveToList/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common.delete/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /collections.duplicateToList/ })).toBeInTheDocument()
})
it('FE-MOB-COLSCR-018: the list view renders rows, the loading state and both empty states', () => {
render(<MCollections />)
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
fireEvent.click(screen.getByText('Elbphilharmonie'))
expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(10)
fireEvent.click(screen.getByRole('button', { name: 'collections.status.idea' }))
expect(fn('handleStatusChange')).toHaveBeenCalledWith(10, 'want')
mocks.coll = makeHook({ places: [], visiblePlaces: [], placesLoading: true })
const loadingView = render(<MCollections />)
expect(loadingView.container.querySelector('.animate-spin')).not.toBeNull()
mocks.coll = makeHook({ places: [], visiblePlaces: [] })
render(<MCollections />)
expect(screen.getByText('collections.empty.title')).toBeInTheDocument()
mocks.coll = makeHook({ visiblePlaces: [] })
render(<MCollections />)
expect(screen.getByText('collections.empty.noMatchTitle')).toBeInTheDocument()
})
it('FE-MOB-COLSCR-019: the map view hands the mappable places to the map and reports clicks', () => {
mocks.coll = makeHook({ view: 'map', dark: true })
render(<MCollections />)
expect(screen.getByText('markers:1')).toBeInTheDocument()
expect(screen.getByText('dark:true')).toBeInTheDocument()
// No status/label chips in map view.
expect(screen.queryByRole('button', { name: /collections.status.filterAll/ })).not.toBeInTheDocument()
fireEvent.click(screen.getByText('map-open'))
expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(10)
fireEvent.click(screen.getByText('map-deselect'))
expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(null)
})
it('FE-MOB-COLSCR-019b: the map fills the viewport instead of a fixed-height card (#2104)', () => {
mocks.coll = makeHook({ view: 'map', dark: true })
const { container } = render(<MCollections />)
// Root becomes a viewport-high flex column in map view, like the trip and
// journal maps; the card grows with flex-1 rather than pinning 440px.
expect(container.firstElementChild!.className).toContain('h-dvh')
const card = screen.getByTestId('map').parentElement!
expect(card.className).toContain('flex-1')
expect(card.className).not.toMatch(/h-\[\d+px\]/)
})
it('FE-MOB-COLSCR-019c: the list view keeps the document as the scroller', () => {
mocks.coll = makeHook({ view: 'list' })
const { container } = render(<MCollections />)
expect(container.firstElementChild!.className).not.toContain('h-dvh')
})
it('FE-MOB-COLSCR-019d: the open list switcher scrolls instead of hiding its last entries behind the dock (#2104)', () => {
mocks.coll = makeHook({ view: 'map' })
render(<MCollections />)
fireEvent.click(switcher())
// In the viewport-high map column the panel is the only item that can give
// way, so it has to cap and scroll itself to keep "new list" reachable.
const panel = screen.getByRole('button', { name: /collections.newList/ }).parentElement!
expect(panel.className).toContain('overflow-y-auto')
expect(panel.className).toContain('min-h-0')
expect(panel.className).toMatch(/max-h-\[/)
})
it('FE-MOB-COLSCR-020: a map view with nothing mappable falls back to the empty note', () => {
mocks.coll = makeHook({ view: 'map', mappable: [] })
render(<MCollections />)
expect(screen.queryByTestId('map')).not.toBeInTheDocument()
expect(screen.getByText('collections.empty.noMatchTitle')).toBeInTheDocument()
})
it('FE-MOB-COLSCR-021: the place, add, share, label, trip and list sheets are wired to the hook', () => {
mocks.coll = makeHook({
selectedPlace: place(10, 'Elbphilharmonie'),
selectedPlaceId: 10,
showAddPlace: true,
showShare: true,
labelPickerOpen: true,
selectedIds: [10, 11],
copyIds: [10],
listPickerMode: 'move',
ownedLists: [list(1, 'Hamburg'), list(2, 'Kopenhagen')],
})
render(<MCollections />)
fireEvent.click(screen.getByText('place-save'))
expect(fn('updatePlace')).toHaveBeenCalledWith(10, { notes: 'moin' })
fireEvent.click(screen.getByText('place-upload'))
expect(fn('uploadPlaceImage')).toHaveBeenCalledWith(10, expect.any(File))
fireEvent.click(screen.getByText('place-rate'))
expect(fn('handleRatePlace')).toHaveBeenCalledWith(10, 4)
fireEvent.click(screen.getByText('place-copy'))
expect(fn('openCopyForSelectedPlace')).toHaveBeenCalledTimes(1)
// The add sheet gets the active list as its fixed target.
expect(screen.getByText('add-target:1:Hamburg:2')).toBeInTheDocument()
fireEvent.click(screen.getByText('add-close'))
expect(fn('setShowAddPlace')).toHaveBeenCalledWith(false)
expect(screen.getByText('share:1')).toBeInTheDocument()
fireEvent.click(screen.getByText('share-close'))
expect(fn('setShowShare')).toHaveBeenCalledWith(false)
// The picker is in assign mode while the label picker is open.
expect(screen.getByText('labels:assign:2')).toBeInTheDocument()
fireEvent.click(screen.getByText('labels-manage'))
expect(fn('setLabelPickerOpen')).toHaveBeenCalledWith(false)
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByText('labels-close'))
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(false)
expect(screen.getByText('trippicker:1')).toBeInTheDocument()
fireEvent.click(screen.getByText('trip-copy'))
expect(fn('handleCopyToTrip')).toHaveBeenCalledWith(9)
// The active list is not offered as a move target.
expect(screen.getByText('listpicker:move:2:2')).toBeInTheDocument()
fireEvent.click(screen.getByText('listpicker-pick'))
expect(fn('handleMoveToList')).toHaveBeenCalledWith(2)
fireEvent.click(screen.getByText('listpicker-close'))
expect(fn('setListPickerMode')).toHaveBeenCalledWith(null)
})
it('FE-MOB-COLSCR-022: the list picker duplicates in copy mode', () => {
mocks.coll = makeHook({ listPickerMode: 'copy', selectedIds: [10], ownedLists: [list(1, 'Hamburg'), list(2, 'Kopenhagen')] })
render(<MCollections />)
fireEvent.click(screen.getByText('listpicker-pick'))
expect(fn('handleDuplicateToList')).toHaveBeenCalledWith(2)
expect(fn('handleMoveToList')).not.toHaveBeenCalled()
})
it('FE-MOB-COLSCR-023: the editor sheet reports a created list and a delete request', () => {
mocks.coll = makeHook({ editorTarget: 'new' })
render(<MCollections />)
expect(screen.getByText('editor:new')).toBeInTheDocument()
fireEvent.click(screen.getByText('editor-created'))
expect(fn('handleEditorCreated')).toHaveBeenCalledWith(77)
fireEvent.click(screen.getByText('editor-delete'))
expect(fn('setConfirmDeleteList')).toHaveBeenCalledWith(3)
fireEvent.click(screen.getByText('editor-close'))
expect(fn('setEditorTarget')).toHaveBeenCalledWith(null)
})
it('FE-MOB-COLSCR-024: the delete-list confirm cancels and deletes', () => {
mocks.coll = makeHook({ confirmDeleteList: 1 })
render(<MCollections />)
expect(screen.getByRole('dialog', { name: 'collections.deleteList' })).toBeInTheDocument()
expect(screen.getByText('collections.deleteListConfirm')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(fn('setConfirmDeleteList')).toHaveBeenCalledWith(null)
fireEvent.click(screen.getByRole('button', { name: /common.delete/ }))
expect(fn('handleDeleteList')).toHaveBeenCalledTimes(1)
// Escape dismisses the sheet through the same handler.
fireEvent.keyDown(document, { key: 'Escape' })
expect(fn('setConfirmDeleteList')).toHaveBeenCalledTimes(2)
})
it('FE-MOB-COLSCR-025: the ?create= hand-off from the bottom nav opens the add sheet once', async () => {
render(<MCollections />, { initialEntries: ['/collections?create=place'] })
await waitFor(() => expect(fn('setShowAddPlace')).toHaveBeenCalledWith(true))
// The param is consumed so a re-render does not re-open the sheet.
expect(fn('setShowAddPlace')).toHaveBeenCalledTimes(1)
})
it('FE-MOB-COLSCR-026: the union hand-off opens the sheet when at least one list can take the place', async () => {
mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, canEdit: false })
render(<MCollections />, { initialEntries: ['/collections?create=1'] })
await waitFor(() => expect(fn('setShowAddPlace')).toHaveBeenCalledWith(true))
})
it('FE-MOB-COLSCR-027: without an editable target the hand-off is ignored', async () => {
mocks.coll = makeHook({ activeId: null, activeCollection: null, canEdit: false, canShare: false, ownedLists: [] })
render(<MCollections />, { initialEntries: ['/collections?create=place'] })
await waitFor(() => expect(screen.getByText('collections.title')).toBeInTheDocument())
expect(fn('setShowAddPlace')).not.toHaveBeenCalled()
})
it('FE-MOB-COLSCR-028: an unrelated query param leaves the sheet closed', () => {
render(<MCollections />, { initialEntries: ['/collections?tab=map'] })
expect(fn('setShowAddPlace')).not.toHaveBeenCalled()
})
it('FE-MOB-COLSCR-029: desktop-only category and rating filters are cleared on phone (#1435)', async () => {
mocks.coll = makeHook({ categoryFilter: 3, ratingFilter: 4 })
render(<MCollections />)
await waitFor(() => expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all'))
expect(fn('setRatingFilter')).toHaveBeenCalledWith('all')
})
it('FE-MOB-COLSCR-030: the add sheet has no fixed target while the union is active', () => {
mocks.coll = makeHook({
isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, showAddPlace: true,
})
render(<MCollections />)
expect(screen.getByText('add-target:null::1')).toBeInTheDocument()
})
})