diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 3d1beffba..10428dcbd 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -515,13 +515,23 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro search.loading ? t('roadtrip.poi.searchingHint') : filteredToNothing - ? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() }) + // Four controls narrow this list and only one of them is the + // name box. Blaming it regardless produced `Nothing on the + // way matches ""` — empty quotes naming a filter the reader + // never set — when a section, a plug type or a minimum power + // was what emptied it. + ? corridor.nameFilter.trim() + ? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() }) + : t('roadtrip.poi.noneMatchFilters') : t('roadtrip.poi.empty') } /> ) : ( <> - {corridor.nameFilter.trim() ? ( + {/* Shown whenever anything narrowed the list, not only the name + box: a section or a plug filter hides hits just as much, and + the reader is owed the same "x of y" either way. */} + {corridor.visible.length !== search.results.length ? (

{t('roadtrip.poi.foundFiltered', { count: corridor.visible.length, total: search.results.length })}

diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx new file mode 100644 index 000000000..8ab93f52a --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx @@ -0,0 +1,130 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, render, screen } from '../../../tests/helpers/render' +import { useSettingsStore } from '../../store/settingsStore' +import RoadtripLimitsCard from './RoadtripLimitsCard' + +/** + * FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning. + * + * The one that matters is when a number is written. These fields had no local + * state, so every keystroke was a settings PUT: typing "180" sent three, and + * they raced. The value that decides whether a day is over budget could end up a + * tenth of what was typed and only say so after the next reload. + */ + +function open(onSave?: (key: string, value: number) => void) { + render() + fireEvent.click(screen.getByRole('button')) +} + +/** The three inputs, in the order the dialog lists them. */ +const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[] + +beforeEach(() => { + useSettingsStore.setState({ + settings: { + roadtrip_leg_minutes: 0, + roadtrip_day_minutes: 0, + roadtrip_range_km: 0, + distance_unit: 'metric', + } as never, + }) +}) + +describe('RoadtripLimitsCard', () => { + it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => { + const onSave = vi.fn() + open(onSave) + + const [leg] = inputs() + fireEvent.change(leg, { target: { value: '1' } }) + fireEvent.change(leg, { target: { value: '18' } }) + fireEvent.change(leg, { target: { value: '180' } }) + + // Three keystrokes used to be three unordered PUTs carrying 1, 18 and 180. + expect(onSave).not.toHaveBeenCalled() + expect(leg.value).toBe('180') + }) + + it('FE-ROADTRIP-LIMITS-002: leaving the field saves once, with what was typed', () => { + const onSave = vi.fn() + open(onSave) + + const [leg] = inputs() + fireEvent.change(leg, { target: { value: '180' } }) + fireEvent.blur(leg) + + expect(onSave).toHaveBeenCalledTimes(1) + expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 180) + }) + + it('FE-ROADTRIP-LIMITS-003: Enter commits without having to click away', () => { + const onSave = vi.fn() + open(onSave) + + const [, day] = inputs() + fireEvent.change(day, { target: { value: '540' } }) + fireEvent.keyDown(day, { key: 'Enter' }) + + expect(onSave).toHaveBeenCalledWith('roadtrip_day_minutes', 540) + }) + + it('FE-ROADTRIP-LIMITS-004: leaving a field nobody touched saves nothing', () => { + const onSave = vi.fn() + open(onSave) + + fireEvent.blur(inputs()[0]) + + expect(onSave).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-LIMITS-005: retyping the value it already had is not a write', () => { + useSettingsStore.setState({ + settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never, + }) + const onSave = vi.fn() + open(onSave) + + const [leg] = inputs() + fireEvent.change(leg, { target: { value: '180' } }) + fireEvent.blur(leg) + + expect(onSave).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-LIMITS-006: clearing a field turns the limit off', () => { + useSettingsStore.setState({ + settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never, + }) + const onSave = vi.fn() + open(onSave) + + const [leg] = inputs() + fireEvent.change(leg, { target: { value: '' } }) + fireEvent.blur(leg) + + expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 0) + }) + + it('FE-ROADTRIP-LIMITS-007: the range field carries the reader own unit', () => { + useSettingsStore.setState({ + settings: { roadtrip_leg_minutes: 0, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'imperial' } as never, + }) + open(vi.fn()) + + expect(screen.getByText('mi')).toBeInTheDocument() + expect(screen.queryByText('km')).not.toBeInTheDocument() + }) + + it('FE-ROADTRIP-LIMITS-008: without a way to save, the dialog is read-only', () => { + // `onSave` absent is how the caller says the reader may look but not change. + open(undefined) + + const [leg] = inputs() + fireEvent.change(leg, { target: { value: '180' } }) + // Nothing to assert but the absence of a crash: the commit path has no + // handler to call, and must not assume one. + expect(() => fireEvent.blur(leg)).not.toThrow() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index f9c771f24..f7c84b720 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -39,6 +39,20 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: { placeholder: string onChange: (next: number) => void }): React.ReactElement { + // Typed here, saved on blur or Enter. Writing on every keystroke sent one + // settings PUT per character — typing "180" produced three, carrying 1, 18 and + // 180, unordered and concurrent over HTTP/2. Whichever the server committed + // last won, so the limit that decides every over-budget warning could quietly + // end up a tenth of what was typed, and only show it after the next reload. + const [draft, setDraft] = useState(null) + const shown = draft ?? (value ? String(value) : '') + const commit = () => { + if (draft === null) return + const next = parseLimit(draft) + setDraft(null) + if (next !== (value ?? 0)) onChange(next) + } + return (