diff --git a/frontend/src/__tests__/useNearbyStops.test.tsx b/frontend/src/__tests__/useNearbyStops.test.tsx new file mode 100644 index 0000000..5cf8df2 --- /dev/null +++ b/frontend/src/__tests__/useNearbyStops.test.tsx @@ -0,0 +1,178 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useNearbyStops } from "../hooks/useNearbyStops"; +import type { Stop } from "../api/types"; + +// ── Mock apiGet ───────────────────────────────────────────────────────────── + +const mockApiGet = vi.fn<(path: string) => Promise>(); + +vi.mock("../api/client", () => ({ + apiGet: (path: string) => mockApiGet(path), +})); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("useNearbyStops", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetches and transforms nearby stops correctly", async () => { + const apiResponse = { + stops: [ + { + stop_id: "2000", + stop_name: "University Ave / King St", + stop_lat: 43.4723, + stop_lon: -80.5402, + distance_m: 120.5, + zone_id: "1", + wheelchair_boarding: 1, + }, + { + stop_id: "3001", + stop_name: "Charles St / Benton St", + stop_lat: 43.4731, + stop_lon: -80.5415, + distance_m: 250.0, + zone_id: "2", + wheelchair_boarding: 0, + }, + ], + }; + + mockApiGet.mockResolvedValueOnce(apiResponse); + + const center: [number, number] = [43.472, -80.54]; + const { result } = renderHook(() => useNearbyStops(center), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const stops: Stop[] = result.current.data!; + expect(stops).toHaveLength(2); + + // First stop: full data + expect(stops[0].stopId).toBe("2000"); + expect(stops[0].stopName).toBe("University Ave / King St"); + expect(stops[0].stopLat).toBe(43.4723); + expect(stops[0].stopLon).toBe(-80.5402); + expect(stops[0].distanceM).toBe(120.5); + expect(stops[0].zoneId).toBe("1"); + expect(stops[0].wheelchairBoarding).toBe(1); + + // Second stop + expect(stops[1].stopId).toBe("3001"); + expect(stops[1].stopName).toBe("Charles St / Benton St"); + expect(stops[1].wheelchairBoarding).toBe(0); + + expect(mockApiGet).toHaveBeenCalledWith( + "/api/stops/nearby?lat=43.47200&lon=-80.54000&radius=500" + ); + }); + + it("handles null/empty fields", async () => { + const apiResponse = { + stops: [ + { + stop_id: "4000", + stop_name: null, + stop_lat: null, + stop_lon: null, + distance_m: null, + zone_id: null, + wheelchair_boarding: null, + }, + ], + }; + + mockApiGet.mockResolvedValueOnce(apiResponse); + + const center: [number, number] = [43.5, -80.5]; + const { result } = renderHook(() => useNearbyStops(center), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const stops: Stop[] = result.current.data!; + expect(stops).toHaveLength(1); + expect(stops[0].stopId).toBe("4000"); + expect(stops[0].stopName).toBeUndefined(); + expect(stops[0].stopLat).toBe(0); + expect(stops[0].stopLon).toBe(0); + expect(stops[0].distanceM).toBe(0); + expect(stops[0].zoneId).toBeUndefined(); + expect(stops[0].wheelchairBoarding).toBeUndefined(); + }); + + it("handles wheelchair_boarding field", async () => { + const apiResponse = { + stops: [ + { + stop_id: "5000", + stop_name: "Accessible Stop", + stop_lat: 43.48, + stop_lon: -80.53, + distance_m: 50, + zone_id: "3", + wheelchair_boarding: 2, + }, + ], + }; + + mockApiGet.mockResolvedValueOnce(apiResponse); + + const center: [number, number] = [43.48, -80.53]; + const { result } = renderHook(() => useNearbyStops(center), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const stops: Stop[] = result.current.data!; + expect(stops[0].wheelchairBoarding).toBe(2); + }); + + it("is disabled when center is null", async () => { + const { result } = renderHook(() => useNearbyStops(null), { + wrapper: createWrapper(), + }); + + expect(result.current.isFetching).toBe(false); + expect(mockApiGet).not.toHaveBeenCalled(); + }); + + it("propagates API errors", async () => { + mockApiGet.mockRejectedValueOnce(new Error("Network error")); + + const center: [number, number] = [43.5, -80.5]; + const { result } = renderHook(() => useNearbyStops(center), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBeDefined(); + expect((result.current.error as Error).message).toBe("Network error"); + }); +}); diff --git a/frontend/src/__tests__/useVehicleArrivals.test.tsx b/frontend/src/__tests__/useVehicleArrivals.test.tsx new file mode 100644 index 0000000..f41637c --- /dev/null +++ b/frontend/src/__tests__/useVehicleArrivals.test.tsx @@ -0,0 +1,173 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useVehicleArrivals } from "../hooks/useVehicleArrivals"; +import type { VehicleArrivals, VehicleArrivalStop } from "../api/types"; + +// ── Mock apiGet ───────────────────────────────────────────────────────────── + +const mockApiGet = vi.fn<(path: string) => Promise>(); + +vi.mock("../api/client", () => ({ + apiGet: (path: string) => mockApiGet(path), +})); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("useVehicleArrivals", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetches and transforms arrivals correctly", async () => { + const apiResponse = { + vehicle_id: "V-1234", + trip_id: "T-5678", + route_id: "R-9012", + feed_timestamp: 1718500000, + updated_at: "2025-06-16T10:00:00Z", + stops: [ + { + stop_id: "S-100", + stop_name: "Main St / Waterloo Ave", + stop_lat: 43.45, + stop_lon: -80.49, + stop_sequence: 1, + arrival_time: 1718500200, + arrival_delay: 30, + departure_time: 1718500260, + departure_delay: 15, + }, + ], + }; + + mockApiGet.mockResolvedValueOnce(apiResponse); + + const { result } = renderHook(() => useVehicleArrivals("V-1234"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const data: VehicleArrivals = result.current.data!; + + // Top-level fields + expect(data.vehicleId).toBe("V-1234"); + expect(data.tripId).toBe("T-5678"); + expect(data.routeId).toBe("R-9012"); + expect(data.feedTimestamp).toBe(1718500000); + expect(data.updatedAt).toBe("2025-06-16T10:00:00Z"); + + // Stop fields + expect(data.stops).toHaveLength(1); + const stop: VehicleArrivalStop = data.stops[0]; + expect(stop.stopId).toBe("S-100"); + expect(stop.stopName).toBe("Main St / Waterloo Ave"); + expect(stop.stopLat).toBe(43.45); + expect(stop.stopLon).toBe(-80.49); + expect(stop.stopSequence).toBe(1); + expect(stop.arrivalTime).toBe(1718500200); + expect(stop.arrivalDelay).toBe(30); + expect(stop.departureTime).toBe(1718500260); + expect(stop.departureDelay).toBe(15); + + expect(mockApiGet).toHaveBeenCalledWith("/api/vehicles/V-1234/arrivals"); + }); + + it("handles empty stops array", async () => { + const apiResponse = { + vehicle_id: "V-5678", + stops: [], + }; + + mockApiGet.mockResolvedValueOnce(apiResponse); + + const { result } = renderHook(() => useVehicleArrivals("V-5678"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const data: VehicleArrivals = result.current.data!; + expect(data.vehicleId).toBe("V-5678"); + expect(data.stops).toHaveLength(0); + }); + + it("handles null/optional stop fields", async () => { + const apiResponse = { + vehicle_id: "V-9012", + stops: [ + { + stop_id: "S-200", + stop_name: null, + stop_lat: null, + stop_lon: null, + stop_sequence: null, + arrival_time: null, + arrival_delay: null, + departure_time: null, + departure_delay: null, + }, + ], + }; + + mockApiGet.mockResolvedValueOnce(apiResponse); + + const { result } = renderHook(() => useVehicleArrivals("V-9012"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const data: VehicleArrivals = result.current.data!; + expect(data.stops).toHaveLength(1); + const stop: VehicleArrivalStop = data.stops[0]; + expect(stop.stopId).toBe("S-200"); + expect(stop.stopName).toBeUndefined(); + expect(stop.stopLat).toBeUndefined(); + expect(stop.stopLon).toBeUndefined(); + expect(stop.stopSequence).toBeUndefined(); + expect(stop.arrivalTime).toBeUndefined(); + expect(stop.arrivalDelay).toBeUndefined(); + expect(stop.departureTime).toBeUndefined(); + expect(stop.departureDelay).toBeUndefined(); + }); + + it("is disabled when vehicleId is null", async () => { + const { result } = renderHook(() => useVehicleArrivals(null), { + wrapper: createWrapper(), + }); + + expect(result.current.isFetching).toBe(false); + expect(mockApiGet).not.toHaveBeenCalled(); + }); + + it("propagates API errors", async () => { + mockApiGet.mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => useVehicleArrivals("V-ERR"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBeDefined(); + expect((result.current.error as Error).message).toBe("Network error"); + }); +});