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
12 changes: 9 additions & 3 deletions webapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import {
// Actions
getRsuData,
} from './generalSlices/rsuSlice'
import { keycloakLogin, selectAuthLoginData, selectRouteNotFound } from './generalSlices/userSlice'
import {
keycloakLogin,
selectAuthLoginData,
selectOrganizationName,
selectRouteNotFound,
} from './generalSlices/userSlice'
import keycloak from './keycloak-config'
import { ThunkDispatch } from 'redux-thunk'
import { RootState } from './store'
Expand All @@ -29,6 +34,7 @@ const App = () => {
const dispatch: ThunkDispatch<RootState, void, AnyAction> = useDispatch()
const authLoginData = useSelector(selectAuthLoginData)
const routeNotFound = useSelector(selectRouteNotFound)
const organizationName = useSelector(selectOrganizationName)

const isDarkTheme = useBrowserThemeDetector()
const theme = useMemo(
Expand All @@ -54,8 +60,8 @@ const App = () => {

useEffect(() => {
dispatch(getRsuData())
dispatch(getIntersections())
}, [authLoginData, dispatch])
dispatch(getIntersections(organizationName))
}, [authLoginData, organizationName, dispatch])

return (
<StyledEngineProvider injectFirst>
Expand Down
187 changes: 187 additions & 0 deletions webapp/src/apis/intersections/mm-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { vi } from 'vitest'
import MessageMonitorApi from './mm-api'
import { authApiHelper } from './api-helper-cviz'

vi.mock('./api-helper-cviz', () => ({
authApiHelper: {
invokeApi: vi.fn(),
},
}))

describe('MessageMonitorApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('getIntersections returns intersections and calls endpoint', async () => {
const mockResponse = [{ intersectionId: 12109 }]
;(authApiHelper.invokeApi as any).mockResolvedValue(mockResponse)

const result = await MessageMonitorApi.getIntersections({ token: 'token', organization: 'org' } as any)

expect(result).toEqual(mockResponse)
expect(authApiHelper.invokeApi).toHaveBeenCalledWith({
path: '/intersections',
token: 'token',
headers: { Organization: 'org' },
failureMessage: 'Failed to retrieve intersection list',
tag: 'intersection',
})
})

it('getSpatMessagesWithLatest merges in-range and latest messages', async () => {
const inRangeSpat = { id: 'in-range' }
const latestSpat = { id: 'latest' }
const spatSpy = vi.spyOn(MessageMonitorApi, 'getSpatMessages')
spatSpy.mockResolvedValueOnce([latestSpat] as any)
spatSpy.mockResolvedValueOnce([inRangeSpat, null] as any)

const startTime = new Date('2026-01-01T00:00:00Z')
const endTime = new Date('2026-01-01T00:05:00Z')

const result = await MessageMonitorApi.getSpatMessagesWithLatest({
token: 'token',
intersectionId: 12109,
startTime,
endTime,
compact: true,
})

expect(result).toEqual([inRangeSpat, latestSpat])
expect(spatSpy).toHaveBeenCalledTimes(2)
spatSpy.mockRestore()
})

it('getSpatMessages builds query params and returns content', async () => {
const expected = [{ messageType: 'SPAT' }]
;(authApiHelper.invokeApi as any).mockResolvedValue({ content: expected })

const startTime = new Date('2026-01-01T00:00:00Z')
const endTime = new Date('2026-01-01T00:05:00Z')

const result = await MessageMonitorApi.getSpatMessages({
token: 'token',
intersectionId: 12109,
startTime,
endTime,
latest: true,
compact: true,
})

expect(result).toEqual(expected)
expect(authApiHelper.invokeApi).toHaveBeenCalledWith(
expect.objectContaining({
path: '/data/processed-spat',
token: 'token',
queryParams: {
intersection_id: '12109',
start_time_utc_millis: startTime.getTime().toString(),
end_time_utc_millis: endTime.getTime().toString(),
latest: 'true',
compact: 'true',
},
})
)
})

it('getMapMessages builds query params and returns content', async () => {
const expected = [{ properties: { intersectionId: 12109 } }]
;(authApiHelper.invokeApi as any).mockResolvedValue({ content: expected })

const startTime = new Date('2026-01-01T00:00:00Z')
const endTime = new Date('2026-01-01T00:05:00Z')

const result = await MessageMonitorApi.getMapMessages({
token: 'token',
intersectionId: 12109,
startTime,
endTime,
latest: false,
})

expect(result).toEqual(expected)
expect(authApiHelper.invokeApi).toHaveBeenCalledWith(
expect.objectContaining({
path: '/data/processed-map',
token: 'token',
queryParams: {
intersection_id: '12109',
start_time_utc_millis: startTime.getTime().toString(),
end_time_utc_millis: endTime.getTime().toString(),
latest: 'false',
},
})
)
})

it('getBsmMessages builds query params and returns content', async () => {
const expected = [{ properties: { messageType: 'BSM' } }]
;(authApiHelper.invokeApi as any).mockResolvedValue({ content: expected })

const startTime = new Date('2026-01-01T00:00:00Z')
const endTime = new Date('2026-01-01T00:05:00Z')

const result = await MessageMonitorApi.getBsmMessages({
token: 'token',
vehicleId: '10.11.81.12',
startTime,
endTime,
long: -105.0909,
lat: 39.588,
distance: 500,
})

expect(result).toEqual(expected)
expect(authApiHelper.invokeApi).toHaveBeenCalledWith(
expect.objectContaining({
path: '/data/processed-bsm',
token: 'token',
queryParams: {
origin_ip: '10.11.81.12',
start_time_utc_millis: startTime.getTime().toString(),
end_time_utc_millis: endTime.getTime().toString(),
longitude: '-105.0909',
latitude: '39.588',
distance: '500',
},
})
)
})

it('getMessageCount for bsm adds map-based coordinates and returns first count', async () => {
;(authApiHelper.invokeApi as any)
.mockResolvedValueOnce({
content: [
{
properties: {
refPoint: { latitude: 39.5880413, longitude: -105.0908854 },
},
},
],
})
.mockResolvedValueOnce({ content: [42] })

const startTime = new Date('2026-01-01T00:00:00Z')
const endTime = new Date('2026-01-01T00:05:00Z')

const result = await MessageMonitorApi.getMessageCount('token', 'bsm', 12109, startTime, endTime)

expect(result).toBe(42)
expect(authApiHelper.invokeApi).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
path: '/data/bsm/count',
token: 'token',
queryParams: {
start_time_utc_millis: startTime.getTime().toString(),
end_time_utc_millis: endTime.getTime().toString(),
test: 'false',
latitude: '39.5880413',
longitude: '-105.0908854',
distance: '500',
intersection_id: '12109',
},
})
)
})
})
3 changes: 2 additions & 1 deletion webapp/src/apis/intersections/mm-api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { authApiHelper } from './api-helper-cviz'

