feat(GRO-2158): route planner page at /admin/routes (#60)
This commit was merged in pull request #60.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { RoutesPage } from "../pages/Routes.tsx";
|
||||
|
||||
// Leaflet does not render in jsdom — replace the lazily-loaded map with a stub
|
||||
// that just reports the stop count so we can assert it received the route data.
|
||||
vi.mock("../components/RouteMap.js", () => ({
|
||||
default: ({ stops }: { stops: unknown[] }) => (
|
||||
<div data-testid="route-map">map:{stops.length}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const MANAGER = { id: "m1", name: "Manager", role: "manager", active: true };
|
||||
const GROOMER = { id: "g1", name: "Sam Groomer", role: "groomer", active: true };
|
||||
|
||||
const ROUTE_RESPONSE = {
|
||||
route: {
|
||||
id: "r1",
|
||||
staffId: "g1",
|
||||
routeDate: "2026-06-09",
|
||||
status: "optimized",
|
||||
totalTravelMins: 95,
|
||||
totalDistanceKm: "42.50",
|
||||
},
|
||||
stops: [
|
||||
{
|
||||
id: "s1",
|
||||
appointmentId: "a1",
|
||||
stopOrder: 1,
|
||||
latitude: 51.5,
|
||||
longitude: -0.1,
|
||||
travelMinsFromPrev: null,
|
||||
travelDistanceKmFromPrev: null,
|
||||
bufferMins: 15,
|
||||
appointmentStartTime: "2026-06-09T09:00:00.000Z",
|
||||
appointmentEndTime: "2026-06-09T10:00:00.000Z",
|
||||
appointmentStatus: "confirmed",
|
||||
clientId: "c1",
|
||||
clientName: "Alice",
|
||||
clientAddress: "1 High St",
|
||||
conflict: { hasConflict: false },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
appointmentId: "a2",
|
||||
stopOrder: 2,
|
||||
latitude: 51.52,
|
||||
longitude: -0.12,
|
||||
travelMinsFromPrev: 20,
|
||||
travelDistanceKmFromPrev: "8.00",
|
||||
bufferMins: 15,
|
||||
appointmentStartTime: "2026-06-09T11:00:00.000Z",
|
||||
appointmentEndTime: "2026-06-09T12:00:00.000Z",
|
||||
appointmentStatus: "confirmed",
|
||||
clientId: "c2",
|
||||
clientName: "Bob",
|
||||
clientAddress: "2 Low St",
|
||||
conflict: { hasConflict: true },
|
||||
},
|
||||
],
|
||||
hasConflicts: true,
|
||||
conflictCount: 1,
|
||||
};
|
||||
|
||||
function mockFetch(meRole: "manager" | "groomer") {
|
||||
return vi.fn((url: string, opts?: RequestInit) => {
|
||||
if (url === "/api/staff/me") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(meRole === "manager" ? MANAGER : GROOMER),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/staff") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([MANAGER, GROOMER]) } as Response);
|
||||
}
|
||||
if (url.startsWith("/api/routes/daily")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
||||
}
|
||||
if (url === "/api/routes/optimize" && opts?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("RoutesPage", () => {
|
||||
it("renders stop cards, summary, status badge and map for a manager", async () => {
|
||||
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 High St")).toBeInTheDocument();
|
||||
|
||||
// Summary: travel time formatted, distance shown
|
||||
expect(screen.getByText("1 h 35 min")).toBeInTheDocument();
|
||||
expect(screen.getByText("42.50 km")).toBeInTheDocument();
|
||||
|
||||
// Status badge
|
||||
expect(screen.getByText("Optimized")).toBeInTheDocument();
|
||||
|
||||
// First stop is start-of-route; second shows travel from previous
|
||||
expect(screen.getByText("Start of route")).toBeInTheDocument();
|
||||
expect(screen.getByText("20 min travel from previous")).toBeInTheDocument();
|
||||
|
||||
// Map received both stops (lazy chunk resolves asynchronously)
|
||||
expect(await screen.findByTestId("route-map")).toHaveTextContent("map:2");
|
||||
});
|
||||
|
||||
it("shows the groomer selector for managers", async () => {
|
||||
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
await waitFor(() => expect(screen.getByText("Groomer")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("hides the groomer selector for groomer role (auto-filtered)", async () => {
|
||||
global.fetch = mockFetch("groomer") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
expect(screen.queryByText("Groomer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls the optimize endpoint when Optimize is clicked", async () => {
|
||||
const fetchMock = mockFetch("manager");
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Optimize"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/routes/optimize",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user