Skip to content
Merged
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
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 18 additions & 8 deletions frontend/src/__tests__/NearbyStopsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,15 +21,24 @@ const defaultProps = {
onFocusChange: () => {},
};

function Wrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

const renderWithWrapper = (ui: React.ReactElement) => render(ui, { wrapper: Wrapper });

describe("NearbyStopsPanel", () => {
it("shows empty state when stops array is empty", () => {
render(<NearbyStopsPanel stops={[]} {...defaultProps} />);
expect(screen.getByText("No stops found nearby — try panning the map")).toBeInTheDocument();
renderWithWrapper(<NearbyStopsPanel stops={[]} {...defaultProps} />);
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(<NearbyStopsPanel stops={stops} {...defaultProps} />);
renderWithWrapper(<NearbyStopsPanel stops={stops} {...defaultProps} />);
expect(screen.getByText("Nearby Stops (1)")).toBeInTheDocument();
});

Expand All @@ -37,7 +47,7 @@ describe("NearbyStopsPanel", () => {
makeStop({ stopId: "s1", stopName: "King & Weber", distanceM: 50 }),
makeStop({ stopId: "s2", stopName: "University & Bridge", distanceM: 200 }),
];
render(<NearbyStopsPanel stops={stops} {...defaultProps} />);
renderWithWrapper(<NearbyStopsPanel stops={stops} {...defaultProps} />);
expect(screen.getByText(/King & Weber/)).toBeInTheDocument();
expect(screen.getByText(/50m/)).toBeInTheDocument();
expect(screen.getByText(/University & Bridge/)).toBeInTheDocument();
Expand All @@ -46,28 +56,28 @@ describe("NearbyStopsPanel", () => {

it("falls back to stopId when stopName is missing", () => {
const stops = [makeStop({ stopName: undefined })];
render(<NearbyStopsPanel stops={stops} {...defaultProps} />);
renderWithWrapper(<NearbyStopsPanel stops={stops} {...defaultProps} />);
expect(screen.getByText(/stop-1/)).toBeInTheDocument();
});

it("only shows up to 5 stops", () => {
const stops = Array.from({ length: 10 }, (_, i) =>
makeStop({ stopId: `s${i}`, stopName: `Stop ${i}`, distanceM: i * 10 })
);
render(<NearbyStopsPanel stops={stops} {...defaultProps} />);
renderWithWrapper(<NearbyStopsPanel stops={stops} {...defaultProps} />);
expect(screen.getByText("Nearby Stops (10)")).toBeInTheDocument();
// Only 5 stop entries should be rendered (sliced in component)
expect(screen.getAllByText(/Stop \d+/)).toHaveLength(5);
});

it("renders distance with zero decimals", () => {
const stops = [makeStop({ distanceM: 123.456 })];
render(<NearbyStopsPanel stops={stops} {...defaultProps} />);
renderWithWrapper(<NearbyStopsPanel stops={stops} {...defaultProps} />);
expect(screen.getByText(/123m/)).toBeInTheDocument();
});

it("shows skeleton cards when loading", () => {
render(<NearbyStopsPanel stops={[]} {...defaultProps} isLoading={true} />);
renderWithWrapper(<NearbyStopsPanel stops={[]} {...defaultProps} isLoading={true} />);
// The SkeletonCard uses animate-pulse
const skeletons = document.querySelectorAll(".animate-pulse");
expect(skeletons.length).toBeGreaterThanOrEqual(1);
Expand Down
303 changes: 303 additions & 0 deletions frontend/src/__tests__/mappers.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
2 changes: 1 addition & 1 deletion frontend/src/hooks/useAlerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading