From ca33f41412f65ced6b6076dc80a7f0dc2a3ade2a Mon Sep 17 00:00:00 2001 From: Zaid Abdulameer Date: Tue, 16 Jun 2026 19:26:29 -0400 Subject: [PATCH] feat: add frontend test suite with Vitest Export mapper functions from hooks and App.tsx for testability. Fix NearbyStopsPanel test to wrap with QueryClientProvider. Add comprehensive mapper tests covering all API type transformations. Closes #89 --- frontend/src/App.tsx | 2 +- .../src/__tests__/NearbyStopsPanel.test.tsx | 26 +- frontend/src/__tests__/mappers.test.ts | 303 ++++++++++++++++++ frontend/src/hooks/useAlerts.ts | 2 +- frontend/src/hooks/useNearbyStops.ts | 2 +- frontend/src/hooks/useRoutes.ts | 2 +- frontend/src/hooks/useStopDepartures.ts | 2 +- frontend/src/hooks/useVehiclePositions.ts | 4 +- 8 files changed, 328 insertions(+), 15 deletions(-) create mode 100644 frontend/src/__tests__/mappers.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e582c20..30092d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,7 @@ import type { CacheStatus } from "./api/types"; const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : "Something went wrong."; -const formatAge = (seconds: number | null): string | null => { +export const formatAge = (seconds: number | null): string | null => { if (seconds === null) return null; if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); diff --git a/frontend/src/__tests__/NearbyStopsPanel.test.tsx b/frontend/src/__tests__/NearbyStopsPanel.test.tsx index 0f4f335..e6bd5ec 100644 --- a/frontend/src/__tests__/NearbyStopsPanel.test.tsx +++ b/frontend/src/__tests__/NearbyStopsPanel.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, it, expect } from "vitest"; import NearbyStopsPanel from "../components/NearbyStopsPanel"; import type { Stop } from "../api/types"; @@ -20,15 +21,24 @@ const defaultProps = { onFocusChange: () => {}, }; +function Wrapper({ children }: { children: React.ReactNode }) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +const renderWithWrapper = (ui: React.ReactElement) => render(ui, { wrapper: Wrapper }); + describe("NearbyStopsPanel", () => { it("shows empty state when stops array is empty", () => { - render(); - expect(screen.getByText("No stops found nearby — try panning the map")).toBeInTheDocument(); + renderWithWrapper(); + expect( + screen.getByText("No stops found nearby — try panning the map or searching by name") + ).toBeInTheDocument(); }); it("renders the count of nearby stops", () => { const stops = [makeStop()]; - render(); + renderWithWrapper(); expect(screen.getByText("Nearby Stops (1)")).toBeInTheDocument(); }); @@ -37,7 +47,7 @@ describe("NearbyStopsPanel", () => { makeStop({ stopId: "s1", stopName: "King & Weber", distanceM: 50 }), makeStop({ stopId: "s2", stopName: "University & Bridge", distanceM: 200 }), ]; - render(); + renderWithWrapper(); expect(screen.getByText(/King & Weber/)).toBeInTheDocument(); expect(screen.getByText(/50m/)).toBeInTheDocument(); expect(screen.getByText(/University & Bridge/)).toBeInTheDocument(); @@ -46,7 +56,7 @@ describe("NearbyStopsPanel", () => { it("falls back to stopId when stopName is missing", () => { const stops = [makeStop({ stopName: undefined })]; - render(); + renderWithWrapper(); expect(screen.getByText(/stop-1/)).toBeInTheDocument(); }); @@ -54,7 +64,7 @@ describe("NearbyStopsPanel", () => { const stops = Array.from({ length: 10 }, (_, i) => makeStop({ stopId: `s${i}`, stopName: `Stop ${i}`, distanceM: i * 10 }) ); - render(); + renderWithWrapper(); expect(screen.getByText("Nearby Stops (10)")).toBeInTheDocument(); // Only 5 stop entries should be rendered (sliced in component) expect(screen.getAllByText(/Stop \d+/)).toHaveLength(5); @@ -62,12 +72,12 @@ describe("NearbyStopsPanel", () => { it("renders distance with zero decimals", () => { const stops = [makeStop({ distanceM: 123.456 })]; - render(); + renderWithWrapper(); expect(screen.getByText(/123m/)).toBeInTheDocument(); }); it("shows skeleton cards when loading", () => { - render(); + renderWithWrapper(); // The SkeletonCard uses animate-pulse const skeletons = document.querySelectorAll(".animate-pulse"); expect(skeletons.length).toBeGreaterThanOrEqual(1); diff --git a/frontend/src/__tests__/mappers.test.ts b/frontend/src/__tests__/mappers.test.ts new file mode 100644 index 0000000..d0b9288 --- /dev/null +++ b/frontend/src/__tests__/mappers.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect } from "vitest"; +import { toVehiclePosition, toCacheStatus } from "../hooks/useVehiclePositions"; +import { toRoute } from "../hooks/useRoutes"; +import { toStop } from "../hooks/useNearbyStops"; +import { toAlert } from "../hooks/useAlerts"; +import { toDeparture } from "../hooks/useStopDepartures"; +import { formatAge } from "../App"; + +// --------------------------------------------------------------------------- +// toVehiclePosition +// --------------------------------------------------------------------------- +describe("toVehiclePosition", () => { + it("maps a full response correctly", () => { + const result = toVehiclePosition({ + vehicle_id: "veh-1", + trip_id: "trip-1", + route_id: "200", + latitude: 43.45, + longitude: -80.49, + bearing: 90, + speed: 12.5, + timestamp: 1710000000, + transport_type: "bus", + }); + expect(result).toEqual({ + id: "veh-1", + lat: 43.45, + lon: -80.49, + routeId: "200", + heading: 90, + speed: 12.5, + updatedAt: "2024-03-09T16:00:00.000Z", + transportType: "bus", + }); + }); + + it("handles null/undefined fields", () => { + const result = toVehiclePosition({}); + expect(result.id).toBe("unknown"); + expect(Number.isNaN(result.lat)).toBe(true); + expect(Number.isNaN(result.lon)).toBe(true); + expect(result.routeId).toBeUndefined(); + expect(result.heading).toBeUndefined(); + expect(result.speed).toBeUndefined(); + expect(result.updatedAt).toBeUndefined(); + expect(result.transportType).toBe("bus"); + }); + + it("detects LRT transport type", () => { + const result = toVehiclePosition({ transport_type: "lrt" }); + expect(result.transportType).toBe("lrt"); + }); +}); + +// --------------------------------------------------------------------------- +// toCacheStatus +// --------------------------------------------------------------------------- +describe("toCacheStatus", () => { + it("maps response metadata correctly", () => { + const result = toCacheStatus({ + vehicles: [], + stale: true, + last_updated: "2025-03-09T12:00:00Z", + last_refresh_age_seconds: 30, + refresh_error: "timeout", + }); + expect(result).toEqual({ + lastUpdated: "2025-03-09T12:00:00Z", + lastRefreshAgeSeconds: 30, + stale: true, + refreshError: "timeout", + }); + }); + + it("handles missing fields", () => { + const result = toCacheStatus({ vehicles: [] }); + expect(result.lastUpdated).toBe(""); + expect(result.lastRefreshAgeSeconds).toBeNull(); + expect(result.stale).toBe(false); + expect(result.refreshError).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// toRoute +// --------------------------------------------------------------------------- +describe("toRoute", () => { + it("maps a full route with shape", () => { + const result = toRoute({ + route_id: "200", + route_short_name: "200", + route_long_name: "iXpress", + route_color: "#123456", + route_text_color: "#FFFFFF", + shape: [ + { lat: 43.45, lon: -80.49, sequence: 1 }, + { lat: 43.46, lon: -80.5, sequence: 2 }, + { lat: 43.47, lon: -80.51, sequence: 3 }, + ], + }); + expect(result).toEqual({ + id: "200", + shortName: "200", + longName: "iXpress", + color: "#123456", + textColor: "#FFFFFF", + shape: [ + { lat: 43.45, lon: -80.49, sequence: 1 }, + { lat: 43.46, lon: -80.5, sequence: 2 }, + { lat: 43.47, lon: -80.51, sequence: 3 }, + ], + }); + }); + + it("falls back to longName when shortName is missing", () => { + const result = toRoute({ route_id: "200", route_long_name: "iXpress" }); + expect(result.shortName).toBe("iXpress"); + }); + + it("falls back to routeId when both names are missing", () => { + const result = toRoute({ route_id: "200" }); + expect(result.shortName).toBe("200"); + }); + + it("filters invalid shape points", () => { + const result = toRoute({ + route_id: "200", + shape: [ + { lat: 43.45, lon: -80.49, sequence: 1 }, + { lat: null, lon: null, sequence: 2 }, + { lat: 43.47, lon: -80.51, sequence: 3 }, + ], + }); + expect(result.shape).toEqual([ + { lat: 43.45, lon: -80.49, sequence: 1 }, + { lat: 43.47, lon: -80.51, sequence: 3 }, + ]); + }); + + it("ignores single-point shapes", () => { + const result = toRoute({ + route_id: "200", + shape: [{ lat: 43.45, lon: -80.49, sequence: 1 }], + }); + expect(result.shape).toBeUndefined(); + }); + + it("handles null shape", () => { + const result = toRoute({ route_id: "200", shape: null }); + expect(result.shape).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// toStop +// --------------------------------------------------------------------------- +describe("toStop", () => { + it("maps a full stop correctly", () => { + const result = toStop({ + stop_id: "1234", + stop_name: "Main St", + stop_lat: 43.45, + stop_lon: -80.49, + distance_m: 150, + zone_id: "A", + wheelchair_boarding: 1, + }); + expect(result).toEqual({ + stopId: "1234", + stopName: "Main St", + stopLat: 43.45, + stopLon: -80.49, + distanceM: 150, + zoneId: "A", + wheelchairBoarding: 1, + }); + }); + + it("handles null fields", () => { + const result = toStop({ stop_id: "1234" }); + expect(result.stopId).toBe("1234"); + expect(result.stopName).toBeUndefined(); + expect(result.stopLat).toBe(0); + expect(result.stopLon).toBe(0); + expect(result.distanceM).toBe(0); + expect(result.zoneId).toBeUndefined(); + expect(result.wheelchairBoarding).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// toAlert +// --------------------------------------------------------------------------- +describe("toAlert", () => { + it("maps a full alert correctly", () => { + const result = toAlert({ + alert_id: "alert-1", + header_text: "Delay on Route 200", + description_text: "Expect delays of 10-15 minutes", + route_ids: ["200", "201"], + cause: "MAINTENANCE", + effect: "SIGNIFICANT_DELAYS", + active_periods: [{ start: 1710000000, end: 1710086400 }], + }); + expect(result).toEqual({ + alertId: "alert-1", + headerText: "Delay on Route 200", + descriptionText: "Expect delays of 10-15 minutes", + routeIds: ["200", "201"], + cause: "MAINTENANCE", + effect: "SIGNIFICANT_DELAYS", + activePeriods: [{ start: 1710000000, end: 1710086400 }], + }); + }); + + it("handles null fields", () => { + const result = toAlert({}); + expect(result.alertId).toBeUndefined(); + expect(result.headerText).toBeUndefined(); + expect(result.descriptionText).toBeUndefined(); + expect(result.routeIds).toEqual([]); + expect(result.cause).toBeUndefined(); + expect(result.effect).toBeUndefined(); + expect(result.activePeriods).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// toDeparture +// --------------------------------------------------------------------------- +describe("toDeparture", () => { + it("maps a full departure correctly", () => { + const result = toDeparture({ + trip_id: "trip-1", + route_id: "200", + route_short_name: "200", + route_color: "#123456", + stop_id: "1234", + arrival_time: 1710003600, + departure_time: 1710003660, + type: "predicted", + minutes_away: 5, + }); + expect(result).toEqual({ + tripId: "trip-1", + routeId: "200", + routeShortName: "200", + routeColor: "#123456", + stopId: "1234", + arrivalTime: 1710003600, + departureTime: 1710003660, + type: "predicted", + minutesAway: 5, + }); + }); + + it("handles null fields", () => { + const result = toDeparture({ + trip_id: "trip-1", + route_id: "200", + stop_id: "1234", + type: "scheduled", + }); + expect(result.routeShortName).toBeUndefined(); + expect(result.routeColor).toBeUndefined(); + expect(result.arrivalTime).toBeUndefined(); + expect(result.departureTime).toBeUndefined(); + expect(result.minutesAway).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// formatAge +// --------------------------------------------------------------------------- +describe("formatAge", () => { + it("returns null for null input", () => { + expect(formatAge(null)).toBeNull(); + }); + + it("formats seconds only", () => { + expect(formatAge(45)).toBe("45s"); + }); + + it("formats minutes and seconds", () => { + expect(formatAge(125)).toBe("2m 5s"); + }); + + it("formats minutes only when seconds are zero", () => { + expect(formatAge(120)).toBe("2m"); + }); + + it("formats hours and minutes", () => { + expect(formatAge(3660)).toBe("1h 1m"); + }); + + it("formats hours only when minutes are zero", () => { + expect(formatAge(7200)).toBe("2h"); + }); + + it("handles zero seconds", () => { + expect(formatAge(0)).toBe("0s"); + }); +}); diff --git a/frontend/src/hooks/useAlerts.ts b/frontend/src/hooks/useAlerts.ts index 5e53797..51db2e1 100644 --- a/frontend/src/hooks/useAlerts.ts +++ b/frontend/src/hooks/useAlerts.ts @@ -16,7 +16,7 @@ type AlertsResponse = { alerts: AlertApi[]; }; -const toAlert = (raw: AlertApi): Alert => ({ +export const toAlert = (raw: AlertApi): Alert => ({ alertId: raw.alert_id ?? undefined, headerText: raw.header_text ?? undefined, descriptionText: raw.description_text ?? undefined, diff --git a/frontend/src/hooks/useNearbyStops.ts b/frontend/src/hooks/useNearbyStops.ts index 8789b3b..df4b5b8 100644 --- a/frontend/src/hooks/useNearbyStops.ts +++ b/frontend/src/hooks/useNearbyStops.ts @@ -16,7 +16,7 @@ type NearbyStopsResponse = { stops: StopApi[]; }; -const toStop = (raw: StopApi): Stop => ({ +export const toStop = (raw: StopApi): Stop => ({ stopId: raw.stop_id, stopName: raw.stop_name ?? undefined, stopLat: raw.stop_lat ?? 0, diff --git a/frontend/src/hooks/useRoutes.ts b/frontend/src/hooks/useRoutes.ts index be3f499..e298c4e 100644 --- a/frontend/src/hooks/useRoutes.ts +++ b/frontend/src/hooks/useRoutes.ts @@ -17,7 +17,7 @@ type RouteApi = { shape?: RouteShapePointApi[] | null; }; -const toRoute = (raw: RouteApi): Route => { +export const toRoute = (raw: RouteApi): Route => { const shape = raw.shape ?.filter( (point): point is { lat: number; lon: number; sequence?: number | null } => diff --git a/frontend/src/hooks/useStopDepartures.ts b/frontend/src/hooks/useStopDepartures.ts index d95ee83..ed91f00 100644 --- a/frontend/src/hooks/useStopDepartures.ts +++ b/frontend/src/hooks/useStopDepartures.ts @@ -32,7 +32,7 @@ export interface Departure { minutesAway?: number; } -const toDeparture = (raw: DepartureApi): Departure => ({ +export const toDeparture = (raw: DepartureApi): Departure => ({ tripId: raw.trip_id, routeId: raw.route_id, routeShortName: raw.route_short_name ?? undefined, diff --git a/frontend/src/hooks/useVehiclePositions.ts b/frontend/src/hooks/useVehiclePositions.ts index 0a82505..3debf66 100644 --- a/frontend/src/hooks/useVehiclePositions.ts +++ b/frontend/src/hooks/useVehiclePositions.ts @@ -22,7 +22,7 @@ type VehiclePositionsResponse = { refresh_error?: string | null; }; -const toVehiclePosition = (raw: VehiclePositionApi): VehiclePosition => ({ +export const toVehiclePosition = (raw: VehiclePositionApi): VehiclePosition => ({ id: raw.vehicle_id ?? "unknown", lat: raw.latitude ?? NaN, lon: raw.longitude ?? NaN, @@ -33,7 +33,7 @@ const toVehiclePosition = (raw: VehiclePositionApi): VehiclePosition => ({ transportType: raw.transport_type === "lrt" ? "lrt" : "bus", }); -const toCacheStatus = (raw: VehiclePositionsResponse): CacheStatus => ({ +export const toCacheStatus = (raw: VehiclePositionsResponse): CacheStatus => ({ lastUpdated: raw.last_updated ?? "", lastRefreshAgeSeconds: raw.last_refresh_age_seconds ?? null, stale: raw.stale ?? false,