diff --git a/web/apps/wps-web/src/features/percentileCalculator/components/PercentileStationResultTable.tsx b/web/apps/wps-web/src/features/percentileCalculator/components/PercentileStationResultTable.tsx index 980393fc2..9745672e8 100644 --- a/web/apps/wps-web/src/features/percentileCalculator/components/PercentileStationResultTable.tsx +++ b/web/apps/wps-web/src/features/percentileCalculator/components/PercentileStationResultTable.tsx @@ -20,12 +20,11 @@ export const PercentileStationResultTable: React.FunctionComponent = ({ s const yearRange = years.join(', ') const [snackbarOpen, setSnackbarOpen] = useState(false) - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — deps are captured via closure correctly useEffect(() => { if (years.length < timeRange) { setSnackbarOpen(true) } - }, [years]) + }, [years, timeRange]) return (
diff --git a/web/apps/wps-web/src/features/percentileCalculator/components/percentileStationResultTable.test.tsx b/web/apps/wps-web/src/features/percentileCalculator/components/percentileStationResultTable.test.tsx new file mode 100644 index 000000000..c5f122f72 --- /dev/null +++ b/web/apps/wps-web/src/features/percentileCalculator/components/percentileStationResultTable.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import type { StationSummaryResponse } from '@wps/api/percentileAPI' +import { describe, expect, it } from 'vitest' +import { PercentileStationResultTable } from './PercentileStationResultTable' + +const makeStationResponse = (years: number[]): StationSummaryResponse => ({ + ffmc: 88.5, + isi: 9.1, + bui: 72.0, + years, + station: { + code: 101, + name: 'Test Station', + lat: 49.0, + long: -120.0, + ecodivision_name: 'Montane Cordillera', + core_season: { start_month: 5, start_day: 1, end_month: 9, end_day: 30 } + } +}) + +describe('PercentileStationResultTable snackbar', () => { + it('does not show warning when data covers the full time range', () => { + const years = [2020, 2021, 2022, 2023, 2024] + render() + + expect(screen.queryByText(/Data only available for/)).not.toBeInTheDocument() + }) + + it('shows warning when fewer years of data are available than the time range', () => { + const years = [2022, 2023, 2024] + render() + + expect(screen.getByText('Data only available for 3 of 10 years')).toBeInTheDocument() + }) + + it('re-shows the warning after dismissal when timeRange increases beyond available data', () => { + const years = [2022, 2023, 2024] + const { rerender } = render( + + ) + // 3 years >= timeRange=2 — no warning + expect(screen.queryByText(/Data only available for/)).not.toBeInTheDocument() + + // Increase time range beyond available data — warning appears + rerender() + expect(screen.getByText('Data only available for 3 of 5 years')).toBeInTheDocument() + + // User dismisses the snackbar + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + + // Slider increases time range further — warning re-opens + rerender() + expect(screen.getByText('Data only available for 3 of 10 years')).toBeInTheDocument() + }) +}) diff --git a/web/apps/wps-web/src/features/percentileCalculator/pages/PercentileCalculatorPage.tsx b/web/apps/wps-web/src/features/percentileCalculator/pages/PercentileCalculatorPage.tsx index 6ff48b721..45909a79a 100644 --- a/web/apps/wps-web/src/features/percentileCalculator/pages/PercentileCalculatorPage.tsx +++ b/web/apps/wps-web/src/features/percentileCalculator/pages/PercentileCalculatorPage.tsx @@ -12,7 +12,7 @@ import { TimeRangeSlider, yearWhenTheCalculationIsDone } from 'features/percenti import WxStationDropdown from 'features/percentileCalculator/components/WxStationDropdown' import { fetchPercentiles, resetPercentilesResult } from 'features/percentileCalculator/slices/percentilesSlice' import { fetchWxStations } from 'features/stations/slices/stationsSlice' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { useDispatch } from 'react-redux' import { useLocation, useNavigate } from 'react-router-dom' @@ -24,30 +24,33 @@ const PercentileCalculatorPage = () => { const location = useLocation() const navigate = useNavigate() - const codesFromQuery = getStationCodesFromUrl(location.search) + const codesFromQuery = useMemo(() => getStationCodesFromUrl(location.search), [location.search]) const [stationCodes, setStationCodes] = useState(codesFromQuery) const [timeRange, setTimeRange] = useState(defaultTimeRange) - const yearRange = { - start: yearWhenTheCalculationIsDone - (timeRange - 1), - end: yearWhenTheCalculationIsDone - } + const yearRange = useMemo( + () => ({ + start: yearWhenTheCalculationIsDone - (timeRange - 1), + end: yearWhenTheCalculationIsDone + }), + [timeRange] + ) + const yearRangeRef = useRef(yearRange) + yearRangeRef.current = yearRange - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fetch on mount only useEffect(() => { dispatch(fetchWxStations(getStations, StationSource.unspecified)) - }, []) + }, [dispatch]) - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — deps are captured via closure correctly useEffect(() => { if (codesFromQuery.length > 0) { - dispatch(fetchPercentiles(codesFromQuery, defaultPercentile, yearRange)) + dispatch(fetchPercentiles(codesFromQuery, defaultPercentile, yearRangeRef.current)) } else { dispatch(resetPercentilesResult()) } // Update local state to match with the url query setStationCodes(codesFromQuery) - }, [location]) + }, [codesFromQuery, dispatch]) const onCalculateClick = () => { // Update the url query with the new station codes diff --git a/web/apps/wps-web/src/features/percentileCalculator/pages/percentileCalculatorPage.test.tsx b/web/apps/wps-web/src/features/percentileCalculator/pages/percentileCalculatorPage.test.tsx new file mode 100644 index 000000000..c7309196d --- /dev/null +++ b/web/apps/wps-web/src/features/percentileCalculator/pages/percentileCalculatorPage.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fetchPercentiles } from 'features/percentileCalculator/slices/percentilesSlice' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from '@/test/testUtils' +import PercentileCalculatorPage from './PercentileCalculatorPage' + +const MOCKED_CURRENT_YEAR = 2024 + +vi.mock('@wps/api/stationAPI', () => ({ + getStations: vi.fn(() => Promise.resolve([])), + StationSource: { unspecified: 'unspecified' } +})) + +vi.mock('features/stations/slices/stationsSlice', async () => { + const actual = await vi.importActual( + 'features/stations/slices/stationsSlice' + ) + return { ...actual, fetchWxStations: vi.fn(() => () => {}) } +}) + +vi.mock('features/percentileCalculator/slices/percentilesSlice', async () => { + const actual = await vi.importActual( + 'features/percentileCalculator/slices/percentilesSlice' + ) + return { ...actual, fetchPercentiles: vi.fn(() => () => {}) } +}) + +vi.mock('features/percentileCalculator/components/TimeRangeSlider', () => ({ + TimeRangeSlider: ({ onYearRangeChange }: { onYearRangeChange: (n: number) => void }) => ( + + ), + yearWhenTheCalculationIsDone: 2024 +})) + +vi.mock('features/percentileCalculator/components/WxStationDropdown', () => ({ + default: ({ onChange }: { onChange: (codes: number[]) => void }) => ( + + ) +})) + +vi.mock('features/percentileCalculator/components/PercentileResults', () => ({ default: () => null })) +vi.mock('features/percentileCalculator/components/PercentileTextfield', () => ({ PercentileTextfield: () => null })) + +const renderPage = (search = '') => + render( + + + + + + ) + +describe('PercentileCalculatorPage', () => { + beforeEach(() => { + vi.mocked(fetchPercentiles).mockClear() + }) + + it('fetches percentiles on mount when station codes are in the URL', async () => { + renderPage('?codes=101,202') + + await waitFor(() => { + expect(fetchPercentiles).toHaveBeenCalledOnce() + expect(fetchPercentiles).toHaveBeenCalledWith([101, 202], 90, expect.any(Object)) + }) + }) + + it('does not fetch percentiles again when the time range slider changes', async () => { + renderPage('?codes=101') + + await waitFor(() => { + expect(fetchPercentiles).toHaveBeenCalledOnce() + }) + + fireEvent.click(screen.getByTestId('change-time-range')) + + // Allow any potential re-renders to settle + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(fetchPercentiles).toHaveBeenCalledOnce() + }) + + it('uses the slider yearRange at the time Calculate is clicked, not the default', async () => { + renderPage() + + // Change slider to timeRange=5 before selecting any station + fireEvent.click(screen.getByTestId('change-time-range')) + + // Select a station to enable Calculate + fireEvent.click(screen.getByTestId('select-station')) + + // Click Calculate — this navigates to ?codes=101 + fireEvent.click(screen.getByText('Calculate')) + + await waitFor(() => { + expect(fetchPercentiles).toHaveBeenCalledWith([101], 90, { + start: MOCKED_CURRENT_YEAR - 4, // timeRange=5 → start = 2024 - (5-1) + end: MOCKED_CURRENT_YEAR + }) + }) + }) +})