feat(places): share the rating floor between the lists and the map

The minimum-rating filter lived in the desktop sidebar's local state, so it
only ever narrowed the desktop list: the map markers and the phone's places
list ignored it. It now sits in the trip store beside the pool and category
filters, and one module (utils/placesFilter) holds the pool, category and
rating matchers that the desktop list, the phone list and mapPlaces all use,
instead of three hand-copied versions of the same checks.
This commit is contained in:
EvKoh
2026-10-03 14:14:23 +02:00
committed by Maurice
parent b6c3b899d5
commit a44c966f4d
11 changed files with 240 additions and 47 deletions
@@ -9,6 +9,7 @@ import { tintOf } from './planParts'
import { CategoryTile } from './PlacesBulkCategoryModal'
import type { PlacesFilter, SidebarState } from './usePlacesSidebar'
import { PLACES_SORTS, type PlacesSort } from './placesSort'
import { RATING_FLOORS } from '../../utils/placesFilter'
/**
* Below this the two labels stop fitting side by side and both buttons fall back
@@ -48,8 +49,6 @@ function BandButton({ label, onClick, on = false, lit = false, ariaPressed, aria
)
}
const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
export function PlacesDropOverlay({ t }: SidebarState) {
return (
<div
@@ -1,4 +1,4 @@
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-059
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-060
import { http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render';
@@ -245,6 +245,17 @@ describe('usePlacesSidebar filtering', () => {
expect(names()).toEqual(['Unrated', 'Rated']);
});
it('FE-PLANNER-PSHOOK-060: the star floor lives in the trip store, so the map and the phone list follow it', () => {
const places = [buildPlace({ name: 'Good', rating_avg: 4 }), buildPlace({ name: 'Meh', rating_avg: 2 })];
render(<Host {...makeProps({ places })} />);
act(() => { S.setRatingFilter(3); });
expect(useTripStore.getState().placesRatingFilter).toBe(3);
// And back: a floor picked elsewhere (the phone's filter sheet) narrows this list too.
act(() => { useTripStore.getState().setPlacesRatingFilter('all'); });
expect(S.ratingFilter).toBe('all');
expect(names()).toEqual(['Good', 'Meh']);
});
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
const places = [
buildPlace({ name: 'Cafe Nord', category_id: 4 }),
@@ -18,6 +18,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { placeMatchesSearch } from '../../utils/placeSearch'
import { matchesCategoryFilter, matchesPlacesFilter } from '../../utils/placesFilter'
import { safeHttpUrl } from '../../utils/safeUrl'
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
import type { MenuEntry } from './planParts'
@@ -182,12 +183,13 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const categoryFilters = useTripStore((s) => s.placesCategoryFilter)
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
const [selectMode, setSelectMode] = useState(false)
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
// Minimum average stars, matching the collections filter (#1435): 'all', or a
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
// which put the best first but still left everything else on the list — no
// help at all when the point is to see only what the group actually rated.
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
// In the trip store with the other filters, so the map markers follow it too.
const ratingFilter = useTripStore((s) => s.placesRatingFilter)
const setRatingFilter = useTripStore((s) => s.setPlacesRatingFilter)
// The list's order (#2093), remembered on this device.
const [placesSort, setPlacesSortState] = useState<PlacesSort>(readPlacesSort)
const setPlacesSort = useCallback((sort: PlacesSort) => { setPlacesSortState(sort); writePlacesSort(sort) }, [])
@@ -287,16 +289,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const filtered = useMemo(() => {
const list = places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
} else if (!categoryFilters.has(String(p.category_id))) return false
}
if (!matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds, plannedFilterIds })) return false
if (!placeMatchesSearch(p, search)) return false
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
if (localityFilter && !matchesLocality(localityOf.get(p.id), localityFilter)) return false
return true
})
@@ -311,14 +305,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
* different questions.
*/
const filterCounts = useMemo(() => {
const base = places.filter(p => {
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
} else if (!categoryFilters.has(String(p.category_id))) return false
}
return placeMatchesSearch(p, search)
})
const base = places.filter(p => matchesCategoryFilter(p, categoryFilters) && placeMatchesSearch(p, search))
return {
all: base.length,
unplanned: base.filter(p => !plannedIds.has(p.id)).length,
@@ -144,6 +144,18 @@ describe('placesBrowserModel — tracks pool', () => {
expect(out.map(p => p.id)).toEqual([1])
})
it('FE-MOB-PMODEL-015: the rating floor from the store narrows the pool like it narrows the map', () => {
const rated = [
mkPlace(1, { name: 'Top', rating_avg: 4.6 }),
mkPlace(2, { name: 'Fine', rating_avg: 3.2 }),
mkPlace(3, { name: 'Unrated' }),
]
const out = filterPool(rated, { filter: 'all', categoryFilters: new Set(), ratingFilter: 3, search: '', plannedIds })
expect(out.map(p => p.id)).toEqual([1, 2])
const all = filterPool(rated, { filter: 'all', categoryFilters: new Set(), search: '', plannedIds })
expect(all.map(p => p.id)).toEqual([1, 2, 3])
})
it('FE-MOB-PMODEL-013: poolCounts counts tracks on the category+search base set', () => {
const c = poolCounts(places, new Set(['4']), '', plannedIds)
expect(c.all).toBe(2)
@@ -1,5 +1,8 @@
import type { AssignmentsMap, Day, Place } from '../../../../types'
import { placeMatchesSearch } from '../../../../utils/placeSearch'
import {
matchesCategoryFilter, matchesPlacesFilter, type PlacesRatingFloor,
} from '../../../../utils/placesFilter'
import {
plannedPlaceIds as sharedPlannedPlaceIds,
type PlannedAccommodation, type PlannedReservation,
@@ -8,10 +11,14 @@ import {
/**
* Pure filter model of the mobile places browser. Mirrors the desktop
* sidebar's semantics exactly (usePlacesSidebar + the map's mapPlaces memo):
* the pool filter and category set come from the trip store, so the list here
* the pool filter, the category set and the rating floor come from the trip
* store and go through the same matcher (utils/placesFilter), so the list here
* and the map markers always agree (#1541).
*/
/** Kept under this name for the browser's callers; the predicate itself lives in utils/placesFilter. */
export { matchesCategoryFilter }
export function plannedPlaceIds(
assignments: AssignmentsMap,
accommodations: PlannedAccommodation[] = [],
@@ -38,12 +45,6 @@ export function firstPlannedDayNumbers(assignments: AssignmentsMap, days: Day[])
return result
}
export function matchesCategoryFilter(place: Place, categoryFilters: Set<string>): boolean {
if (categoryFilters.size === 0) return true
if (place.category_id == null) return categoryFilters.has('uncategorized')
return categoryFilters.has(String(place.category_id))
}
export function matchesSearch(place: Place, search: string): boolean {
return placeMatchesSearch(place, search)
}
@@ -51,17 +52,14 @@ export function matchesSearch(place: Place, search: string): boolean {
interface PoolFilterArgs {
filter: string
categoryFilters: Set<string>
/** Minimum average stars; omitted means every rating. */
ratingFilter?: PlacesRatingFloor
search: string
plannedIds: Set<number>
}
export function filterPool(places: Place[], { filter, categoryFilters, search, plannedIds }: PoolFilterArgs): Place[] {
return places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
return matchesCategoryFilter(p, categoryFilters) && matchesSearch(p, search)
})
export function filterPool(places: Place[], { filter, categoryFilters, ratingFilter = 'all', search, plannedIds }: PoolFilterArgs): Place[] {
return places.filter(p => matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds }) && matchesSearch(p, search))
}
/** Chip counts run on the category+search base set, like the desktop tabs. */
@@ -1,4 +1,4 @@
// FE-TP-HOOK-001 to FE-TP-HOOK-175
// FE-TP-HOOK-001 to FE-TP-HOOK-176
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -658,6 +658,20 @@ describe('useTripPlanner — map derivations', () => {
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
})
it('FE-TP-HOOK-176: the rating floor the lists set also thins the markers', async () => {
seedTrip({
places: [geo(1, { rating_avg: 4.5 }), geo(2, { rating_avg: 3 }), geo(3, { rating_avg: null })],
placesRatingFilter: 4,
})
const { result } = await renderPlanner()
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
act(() => { useTripStore.getState().setPlacesRatingFilter('all') })
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1, 2, 3])
})
it('FE-TP-HOOK-028: the unplanned filter drops places that sit on a day', async () => {
const planned = geo(1)
seedTrip({
+6 -10
View File
@@ -81,6 +81,7 @@ import {
} from '../../utils/connectionsVisibility'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
import { matchesPlacesFilter } from '../../utils/placesFilter'
import { pendingStayPlaceIds } from '../../utils/pendingStays'
import { useDayDelete } from './useDayDelete'
import { useDayAdd } from './useDayAdd'
@@ -711,6 +712,7 @@ export function useTripPlanner() {
// switches can't desync the marker set from the filter UI (#1541).
const placesFilter = useTripStore((s) => s.placesFilter)
const placesCategoryFilter = useTripStore((s) => s.placesCategoryFilter)
const placesRatingFilter = useTripStore((s) => s.placesRatingFilter)
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
@@ -749,28 +751,22 @@ export function useTripPlanner() {
const compactIds = compactUnplanned ? plannedPlaceIds({ assignments, accommodations: tripAccommodations, reservations }) : null
const pendingIds = pendingStayPlaceIds(tripAccommodations, reservations)
const filterState = { filter: placesFilter, categoryFilters: placesCategoryFilter, ratingFilter: placesRatingFilter }
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (placesFilter === 'tracks' && !p.route_geometry) return false
if (placesCategoryFilter.size > 0) {
if (p.category_id == null) {
if (!placesCategoryFilter.has('uncategorized')) return false
} else if (!placesCategoryFilter.has(String(p.category_id))) return false
}
// Collapsed-day declutter hides a day's stops on every filter EXCEPT 'planned':
// there the user asked to see the whole plan on the map, so a collapsed day
// must not drop its planned places.
if (placesFilter !== 'planned' && hiddenPlaceIds.has(p.id)) return false
if (placesFilter === 'unplanned' && plannedIds && plannedIds.has(p.id)) return false
if (placesFilter === 'planned' && plannedIds && !plannedIds.has(p.id)) return false
return true
// Pool, categories and rating floor: the very matcher the lists use (#1541).
return matchesPlacesFilter(p, filterState, { plannedIds })
}).map(p => {
// How the map tells a place apart (#2024, #2281); untouched places keep their identity.
const compact = !!compactIds && !compactIds.has(p.id)
const pending = pendingIds.has(p.id)
return compact || pending ? { ...p, _compact: compact, _pending: pending } : p
})
}, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
}, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
// Road trip mode already draws the whole trip its own way, so the overview stands
+11
View File
@@ -36,6 +36,7 @@ import type { TodoSlice } from './slices/todoSlice'
import type { BudgetSlice } from './slices/budgetSlice'
import type { ReservationsSlice } from './slices/reservationsSlice'
import type { FilesSlice } from './slices/filesSlice'
import type { PlacesRatingFloor } from '../utils/placesFilter'
export interface TripStoreState
extends PlacesSlice,
@@ -65,12 +66,18 @@ export interface TripStoreState
// unmounts and remounts (#1541).
placesFilter: string
placesCategoryFilter: Set<string>
// Minimum average stars. Shared for the same reason: the map markers and the
// phone's list follow the floor the desktop list sets, and back (#1435).
placesRatingFilter: PlacesRatingFloor
isLoading: boolean
error: string | null
setSelectedDay: (dayId: number | null) => void
setPlacesFilter: (filter: string) => void
setPlacesCategoryFilter: (categoryIds: Set<string>) => void
setPlacesRatingFilter: (floor: PlacesRatingFloor) => void
/** Lifts the pool, category and rating filters at once. */
resetPlacesFilters: () => void
handleRemoteEvent: (event: WebSocketEvent) => void
resetTrip: () => void
loadTrip: (tripId: number | string) => Promise<void>
@@ -97,12 +104,15 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
selectedDayId: null,
placesFilter: 'all',
placesCategoryFilter: new Set<string>(),
placesRatingFilter: 'all',
isLoading: false,
error: null,
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
setPlacesFilter: (filter: string) => set({ placesFilter: filter }),
setPlacesCategoryFilter: (categoryIds: Set<string>) => set({ placesCategoryFilter: categoryIds }),
setPlacesRatingFilter: (floor: PlacesRatingFloor) => set({ placesRatingFilter: floor }),
resetPlacesFilters: () => set({ placesFilter: 'all', placesCategoryFilter: new Set<string>(), placesRatingFilter: 'all' }),
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
@@ -123,6 +133,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
selectedDayId: null,
placesFilter: 'all',
placesCategoryFilter: new Set<string>(),
placesRatingFilter: 'all',
error: null,
}),
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import { buildPlace } from '../../tests/helpers/factories'
import {
RATING_FLOORS, countActivePlacesFilters, matchesCategoryFilter, matchesPlacesFilter,
matchesPoolFilter, matchesRatingFloor,
} from './placesFilter'
const NONE = new Set<string>()
describe('placesFilter', () => {
it('FE-UTIL-PLFILTER-001: offers the same floors as the collections bar', () => {
expect(RATING_FLOORS).toEqual(['all', 5, 4, 3, 2, 1])
})
it('FE-UTIL-PLFILTER-002: an empty category set keeps everything; "uncategorized" stands for no category', () => {
const tagged = buildPlace({ category_id: 4 })
const bare = buildPlace({ category_id: null })
expect(matchesCategoryFilter(tagged, NONE)).toBe(true)
expect(matchesCategoryFilter(tagged, new Set(['4']))).toBe(true)
expect(matchesCategoryFilter(tagged, new Set(['5']))).toBe(false)
expect(matchesCategoryFilter(bare, new Set(['4']))).toBe(false)
expect(matchesCategoryFilter(bare, new Set(['uncategorized']))).toBe(true)
})
it('FE-UTIL-PLFILTER-003: the rating floor keeps places at or above it and drops the unrated', () => {
expect(matchesRatingFloor(buildPlace({ rating_avg: null }), 'all')).toBe(true)
expect(matchesRatingFloor(buildPlace({ rating_avg: null }), 1)).toBe(false)
expect(matchesRatingFloor(buildPlace({ rating_avg: 4 }), 4)).toBe(true)
expect(matchesRatingFloor(buildPlace({ rating_avg: 3.9 }), 4)).toBe(false)
})
it('FE-UTIL-PLFILTER-004: the pool filter reads the planned sets, and a null set filters nothing', () => {
const planned = buildPlace({ id: 1 })
const loose = buildPlace({ id: 2, route_geometry: '[[1,2],[3,4]]' })
const ids = new Set([1])
expect(matchesPoolFilter(planned, 'all', { plannedIds: ids })).toBe(true)
expect(matchesPoolFilter(planned, 'unplanned', { plannedIds: ids })).toBe(false)
expect(matchesPoolFilter(loose, 'unplanned', { plannedIds: ids })).toBe(true)
expect(matchesPoolFilter(planned, 'planned', { plannedIds: ids })).toBe(true)
expect(matchesPoolFilter(loose, 'planned', { plannedIds: ids })).toBe(false)
expect(matchesPoolFilter(planned, 'tracks', { plannedIds: ids })).toBe(false)
expect(matchesPoolFilter(loose, 'tracks', { plannedIds: ids })).toBe(true)
expect(matchesPoolFilter(planned, 'unplanned', { plannedIds: null })).toBe(true)
expect(matchesPoolFilter(loose, 'planned', { plannedIds: null })).toBe(true)
})
it('FE-UTIL-PLFILTER-005: "planned" can be narrowed to the open day while "unplanned" stays trip-wide', () => {
const elsewhere = buildPlace({ id: 1 })
const sets = { plannedIds: new Set([1, 2]), plannedFilterIds: new Set([2]) }
expect(matchesPoolFilter(elsewhere, 'planned', sets)).toBe(false)
expect(matchesPoolFilter(elsewhere, 'unplanned', sets)).toBe(false)
})
it('FE-UTIL-PLFILTER-006: the combined matcher needs pool, category and rating to agree', () => {
const place = buildPlace({ id: 1, category_id: 4, rating_avg: 4.5 })
const sets = { plannedIds: new Set<number>() }
const state = { filter: 'unplanned', categoryFilters: new Set(['4']), ratingFilter: 4 as const }
expect(matchesPlacesFilter(place, state, sets)).toBe(true)
expect(matchesPlacesFilter(place, { ...state, ratingFilter: 5 }, sets)).toBe(false)
expect(matchesPlacesFilter(place, { ...state, categoryFilters: new Set(['9']) }, sets)).toBe(false)
expect(matchesPlacesFilter(place, state, { plannedIds: new Set([1]) })).toBe(false)
})
it('FE-UTIL-PLFILTER-007: counts each filter that narrows, the category set once', () => {
expect(countActivePlacesFilters({ filter: 'all', categoryFilters: NONE, ratingFilter: 'all' })).toBe(0)
expect(countActivePlacesFilters({ filter: 'planned', categoryFilters: NONE, ratingFilter: 'all' })).toBe(1)
expect(countActivePlacesFilters({ filter: 'all', categoryFilters: new Set(['1', '2']), ratingFilter: 'all' })).toBe(1)
expect(countActivePlacesFilters({ filter: 'tracks', categoryFilters: new Set(['1']), ratingFilter: 3 })).toBe(3)
})
})
+69
View File
@@ -0,0 +1,69 @@
import type { Place } from '../types'
/**
* The trip's places filter, in one place: the pool (all / unplanned / planned /
* tracks), the category set and the minimum rating. The desktop list, the phone
* list and the map markers of both shells all filter through these helpers, on
* the values held in the trip store, so the list and the map can never disagree
* on which places are in view (#1541).
*/
/** A minimum of average stars: 'all', or a floor that unrated places fall through. */
export type PlacesRatingFloor = number | 'all'
/** The floors offered by every rating picker, the same as the collections bar (#1435). */
export const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
/** The pseudo category id that stands for "no category". */
export const UNCATEGORIZED = 'uncategorized'
export function matchesCategoryFilter(place: Place, categoryFilters: ReadonlySet<string>): boolean {
if (categoryFilters.size === 0) return true
if (place.category_id == null) return categoryFilters.has(UNCATEGORIZED)
return categoryFilters.has(String(place.category_id))
}
export function matchesRatingFloor(place: Place, floor: PlacesRatingFloor): boolean {
if (floor === 'all') return true
return place.rating_avg != null && place.rating_avg >= floor
}
export interface PoolFilterSets {
/** Every place planned anywhere in the trip: what 'unplanned' leaves out. */
plannedIds: ReadonlySet<number> | null
/**
* What 'planned' keeps. Defaults to `plannedIds`; the planner narrows it to the
* open day (#2024) while 'unplanned' stays trip-wide.
*/
plannedFilterIds?: ReadonlySet<number> | null
}
/** A null set means "not needed for this filter" and filters nothing out. */
export function matchesPoolFilter(place: Place, filter: string, { plannedIds, plannedFilterIds = plannedIds }: PoolFilterSets): boolean {
if (filter === 'unplanned') return !plannedIds?.has(place.id)
if (filter === 'planned') return !plannedFilterIds || plannedFilterIds.has(place.id)
if (filter === 'tracks') return !!place.route_geometry
return true
}
export interface PlacesFilterState {
filter: string
categoryFilters: ReadonlySet<string>
ratingFilter: PlacesRatingFloor
}
/** Pool, categories and rating together — the whole filter the store holds. */
export function matchesPlacesFilter(place: Place, state: PlacesFilterState, sets: PoolFilterSets): boolean {
return matchesPoolFilter(place, state.filter, sets)
&& matchesCategoryFilter(place, state.categoryFilters)
&& matchesRatingFloor(place, state.ratingFilter)
}
/**
* How many of the three filters are narrowing the places (0 to 3): the pool, the
* categories (however many are ticked) and the rating floor. Drives the badge on
* the phone's filter buttons.
*/
export function countActivePlacesFilters({ filter, categoryFilters, ratingFilter }: PlacesFilterState): number {
return (filter !== 'all' ? 1 : 0) + (categoryFilters.size > 0 ? 1 : 0) + (ratingFilter !== 'all' ? 1 : 0)
}
+26
View File
@@ -354,6 +354,32 @@ describe('tripStore', () => {
});
});
describe('places filters', () => {
it('FE-TRIP-PF-001: the rating floor starts at "all" and is shared through the store', () => {
expect(useTripStore.getState().placesRatingFilter).toBe('all');
useTripStore.getState().setPlacesRatingFilter(4);
expect(useTripStore.getState().placesRatingFilter).toBe(4);
});
it('FE-TRIP-PF-002: resetPlacesFilters lifts the pool, the categories and the rating at once', () => {
const s = useTripStore.getState();
s.setPlacesFilter('unplanned');
s.setPlacesCategoryFilter(new Set(['3', 'uncategorized']));
s.setPlacesRatingFilter(5);
useTripStore.getState().resetPlacesFilters();
const after = useTripStore.getState();
expect(after.placesFilter).toBe('all');
expect(after.placesCategoryFilter.size).toBe(0);
expect(after.placesRatingFilter).toBe('all');
});
it('FE-TRIP-PF-003: resetTrip drops the rating floor of the previous trip', () => {
useTripStore.getState().setPlacesRatingFilter(3);
useTripStore.getState().resetTrip();
expect(useTripStore.getState().placesRatingFilter).toBe('all');
});
});
describe('addTag', () => {
it('FE-TRIP-010: addTag creates tag and appends to tags', async () => {
const existingTag = buildTag();