class MessageMonitorApi {
async getIntersections({ token }): Promise<IntersectionReferenceData[]> {
async getIntersections({ token, organization }): Promise<IntersectionReferenceData[]> {
const response = await authApiHelper.invokeApi({
path: '/intersections',
token: token,
headers: { Organization: organization },
failureMessage: 'Failed to retrieve intersection list',
tag: 'intersection',
})
Expand Down
12 changes: 11 additions & 1 deletion webapp/src/features/intersections/map/control-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,23 @@ function ControlPanel() {
messageData.mapData = JSON.parse(data)
} else if (relativePath.endsWith('_BSM_data.json')) {
const data = await zipEntry.async('string')
messageData.bsmData = JSON.parse(data)
try {
messageData.bsmData = JSON.parse(data)
} catch (error) {
console.error(`Error parsing BSM data from ZIP file: ${error.message}`)
}
// TODO: Add notification data to ZIP download
} else if (relativePath.endsWith('_SPAT_data.json')) {
const data = await zipEntry.async('string')
messageData.spatData = JSON.parse(data)
}
}
const mapLen = messageData.mapData?.length ?? 0
const spatLen = messageData.spatData?.length ?? 0
if (mapLen === 0 || spatLen === 0) {
toast.error(`No valid message data found in ZIP file. Make sure to upload a previously generated ZIP archive`)
return
}
dispatch(handleImportedMapMessageData(messageData))
})
.catch((e) => {
Expand Down
1 change: 1 addition & 0 deletions webapp/src/features/intersections/map/map-slice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export const pullInitialData = createAsyncThunk(
// ######################### SPAT Signal Groups #########################
const spatSignalGroupsLocal = parseSpatSignalGroups(rawSpat)
dispatch(setSpatSignalGroups(spatSignalGroupsLocal))
dispatch(setRawData({ map: rawMap, spat: rawSpat }))

// ######################### BSMs #########################
if (selectAbortAllFutureRequests(getState() as RootState)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
addBsmTimestamps,
addConnections,
createMarkerForNotification,
generateSignalStateFeatureCollection,
isValidDate,
parseMapSignalGroups,
parseSpatSignalGroups,
} from './message-utils'

import mapDataRaw from '../../../../utils/test_data/intersection_12109_MAP_data.json'
import spatData from '../../../../utils/test_data/intersection_12109_SPAT_data.json'
import bsmData from '../../../../utils/test_data/intersection_12109_BSM_data.json'

describe('message-utils', () => {
const mapData = mapDataRaw[0] as unknown as ProcessedMap

it('parseMapSignalGroups builds point features with signal groups', () => {
const result = parseMapSignalGroups(mapData)

expect(result.type).toBe('FeatureCollection')
expect(result.features.length).toBeGreaterThan(0)
expect(result.features[0].geometry.type).toBe('Point')
expect(result.features[0].properties.signalGroup).toBeDefined()
})

it('createMarkerForNotification builds a point marker for signal state conflict notifications', () => {
const center = [-105.0908854, 39.5880413]
const notification = {
notificationType: 'SignalStateConflictNotification',
notificationText: 'Conflict detected',
event: {
conflictType: 'TEST_CONFLICT',
firstConflictingSignalState: 'STOP_AND_REMAIN',
firstConflictingSignalGroup: 2,
secondConflictingSignalState: 'PROTECTED_CLEARANCE',
secondConflictingSignalGroup: 6,
},
} as unknown as MessageMonitor.Notification

const result = createMarkerForNotification(center, notification, mapData.mapFeatureCollection)

expect(result.type).toBe('FeatureCollection')
expect(result.features).toHaveLength(1)
expect(result.features[0].geometry.type).toBe('Point')
expect(result.features[0].geometry.coordinates).toEqual(center)
})

it('generateSignalStateFeatureCollection applies current signal state to existing signal features', () => {
const prevSignalStates = parseMapSignalGroups(mapData)
const signalGroup = prevSignalStates.features[0].properties.signalGroup

const result = generateSignalStateFeatureCollection(prevSignalStates, [
{ signalGroup, state: 'STOP_AND_REMAIN' },
])

expect(result.features[0].properties.signalState).toBe('STOP_AND_REMAIN')
})

it('parseSpatSignalGroups groups states by timestamp', () => {
const result = parseSpatSignalGroups(spatData as unknown as ProcessedSpat[])
const firstTimestamp = spatData[0].utcTimeStamp

expect(result[firstTimestamp]).toBeDefined()
expect(result[firstTimestamp][0].signalGroup).toBe(spatData[0].states[0].signalGroup)
})

it('addBsmTimestamps derives epoch seconds from odeReceivedAt', () => {
const result = addBsmTimestamps(bsmData as unknown as BsmFeatureCollection)
const expectedEpochSeconds = Date.parse(bsmData.features[0].properties.odeReceivedAt) / 1000

expect(result.features[0].properties.odeReceivedAtEpochSeconds).toBe(expectedEpochSeconds)
})

it('isValidDate returns true for a valid Date', () => {
expect(isValidDate(new Date('2025-10-15T23:49:47.376Z'))).toBe(true)
})

it('addConnections enriches connecting lanes with signal state', () => {
const firstSpat = spatData[0] as unknown as ProcessedSpat
const signalGroups = firstSpat.states.map((state) => ({
signalGroup: state.signalGroup,
state: state.stateTimeSpeed[0]?.eventState as SignalState,
}))

const result = addConnections(mapData.connectingLanesFeatureCollection, signalGroups, mapData.mapFeatureCollection)

expect(result.type).toBe('FeatureCollection')
expect(result.features.length).toBeGreaterThan(0)
expect(result.features.some((feature) => feature.properties.signalState !== undefined)).toBe(true)
})
})
Loading
Loading