Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,10 @@ const CreateFireWatch = ({
}
}

// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fetch on mount only
useEffect(() => {
dispatch(fetchWxStations(getStations, StationSource.wildfire_one))
dispatch(fetchFireWatchFireCentres())
}, [])
}, [dispatch])

const isEditMode = !Number.isNaN(fireWatch.id)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,9 @@ const FireWatchDashboard = () => {
}
]

// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fetch on mount only
useEffect(() => {
dispatch(fetchBurnForecasts())
}, [])
}, [dispatch])

const getDetailPanelContent = React.useCallback<NonNullable<DataGridProProps['getDetailPanelContent']>>(
({ row }) => <DetailPanelContent row={row} />,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { render, waitFor } from '@testing-library/react'
import { Provider } from 'react-redux'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CreateFireWatch from '@/features/fireWatch/components/CreateFireWatch'
import { createTestStore } from '@/test/testUtils'

vi.mock('@wps/api/stationAPI', () => ({
getStations: vi.fn(() => Promise.resolve([])),
StationSource: { wildfire_one: 'wildfire_one', unspecified: 'unspecified' }
}))

vi.mock('@wps/api/psuAPI', () => ({
getFireCentres: vi.fn(() => Promise.resolve({ fire_centres: [] }))
}))

describe('CreateFireWatch', () => {
let testStore: ReturnType<typeof createTestStore>

beforeEach(() => {
testStore = createTestStore()
})

const renderCreateFireWatch = () =>
render(
<Provider store={testStore}>
<CreateFireWatch />
</Provider>
)

it('dispatches fetchWxStations and fetchFireWatchFireCentres on mount', async () => {
const dispatchSpy = vi.spyOn(testStore, 'dispatch')
renderCreateFireWatch()

await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalled()
})
})

it('renders the stepper with all steps', () => {
const { getByText } = renderCreateFireWatch()

expect(getByText('Info')).toBeInTheDocument()
expect(getByText('Location')).toBeInTheDocument()
expect(getByText('Weather')).toBeInTheDocument()
expect(getByText('Fuel Info')).toBeInTheDocument()
expect(getByText('FBP Indices')).toBeInTheDocument()
expect(getByText('Submit')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import VectorLayer from 'ol/layer/Vector.js'
import { fromLonLat, toLonLat } from 'ol/proj'
import VectorSource from 'ol/source/Vector.js'
import { Icon, Style } from 'ol/style'
import React, { type SetStateAction, useEffect, useRef, useState } from 'react'
import React, { type SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
import { FORM_MAX_WIDTH } from '@/features/fireWatch/constants'
import type { FireWatch } from '@/features/fireWatch/interfaces'

Expand Down Expand Up @@ -41,16 +41,22 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => {
const [lonInput, setLonInput] = useState(isValidGeometry ? toLonLat(fireWatch.geometry)[0].toFixed(6) : '')
const [editingField, setEditingField] = useState<'lat' | 'lon' | null>(null)

const handleFormUpdate = useCallback(
(partialFireWatch: Partial<FireWatch>) => {
setFireWatch(prev => ({ ...prev, ...partialFireWatch }))
},
[setFireWatch]
)

// Clear all interactions in order to remove the Translate interaction
// and restore the original interactions in the correct order.
const resetMapInteractions = () => {
const resetMapInteractions = useCallback(() => {
map?.getInteractions().clear()
defaultInteractions({}).forEach(interaction => {
map?.addInteraction(interaction)
})
}
}, [map])

// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only re-run when marker changes
useEffect(() => {
// Clear and update the feature source so the newly created feature renders on the map.
featureSource.clear()
Expand All @@ -65,9 +71,8 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => {
handleFormUpdate({ geometry: evt.coordinate })
})
map?.addInteraction(newTranslate)
}, [marker])
}, [marker, featureSource, resetMapInteractions, handleFormUpdate, map])

// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — map init runs once
useEffect(() => {
if (!mapRef.current) {
return
Expand Down Expand Up @@ -100,38 +105,22 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => {
return () => {
mapObject.setTarget('')
}
}, [])
}, [featureSource])

// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only re-run when map instance changes
useEffect(() => {
// Click handler to allow user to click on map to place a marker.
const handleMapClick = (evt: MapBrowserEvent<UIEvent>) => {
const [lon, lat] = toLonLat(evt.coordinate)
setLatInput(lat.toFixed(6))
setLonInput(lon.toFixed(6))
handleFormUpdate({ geometry: evt.coordinate })
const newFeature = new Feature({
geometry: new Point(evt.coordinate)
})
setMarker([newFeature])
}
map?.on('singleclick', evt => handleMapClick(evt))

// Allow dragging of the marker.
const translate = new Translate({
features: new Collection(marker)
})
translate.on('translateend', evt => {
handleFormUpdate({ geometry: evt.coordinate })
})
map?.addInteraction(translate)
}, [map])

// sync textfields with marker coords
useEffect(() => {
if (marker.length && marker[0].getGeometry()) {
const [lon, lat] = toLonLat((marker[0].getGeometry() as Point).getCoordinates())
setLatInput(lat.toFixed(6))
setLonInput(lon.toFixed(6))
}
}, [marker])
}, [map, handleFormUpdate])

// when user finishes editing both fields, update marker
const updateMarkerFromInputs = () => {
Expand All @@ -140,20 +129,17 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => {
handleFormUpdate({ geometry: undefined })
return
}
const lat = parseFloat(latInput)
const lon = parseFloat(lonInput)
const lat = Number.parseFloat(latInput)
const lon = Number.parseFloat(lonInput)
if (!Number.isNaN(lat) && !Number.isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
const coords = fromLonLat([lon, lat])
setLatInput(lat.toFixed(6))
setLonInput(lon.toFixed(6))
setMarker([new Feature({ geometry: new Point(coords) })])
handleFormUpdate({ geometry: coords })
}
}

const handleFormUpdate = (partialFireWatch: Partial<FireWatch>) => {
const newFireWatch = { ...fireWatch, ...partialFireWatch }
setFireWatch(newFireWatch)
}

return (
<Step>
<Box sx={{ display: 'flex', flexDirection: 'column', width: `${FORM_MAX_WIDTH}px`, padding: theme.spacing(4) }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ const ReviewSubmitStep = ({ fireWatch, setActiveStep }: ReviewSubmitStepProps) =
return `${fireWatch.fuelType} ${postfix}`
}

// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — map init runs once
useEffect(() => {
if (!mapRef.current) {
return
Expand Down Expand Up @@ -88,7 +87,7 @@ const ReviewSubmitStep = ({ fireWatch, setActiveStep }: ReviewSubmitStepProps) =
return () => {
mapObject.setTarget('')
}
}, [])
}, [fireWatch])

return (
<Step>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { Dispatch, SetStateAction } from 'react'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import LocationStep from '@/features/fireWatch/components/steps/LocationStep'
import type { FireWatch } from '@/features/fireWatch/interfaces'
import { getBlankFireWatch } from '@/features/fireWatch/utils'

vi.mock('ol', () => ({
Collection: class {},
Map: class {
setTarget = vi.fn()
on = vi.fn()
getInteractions = vi.fn(() => ({ clear: vi.fn() }))
addInteraction = vi.fn()
},
View: class {}
}))

vi.mock('ol/Feature.js', () => ({
default: class {
getGeometry = vi.fn(() => null)
}
}))

vi.mock('ol/geom', () => ({
Point: class {
coords: number[]
constructor(coords: number[]) {
this.coords = coords
}
getCoordinates = vi.fn(function (this: { coords: number[] }) {
return this.coords
})
}
}))

vi.mock('ol/interaction/defaults', () => ({
defaults: vi.fn(() => ({ forEach: vi.fn() }))
}))

vi.mock('ol/interaction/Translate.js', () => ({
default: class {
on = vi.fn()
}
}))

vi.mock('ol/layer/Tile', () => ({ default: class {} }))
vi.mock('ol/layer/Vector.js', () => ({ default: class {} }))

vi.mock('ol/proj', () => ({
fromLonLat: vi.fn(([lon, lat]: number[]) => [lon * 1000, lat * 1000]),
toLonLat: vi.fn(([x, y]: number[]) => [x / 1000, y / 1000])
}))

vi.mock('ol/source/Vector.js', () => ({
default: class {
clear = vi.fn()
addFeatures = vi.fn()
}
}))

vi.mock('ol/style', () => ({
Icon: class {},
Style: class {}
}))

vi.mock('features/fireWeather/components/maps/constants', () => ({
source: {}
}))

describe('LocationStep', () => {
let mockSetFireWatch: Mock<Dispatch<SetStateAction<FireWatch>>>

beforeEach(() => {
mockSetFireWatch = vi.fn<Dispatch<SetStateAction<FireWatch>>>()
})

it('renders lat and lon text fields', () => {
render(<LocationStep fireWatch={getBlankFireWatch()} setFireWatch={mockSetFireWatch} />)

expect(screen.getByTestId('lat-input')).toBeInTheDocument()
expect(screen.getByTestId('lon-input')).toBeInTheDocument()
})

it('formats and commits lat/lon when valid values are entered and the field is blurred', async () => {
render(<LocationStep fireWatch={getBlankFireWatch()} setFireWatch={mockSetFireWatch} />)

const latInput = screen.getByTestId('lat-input')
const lonInput = screen.getByTestId('lon-input')

fireEvent.change(latInput, { target: { value: '50' } })
fireEvent.change(lonInput, { target: { value: '-120' } })
fireEvent.blur(lonInput)

await waitFor(() => {
expect(latInput).toHaveValue('50.000000')
expect(lonInput).toHaveValue('-120.000000')
expect(mockSetFireWatch).toHaveBeenCalledOnce()
})
})

it('clears geometry when lat or lon is empty on blur', async () => {
let currentFireWatch = getBlankFireWatch()
const setFireWatch = vi.fn<Dispatch<SetStateAction<FireWatch>>>().mockImplementation(updater => {
currentFireWatch = typeof updater === 'function' ? updater(currentFireWatch) : updater
})

render(<LocationStep fireWatch={currentFireWatch} setFireWatch={setFireWatch} />)

fireEvent.blur(screen.getByTestId('lon-input'))

await waitFor(() => {
expect(setFireWatch).toHaveBeenCalledOnce()
})
expect(currentFireWatch.geometry).toBeUndefined()
})
})
Loading