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[] }) => (
map:{stops.length}
), })); 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); } if (/^\/api\/routes\/[^/]+\/reorder$/.test(url) && opts?.method === "PATCH") { return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response); } if (/^\/api\/routes\/[^/]+\/export\/google-maps$/.test(url)) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ platform: "google-maps", url: "https://www.google.com/maps/dir/?api=1", stopCount: 2, waypointCount: 0 }), } as Response); } if (/^\/api\/routes\/[^/]+\/export\/apple-maps$/.test(url)) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ platform: "apple-maps", url: "maps://?saddr=51.5,-0.1&daddr=51.52,-0.12", stopCount: 2, waypointCount: 0 }), } 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(); 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(); 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(); await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); expect(screen.queryByText("Groomer")).not.toBeInTheDocument(); }); it("renders a drag handle for each stop (drag-to-reorder enabled)", async () => { global.fetch = mockFetch("manager") as unknown as typeof fetch; render(); await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); expect(screen.getByLabelText("Drag to reorder Alice")).toBeInTheDocument(); expect(screen.getByLabelText("Drag to reorder Bob")).toBeInTheDocument(); }); it("flags the tight-schedule conflict on the affected stop", async () => { global.fetch = mockFetch("manager") as unknown as typeof fetch; render(); await waitFor(() => expect(screen.getByText("Bob")).toBeInTheDocument()); expect( screen.getByText(/Tight schedule — travel \+ buffer may exceed the gap/) ).toBeInTheDocument(); }); it("does not show the re-optimize hint before any manual reorder", async () => { global.fetch = mockFetch("manager") as unknown as typeof fetch; render(); await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); expect(screen.queryByText("Re-optimize")).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(); await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); fireEvent.click(screen.getByText("Optimize")); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( "/api/routes/optimize", expect.objectContaining({ method: "POST" }) ) ); }); it("renders both navigation export buttons when the route has stops", async () => { global.fetch = mockFetch("manager") as unknown as typeof fetch; render(); await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); expect(screen.getByRole("button", { name: "Open in Google Maps" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Open in Apple Maps" })).toBeInTheDocument(); }); it("fetches the export deep link and opens it when Open in Google Maps is clicked", async () => { const fetchMock = mockFetch("manager"); global.fetch = fetchMock as unknown as typeof fetch; const openSpy = vi .spyOn(window, "open") .mockReturnValue({ location: { href: "" }, close: vi.fn() } as unknown as Window); render(); await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: "Open in Google Maps" })); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/routes/r1/export/google-maps") ); expect(openSpy).toHaveBeenCalled(); }); });