mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
merge: update Tours branch with current Places filters
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
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 apply this same predicate to the same
|
||||
* values, held in the trip store (#1541). The map keeps two deliberate
|
||||
* differences of its own: with a day open, "planned" shows that day's plan only
|
||||
* (#2024), and the stops of collapsed days are left off the map (except under
|
||||
* "planned").
|
||||
*/
|
||||
|
||||
/** 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)
|
||||
}
|
||||
Reference in New Issue
Block a user