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,