mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
test(roadtrip): cover the fold, the seam and the live via, and fix what that found
Cases appended to four existing suites for the lines this branch left unrun: the fold state and the map places it hides, the owner-based via anchor, the day seam and the proportional geometry slicing, and the live roadtripVia:changed events. With the four new suites in the commits before this one, coverage of the lines this branch adds goes from 69 % to 96 %, measured the way the gate measures it — the routed lines against the diff, not a file average. Every added case was mutation-checked: the rule it claims to pin was broken in the source and the case had to fail. Two of them found real defects rather than missing coverage, and both are fixed here. A via dropped on the incoming night drive went to the wrong leg. The guard for it asked the insert index whether the point lay before the card's first stop, and that index cannot answer: insertIndexForAlong clamps to at least 1 for any card with two stops or more, and the rail publishes no other kind. So the branch read correctly and never ran, and a via on the night stretch was filed after the first drawn stop — the route ran forward, doubled back to the point and carried on, which is exactly what the comment above it said must not happen. Asked of the distance instead. The two one-field writes the rail offers, the stop kind and the fill level, went straight to the API while their neighbour checked place_edit first. The server refused them, so nothing was ever saved, but a reader got an error toast for touching a control that should not have acted.
This commit is contained in:
@@ -5,6 +5,9 @@ import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripSidebar from './RoadtripSidebar'
|
||||
import RoadtripModeSwitch from './RoadtripModeSwitch'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { DryPoint } from './roadtripModel'
|
||||
import type { RefuelSearch } from './useRefuelSearch'
|
||||
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
@@ -643,6 +646,376 @@ describe('RoadtripSidebar', () => {
|
||||
expect(screen.queryByText(/From day/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The band where the fuel actually ends, and the one search offered from it.
|
||||
*
|
||||
* It hangs off the leg rather than off a stop, because those are two different places:
|
||||
* the range warning marks where somebody finds out, and that can be a long way past the
|
||||
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
|
||||
*/
|
||||
describe('the leg the tank runs out on', () => {
|
||||
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
|
||||
legIndex: 0,
|
||||
intoLegKm: 182,
|
||||
drivenMeters: 182000,
|
||||
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
|
||||
// traveller's own setting, so a band printing it would read the same figure on
|
||||
// every leg of the day.
|
||||
sinceKm: 600,
|
||||
lat: 52.4,
|
||||
lng: 10.2,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Idle unless a case says which part of the search it is standing in. */
|
||||
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
|
||||
openFor: null,
|
||||
loading: false,
|
||||
outcome: null,
|
||||
results: [],
|
||||
offered: [],
|
||||
ask: vi.fn(),
|
||||
close: vi.fn(),
|
||||
...over,
|
||||
})
|
||||
|
||||
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
|
||||
osm_id: `osm-${name}`,
|
||||
name,
|
||||
lat: 52.4,
|
||||
lng: 10.1,
|
||||
category: 'fuel',
|
||||
poi_type: 'fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
alongKm: 170,
|
||||
offRouteKm,
|
||||
spareKm,
|
||||
})
|
||||
|
||||
const empties = dry({ legIndex: 1 })
|
||||
// Three stops, so the leg that empties has one it is not on to be absent from.
|
||||
const thirsty = (): RoadtripDay => day({
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Hannover' }),
|
||||
stop({ assignmentId: 3, name: 'Kassel' }),
|
||||
],
|
||||
legs: [leg(), leg()],
|
||||
dryPoints: [empties],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-035: the band sits on the leg the fuel ends on, and says how far into it', () => {
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [thirsty()], totalStops: 3 })} refuel={search()} />,
|
||||
)
|
||||
|
||||
// The second row owns the second leg. Drawn on every row instead, the rail would
|
||||
// offer a fill-up on a stretch the car drives with a full tank.
|
||||
const rows = container.querySelectorAll('li')
|
||||
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
|
||||
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
|
||||
// Distance into the leg, which is where a drive band keeps its figures. The 600 is
|
||||
// the setting that was crossed and belongs to no place on the map.
|
||||
expect(screen.getByText('after 182 km')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/600 km/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-036: the low-fuel lamp is itself the button that goes looking', () => {
|
||||
const onAskRefuel = vi.fn()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search()}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Not a lamp with a magnifier beside it, which says the same thing twice: the lamp
|
||||
// reports the empty tank and pressing it is what does something about it, so the
|
||||
// lamp has to be inside the button rather than next to one.
|
||||
const ask = screen.getByRole('button', { name: 'Find fuel' })
|
||||
expect(ask.querySelector('svg.lucide-fuel')).toBeInTheDocument()
|
||||
expect(within(ask).queryByRole('button')).toBeNull()
|
||||
|
||||
fireEvent.click(ask)
|
||||
// The dry point travels with the ask: the search is a circle pulled back from that
|
||||
// coordinate, and the day alone does not say where on it to look.
|
||||
expect(onAskRefuel).toHaveBeenCalledWith(1, empties)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-037: a band inside a borrowed stretch is filed under the card it is drawn on', () => {
|
||||
const onAskRefuel = vi.fn()
|
||||
const overnight = dry({ legIndex: 0 })
|
||||
const card = day({
|
||||
dayNumber: 2,
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
|
||||
stop({ assignmentId: 2, name: 'Wittenberg' }),
|
||||
],
|
||||
dryPoints: [overnight],
|
||||
spills: [{
|
||||
at: 0,
|
||||
count: 1,
|
||||
fromDayNumber: 1,
|
||||
departure: '22:30',
|
||||
leg: leg(),
|
||||
line: [[53, 10], [52, 11]],
|
||||
fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
|
||||
}],
|
||||
})
|
||||
const { unmount } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [card] })}
|
||||
refuel={search({ openFor: '1:0', loading: true })}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Everything that WRITES names the day a stop is stored on, day 9 here. This is the
|
||||
// exception and it has to be: the open search is filed under the card day and the
|
||||
// leg, so a band asking as day 9 would have its answers arrive under a key no band
|
||||
// is watching and would never open. That it opened at all is the assertion.
|
||||
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [card] })}
|
||||
refuel={search()}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find fuel' }))
|
||||
expect(onAskRefuel).toHaveBeenCalledWith(1, overnight)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-038: while the request is in flight the lamp steps aside for the way out', () => {
|
||||
const close = vi.fn()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', loading: true, close })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
|
||||
// Gone, not merely quieter. Every press is a real request against a shared service,
|
||||
// and a lamp still standing there invites a second one nobody asked for.
|
||||
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-039: an answer carries the detour and what is left in the tank as two figures', () => {
|
||||
const onAcceptRefuel = vi.fn()
|
||||
const shell = pump('Shell Hannover', 1.4, 48.6)
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', outcome: 'found', results: [shell] })}
|
||||
onAcceptRefuel={onAcceptRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Two facts about two different things: the detour is what the stop costs and what
|
||||
// the list is sorted by, the spare is what is left when the car draws level. Joined
|
||||
// into one line with a separator, neither exact lookup finds anything.
|
||||
expect(screen.getByText('1.4 km')).toBeInTheDocument()
|
||||
expect(screen.getByText('49 km')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Shell Hannover as a fuel stop' }))
|
||||
// With the dry point, because the caller has no other way of knowing which leg the
|
||||
// accepted stop belongs on.
|
||||
expect(onAcceptRefuel).toHaveBeenCalledWith(1, shell, empties)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-040: a search that came back with nothing says which nothing it was', () => {
|
||||
const answered = (outcome: RefuelOutcome) => (
|
||||
<TranslationProvider>
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', outcome })}
|
||||
/>
|
||||
</TranslationProvider>
|
||||
)
|
||||
const { rerender } = render(answered('none'))
|
||||
|
||||
// Only the first of these is a statement about the road. Telling somebody there is
|
||||
// nothing on a stretch that was never fully checked, or never checked at all, is
|
||||
// worse than saying nothing.
|
||||
expect(screen.getByText(/Nothing found/)).toBeInTheDocument()
|
||||
rerender(answered('incomplete'))
|
||||
expect(screen.getByText(/cut short/)).toBeInTheDocument()
|
||||
rerender(answered('failed'))
|
||||
expect(screen.getByText(/did not answer/)).toBeInTheDocument()
|
||||
|
||||
// And a way to ask again rather than a dead end: the place search is a shared
|
||||
// service that does time out, and no answer with no retry reads as broken.
|
||||
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-041: the stop stops repeating what the band already says better', () => {
|
||||
// The warning marks the stop somebody finds out at and counts the whole tank; the
|
||||
// band sits where the fuel ends and offers a way out. Both at once prints the answer
|
||||
// above the problem.
|
||||
const warned = (): RoadtripDay => ({
|
||||
...thirsty(),
|
||||
driveWarnings: [{ index: 2, code: 'range', sinceKm: 640 }],
|
||||
})
|
||||
const { unmount } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} refuel={search()} />,
|
||||
)
|
||||
expect(screen.queryByText('640 km')).toBeNull()
|
||||
|
||||
unmount()
|
||||
// With no search to run there is nothing better, so the finding is all there is and
|
||||
// has to stay.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} />)
|
||||
expect(screen.getByText('640 km')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Folding a day down to its header.
|
||||
*
|
||||
* The whole header is the control and the hover is the only sign of it: a chevron or a
|
||||
* fold label sitting among the day's own facts reads as a fourth fact about the day
|
||||
* rather than as something to press. Nothing on screen says which way it stands, so the
|
||||
* header has to say it to a screen reader.
|
||||
*/
|
||||
describe('a day folded down to its header', () => {
|
||||
it('FE-ROADTRIP-SIDEBAR-042: the header is the control, and it folds by id rather than by number', () => {
|
||||
const onToggleDay = vi.fn()
|
||||
// Id and number deliberately apart. The card is titled by its number and folded by
|
||||
// its id, and on a trip whose days were not created in order those two differ —
|
||||
// mixing them up folds a card the reader did not click.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ dayId: 42 })] })} onToggleDay={onToggleDay} />)
|
||||
|
||||
const header = screen.getByRole('button', { name: /Day 1/ })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(onToggleDay).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-043: folded, the stops are gone and the header is what is left', () => {
|
||||
// Folded by id, again with a card whose id is not its number: a set read as numbers
|
||||
// would fold the card titled "Day 42" here, or nothing at all.
|
||||
wrap(<RoadtripSidebar
|
||||
routes={routes({ days: [day({ dayId: 42 }), day({ dayId: 2, dayNumber: 2 })], totalStops: 4 })}
|
||||
onToggleDay={vi.fn()}
|
||||
collapsedDayIds={new Set([42])}
|
||||
/>)
|
||||
|
||||
const folded = screen.getByRole('button', { name: /Day 1/ })
|
||||
expect(folded).toHaveAttribute('aria-expanded', 'false')
|
||||
// The card keeps its head: a day put away is still one the reader scrolls past, and
|
||||
// its own figures are how it is found again.
|
||||
expect(within(folded).getByText('2 stops')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Hamburg')[0]).not.toBeVisible()
|
||||
// One card at a time. Folding is per day, and the id is what decides which.
|
||||
expect(screen.getByRole('button', { name: /Day 2/ })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getAllByText('Berlin')[1]).toBeVisible()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-044: the header answers the keys that mean press, and no others', () => {
|
||||
const onToggleDay = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onToggleDay={onToggleDay} />)
|
||||
const header = screen.getByRole('button', { name: /Day 1/ })
|
||||
|
||||
// A header wearing a button role gets none of a real button's keyboard behaviour, so
|
||||
// it answers both keys itself. Space has to be swallowed as well as answered, or the
|
||||
// rail scrolls a page down behind the day that was just put away. fireEvent reports
|
||||
// a cancelled event as false.
|
||||
fireEvent.keyDown(header, { key: 'Enter' })
|
||||
expect(fireEvent.keyDown(header, { key: ' ' })).toBe(false)
|
||||
expect(onToggleDay).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.keyDown(header, { key: 'ArrowDown' })
|
||||
expect(onToggleDay).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-045: a rail with nothing to fold offers no control at all', () => {
|
||||
wrap(<RoadtripSidebar routes={routes()} />)
|
||||
|
||||
// A viewer gets a heading. A control that does nothing when pressed is worse than
|
||||
// one that is not there.
|
||||
expect(screen.queryByRole('button', { name: /Day 1/ })).toBeNull()
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeVisible()
|
||||
expect(screen.getByText('Hamburg')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* How full one stop fills the tank.
|
||||
*
|
||||
* A property of the stop rather than of the traveller: the motorway rapid charger is
|
||||
* worth 80 % because the last fifth costs as long again, the one at the hotel is worth
|
||||
* all of it. The badge is the only way into that figure anywhere in TREK.
|
||||
*/
|
||||
describe('the fill badge on a stop that puts fuel back', () => {
|
||||
const refuelling = (fillPercent?: number | null): RoadtripDay => day({
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', fillPercent }),
|
||||
],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-046: the badge is the way into a figure that is not there yet', () => {
|
||||
const onSelectStop = vi.fn()
|
||||
const onSetStopFill = vi.fn()
|
||||
wrap(<RoadtripSidebar
|
||||
routes={routes({ days: [refuelling()] })}
|
||||
onSelectStop={onSelectStop}
|
||||
onSetStopFill={onSetStopFill}
|
||||
/>)
|
||||
|
||||
// The plus stands in the slot the number will occupy: a badge that hid itself until
|
||||
// a figure existed could never be used to make one.
|
||||
fireEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
|
||||
// The whole row is a button too. A click that reached it would select the stop and
|
||||
// move the map out from under the panel that just opened.
|
||||
expect(onSelectStop).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '80' }))
|
||||
expect(onSetStopFill).toHaveBeenCalledWith(20, 80)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-047: the badge opens from the keyboard and swallows the key', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [refuelling()] })} onSetStopFill={vi.fn()} />)
|
||||
const badge = screen.getByRole('button', { name: '+' })
|
||||
|
||||
// The same span-with-a-role bargain as the header, one row further in: unanswered,
|
||||
// the only way to the figure is a mouse.
|
||||
fireEvent.keyDown(badge, { key: 'ArrowDown' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
|
||||
expect(fireEvent.keyDown(badge, { key: 'Enter' })).toBe(false)
|
||||
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-048: only a stop that fills up carries the badge, and its own figure reads as one', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [refuelling(100)] })} onSetStopFill={vi.fn()} />)
|
||||
|
||||
// An explicit 100 is a decision, not an absent figure: on a trip whose default is 80
|
||||
// it says this one goes right up, and folding it into the invitation would leave the
|
||||
// traveller reading 80 on a stop that fills to 100.
|
||||
expect(screen.getAllByRole('button', { name: '100 %' })).toHaveLength(1)
|
||||
// Hamburg has none at all. A place the trip is for fills no tank, so a fill figure
|
||||
// there is a control for a decision nobody makes.
|
||||
expect(screen.queryByText('+')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripModeSwitch', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { Assignment, AssignmentsMap, Day } from '../../types'
|
||||
import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
|
||||
|
||||
// Hoisted together with the mock: the module factory runs before the file body, so a
|
||||
// class declared down there would not exist yet when the hook does its `instanceof`.
|
||||
@@ -17,6 +17,8 @@ const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
|
||||
|
||||
import { useRoadtripRoutes } from './useRoadtripRoutes'
|
||||
import { DEFAULT_SETTINGS, useSettingsStore } from '../../store/settingsStore'
|
||||
import { lineMetres } from './corridor'
|
||||
|
||||
const HAMBURG: [number, number] = [53.5511, 9.9937]
|
||||
const LUENEBURG: [number, number] = [53.2464, 10.4115]
|
||||
@@ -436,4 +438,231 @@ describe('useRoadtripRoutes', () => {
|
||||
unmount()
|
||||
expect(seen!.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-020: gives each leg its share of the drawn line, not its share of the routed metres', async () => {
|
||||
// The router reports 50 km a leg off its own graph while the polyline it sends back
|
||||
// measures 260 km as great-circle hops between the vertices. Cutting at the raw
|
||||
// metres left every run short by the difference: the second leg ended a third of the
|
||||
// way into the drive and the day never reached Berlin at all.
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }]
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, LUENEBURG, BERLIN]))
|
||||
const metres = (line: [number, number][]): number => lineMetres(line.map(([lat, lng]) => ({ lat, lng })))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
const [first, second] = result.current.lines
|
||||
expect(first[0]).toEqual(HAMBURG)
|
||||
// Both ends are interpolated inside the segment they fall in, so the two pieces meet
|
||||
// on one coordinate and put back together give the run back.
|
||||
expect(first[first.length - 1]).toEqual(second[0])
|
||||
// And the last cut lands on the last vertex rather than somewhere short of it.
|
||||
expect(second[second.length - 1][0]).toBeCloseTo(BERLIN[0], 6)
|
||||
expect(second[second.length - 1][1]).toBeCloseTo(BERLIN[1], 6)
|
||||
// Two legs the router called equal take equal shares of the road that was drawn, and
|
||||
// between them the whole 260 km of it rather than the 100 km the router reported.
|
||||
const drawn = metres(first) + metres(second)
|
||||
expect(metres(first) / drawn).toBeCloseTo(0.5, 2)
|
||||
expect(drawn).toBeCloseTo(metres([HAMBURG, LUENEBURG, BERLIN]), -2)
|
||||
})
|
||||
|
||||
/**
|
||||
* The switch that turns a trip stored as days into one continuous drive.
|
||||
*
|
||||
* Off, a card holds only the driving between its own stops: the road across the join is
|
||||
* not asked for, not drawn and not counted. On, every one of those gaps becomes a leg
|
||||
* like any other — and it is the one stretch of a trip that no day run ever covers, so
|
||||
* a hole left in the chain here is left there by nothing else.
|
||||
*/
|
||||
describe('connecting the days', () => {
|
||||
beforeEach(() => {
|
||||
// Wholesale, so nothing a previous case set can answer for this one.
|
||||
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })
|
||||
})
|
||||
|
||||
/** The hook reads the switch off the settings store, the way the limits card writes it. */
|
||||
const setting = (over: Partial<Settings>): void => {
|
||||
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } })) })
|
||||
}
|
||||
|
||||
/** Two ordinary days, with the road from Lueneburg to Berlin left over between them. */
|
||||
const twoDays = () => ({
|
||||
days: [day(1, 1), day(2, 2)],
|
||||
assignments: {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
||||
} as AssignmentsMap,
|
||||
})
|
||||
|
||||
/** Every request for one particular road, found by the two ends it was asked for. */
|
||||
const askedFor = (from: [number, number], to: [number, number]) =>
|
||||
calculateRouteWithLegs.mock.calls.filter(c =>
|
||||
c[0][0].lat === from[0] && c[0][c[0].length - 1].lat === to[0])
|
||||
|
||||
/** A run whose legs are a round 100 km each, so the range budget reads off the page. */
|
||||
const hundreds = (legs: number, coordinates: [number, number][]) => ({
|
||||
coordinates,
|
||||
distance: legs * 100000,
|
||||
duration: legs * 3600,
|
||||
legs: Array.from({ length: legs }, () => ({ distance: 100000, duration: 3600, text: '100 km' })),
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-021: off, the join is neither driven nor counted; on, it is both', async () => {
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// A gap the day-at-a-time plan simply has, and the totals say so by leaving it out.
|
||||
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(0)
|
||||
expect(result.current.lines).toHaveLength(2)
|
||||
expect(result.current.totalDistance).toBe(200000)
|
||||
|
||||
setting({ roadtrip_connect_days: true })
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
await waitFor(() => expect(result.current.lines).toHaveLength(3))
|
||||
expect(result.current.totalDistance).toBe(300000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-022: the joining stroke wears the day it leaves, its kilometres the day it reaches', async () => {
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.lines).toHaveLength(3))
|
||||
|
||||
// Same length and same order as `lines`, or a colour lands on the wrong day.
|
||||
expect(result.current.lineDays).toHaveLength(result.current.lines.length)
|
||||
// The stroke starts at a stop on day 1, so drawing it as day 2 would make day 2
|
||||
// look like it begins somewhere it has no stop.
|
||||
expect(result.current.lineDays).toEqual([1, 1, 2])
|
||||
// The kilometres go the other way: they are driven on the day they arrive.
|
||||
expect(result.current.days.map(d => d.distance)).toEqual([100000, 200000])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-023: a night drive is drawn on the card it reaches only once the switch is on', async () => {
|
||||
// Stored inside one day and real driving, but on screen it runs from a stop on one
|
||||
// card to a stop on the next, and a line between two cards is what the switch asks
|
||||
// about. Its kilometres belong to the day it lands on either way.
|
||||
const daysList = [day(1, 1), day(2, 2)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
||||
} as AssignmentsMap
|
||||
// Three hours a leg, which is what puts the second stop past midnight.
|
||||
calculateRouteWithLegs.mockResolvedValue({
|
||||
...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.lines).toHaveLength(2))
|
||||
expect(result.current.days[0].distance).toBe(200000)
|
||||
|
||||
setting({ roadtrip_connect_days: true })
|
||||
expect(result.current.lines).toHaveLength(3)
|
||||
expect(result.current.lineDays).toEqual([1, 2, 2])
|
||||
expect(result.current.days[0].distance).toBe(300000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-024: vias on the join reach the router in sequence order, and come back as one drive', async () => {
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
// Filed after the last stop of day 1, which is the join itself. Handed over in the
|
||||
// wrong order on purpose: driven through its points backwards the seam doubles back.
|
||||
const vias = {
|
||||
1: [
|
||||
{ id: 2, day_id: 1, after_order_index: 1, sequence: 1, lat: 53.0, lng: 11.5 },
|
||||
{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 },
|
||||
],
|
||||
}
|
||||
// A shaped seam comes back as one leg per waypoint pair.
|
||||
const inPieces = {
|
||||
...routed(1),
|
||||
legs: [
|
||||
{ distance: 30000, duration: 1000, text: '30 km' },
|
||||
{ distance: 30000, duration: 1000, text: '30 km' },
|
||||
{ distance: 40000, duration: 1600, text: '40 km' },
|
||||
],
|
||||
}
|
||||
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
|
||||
Promise.resolve(wp.length === 4 ? inPieces : routed(1)))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments, 'driving', vias))
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
|
||||
const [waypoints] = askedFor(LUENEBURG, BERLIN)[0]
|
||||
expect(waypoints.map((w: { lat: number }) => w.lat)).toEqual([LUENEBURG[0], 53.2, 53.0, BERLIN[0]])
|
||||
// Three pieces, one drive: the rail carries the whole road across the join rather
|
||||
// than the first stretch of it.
|
||||
await waitFor(() => expect(result.current.days[1].distance).toBe(200000))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-025: dragging a via on the join asks for that road again', async () => {
|
||||
// The first answer is filed under the pair of stops. Skipping the seam whenever any
|
||||
// answer existed is what made a via on the join do visibly nothing at all.
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 }] }
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, daysList, assignments, 'driving', v),
|
||||
{ initialProps: { v: vias } },
|
||||
)
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
|
||||
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.4 }] } })
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2))
|
||||
const [waypoints] = askedFor(LUENEBURG, BERLIN)[1]
|
||||
expect(waypoints[1]).toEqual({ lat: 53.9, lng: 10.4 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-026: a join that will not route leaves both cards their own driving', async () => {
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
|
||||
wp[0].lat === LUENEBURG[0] ? Promise.reject(new Error('503')) : Promise.resolve(routed(1)))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
// Let the refusal settle: what it must not do is file a leg anyway.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
|
||||
expect(result.current.lines).toHaveLength(2)
|
||||
expect(result.current.lineDays).toEqual([1, 2])
|
||||
// Partial rather than invented, exactly like any other leg that will not route.
|
||||
expect(result.current.totalDistance).toBe(200000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-027: the road across the join is spent from the tank before the next card fills up', async () => {
|
||||
// A tank does not empty overnight and the join is real driving, so the day it
|
||||
// reaches starts with those kilometres already on the clock — which moves the point
|
||||
// the fuel runs out a whole leg earlier.
|
||||
const SOUTH: [number, number] = [51.5, 13.5]
|
||||
const FURTHER_SOUTH: [number, number] = [51.05, 13.74]
|
||||
setting({ roadtrip_range_km: 250 })
|
||||
const daysList = [day(1, 1), day(2, 2)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: SOUTH }, { id: 5, at: FURTHER_SOUTH }]),
|
||||
} as AssignmentsMap
|
||||
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) => Promise.resolve(
|
||||
wp[0].lat === BERLIN[0]
|
||||
? hundreds(2, [BERLIN, SOUTH, FURTHER_SOUTH])
|
||||
: hundreds(1, wp[0].lat === LUENEBURG[0] ? [LUENEBURG, BERLIN] : [HAMBURG, LUENEBURG])))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.days).toHaveLength(2))
|
||||
await waitFor(() => expect(result.current.days[1].dryPoints).toHaveLength(1))
|
||||
// 100 km carried over from day 1, so the 250th is reached halfway down the second leg.
|
||||
expect(result.current.days[1].dryPoints![0]).toMatchObject({ legIndex: 1, drivenMeters: 150000 })
|
||||
|
||||
setting({ roadtrip_connect_days: true })
|
||||
await waitFor(() => expect(result.current.days[1].distance).toBe(300000))
|
||||
const [dry] = result.current.days[1].dryPoints!
|
||||
expect(dry).toMatchObject({ legIndex: 0, drivenMeters: 50000 })
|
||||
// Walked along the roads this day drives, not along the drawn line: 50 km down that
|
||||
// one is still on the join, north of Berlin, a whole leg from where the tank empties.
|
||||
expect(dry.lat).toBeLessThan(BERLIN[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,13 +11,43 @@ const { roadtripApi } = vi.hoisted(() => ({
|
||||
}))
|
||||
vi.mock('../../api/client', () => ({ roadtripApi }))
|
||||
|
||||
// Offline is the hook's headline rule and it has no other way in: `editable` is read
|
||||
// straight off useNetworkMode, so a test that never drives it can only ever see the
|
||||
// online answer.
|
||||
const { networkMode } = vi.hoisted(() => ({ networkMode: { offline: false } }))
|
||||
vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: () => networkMode }))
|
||||
|
||||
// The remote path is driven by hand: the hook hands its handler to addListener, and the
|
||||
// cases below call it the way a message from another screen would arrive.
|
||||
const { addListener, removeListener } = vi.hoisted(() => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener,
|
||||
removeListener,
|
||||
}))
|
||||
|
||||
import { useRoadtripVias } from './useRoadtripVias'
|
||||
|
||||
const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({
|
||||
id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over,
|
||||
})
|
||||
|
||||
/** Whatever the hook last gave the socket, which is how a change from another screen gets in. */
|
||||
const socketHandler = (): ((event: Record<string, unknown>) => void) => {
|
||||
const call = addListener.mock.calls[addListener.mock.calls.length - 1]
|
||||
if (!call) throw new Error('the hook never subscribed to the socket')
|
||||
return call[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
networkMode.offline = false
|
||||
roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] })
|
||||
roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() })
|
||||
roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() })
|
||||
@@ -107,4 +137,141 @@ describe('useRoadtripVias', () => {
|
||||
|
||||
expect(roadtripApi.addVia).not.toHaveBeenCalled()
|
||||
})
|
||||
it('FE-ROADTRIP-VIAS-008: a via somebody else moved replaces that day and only that day', async () => {
|
||||
// The screen holds the id the router handed it, a string, and the socket sends the
|
||||
// number the server broadcasts. Compared as they arrive, every remote change is lost.
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias('7', true))
|
||||
await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1))
|
||||
const untouched = result.current.byDay[1]
|
||||
|
||||
// dayId travels as the route param it came from, so it is a string on the wire too.
|
||||
act(() => socketHandler()({
|
||||
type: 'roadtripVia:changed',
|
||||
tripId: 7,
|
||||
dayId: '2',
|
||||
vias: [via({ id: 8, day_id: 2, lat: 48 }), via({ id: 9, day_id: 2, lat: 49 })],
|
||||
}))
|
||||
|
||||
// The server sends the day's whole list, so this replaces rather than merges: a merge
|
||||
// keeps drawing the point the other screen has just deleted.
|
||||
expect(result.current.byDay[2].map(v => v.id)).toEqual([8, 9])
|
||||
expect(result.current.byDay[1]).toBe(untouched)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-009: a change to another trip is not applied to this one', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
|
||||
const before = result.current.byDay
|
||||
|
||||
act(() => socketHandler()({
|
||||
type: 'roadtripVia:changed', tripId: 8, dayId: '1', vias: [via({ id: 4, day_id: 1 })],
|
||||
}))
|
||||
|
||||
// The same object, not merely an equal one: this list feeds the routing key, so even
|
||||
// handing back a fresh copy re-routes the trip for an event meant for somebody else.
|
||||
expect(result.current.byDay).toBe(before)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-010: a day emptied elsewhere leaves the map instead of lingering empty', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1))
|
||||
|
||||
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] }))
|
||||
|
||||
// An empty list left behind still answers yes to a `dayId in byDay` check, which is
|
||||
// how the surfaces ask whether a day is shaped at all.
|
||||
expect(2 in result.current.byDay).toBe(false)
|
||||
expect(result.current.byDay[1]).toHaveLength(1)
|
||||
|
||||
const before = result.current.byDay
|
||||
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] }))
|
||||
// Nothing left to drop, so nothing new to hand out — a copy here would re-route the
|
||||
// whole trip for an event that changed nothing.
|
||||
expect(result.current.byDay).toBe(before)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-011: only a track event moves the track, and it clears just its own day', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({
|
||||
vias: [via({ id: 1, day_id: 1 })],
|
||||
tracks: [{ day_id: 1, place_id: 11, stray_km: 0.4 }, { day_id: 2, place_id: 22, stray_km: null }],
|
||||
})
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.trackByDay[1]?.place_id).toBe(11))
|
||||
|
||||
// A via event carries no track at all. Letting it reach the track map would drop the
|
||||
// badge for the road the day follows every time anybody nudged a point on it.
|
||||
act(() => socketHandler()({
|
||||
type: 'roadtripVia:changed', tripId: 7, dayId: '1', vias: [via({ id: 3, day_id: 1 })],
|
||||
}))
|
||||
expect(result.current.trackByDay[1]?.place_id).toBe(11)
|
||||
|
||||
act(() => socketHandler()({ type: 'roadtripTrack:changed', tripId: 7, dayId: '1', track: null }))
|
||||
expect(1 in result.current.trackByDay).toBe(false)
|
||||
expect(result.current.trackByDay[2]?.place_id).toBe(22)
|
||||
|
||||
act(() => socketHandler()({
|
||||
type: 'roadtripTrack:changed', tripId: 7, dayId: '2', track: { day_id: 2, place_id: 33, stray_km: 1 },
|
||||
}))
|
||||
expect(result.current.trackByDay[2].place_id).toBe(33)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-012: the listener follows the trip and goes down with the screen', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
const { result, rerender, unmount } = renderHook(({ id }: { id: number }) => useRoadtripVias(id, true), {
|
||||
initialProps: { id: 7 },
|
||||
})
|
||||
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
|
||||
const first = socketHandler()
|
||||
|
||||
rerender({ id: 8 })
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledWith(8))
|
||||
const second = socketHandler()
|
||||
expect(second).not.toBe(first)
|
||||
expect(removeListener).toHaveBeenCalledWith(first)
|
||||
|
||||
// The live handler is the one built for the trip on screen. One kept from the previous
|
||||
// trip would keep writing that trip's days into this one.
|
||||
act(() => second({
|
||||
type: 'roadtripVia:changed',
|
||||
tripId: 7,
|
||||
dayId: '1',
|
||||
vias: [via({ id: 4, day_id: 1 }), via({ id: 5, day_id: 1 })],
|
||||
}))
|
||||
expect(result.current.byDay[1]).toHaveLength(1)
|
||||
|
||||
// A handler that outlives its screen writes into a trip nobody is looking at.
|
||||
unmount()
|
||||
expect(removeListener).toHaveBeenCalledWith(second)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-014: with no network the map is told not to offer a drag', async () => {
|
||||
// A via anchors to a POSITION in the day stop list, not to an id, so a write queued
|
||||
// offline and replayed after somebody else added a stop would re-pin the drive onto
|
||||
// a leg nobody chose, silently and hours later. Until the anchor is an assignment id
|
||||
// the honest answer is to stop offering the gesture rather than to lose the edit one
|
||||
// write at a time.
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [] })
|
||||
networkMode.offline = true
|
||||
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
|
||||
expect(result.current.editable).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-013: an event that names no day is dropped rather than filed under NaN', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
|
||||
const before = result.current.byDay
|
||||
|
||||
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, vias: [via({ id: 4, day_id: 1 })] }))
|
||||
|
||||
// Unguarded this lands under the key NaN, and everything that walks the days then
|
||||
// routes a day the trip does not have.
|
||||
expect(result.current.byDay).toBe(before)
|
||||
expect(Object.keys(result.current.byDay)).toEqual(['1'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user