shoc-frontend-new/src/test/app/(protected)/dashboard/dashboard-page.test.tsx
Codex Review Integration c7388ba2d9 fix(dashboard): map breakdown type keys and open performance drill-downs
The Work Order Breakdown object from the backend serializes its keys
without JsonPropertyName, so they arrive lowercase (pm/emergency/reactive/
overdue/other). The mapper copied them straight into `status`, which the
card uses as both label and the `types` drill-down id, and
`workOrderTypeDrilldownSearch` only accepts canonical WOType values — so
every breakdown row rendered lowercase and was inert (SH-354). Route both
mapping branches through a normalizer that maps type keys back to their
canonical labels case-insensitively; `other` is not a WOType, so it stays a
labelled, non-drillable "Other" row, and unknown values (legacy array-shape
lifecycle labels) pass through unchanged.

Dispatcher Performance gated its drill-down on `completionRate > 0`, so a
dispatcher with assigned work but no completions yet was inert here while
drillable from Workload. The drill-down filters by dispatcher + range and
never uses the rate, so gate on identity instead.

Also mock useDashboardTrend in the dashboard page test so it no longer
issues a real request that failed quietly in jsdom.
2026-09-16 20:40:33 -03:00

163 lines
5.2 KiB
TypeScript

import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { renderWithProviders } from "@/test/test-utils";
const navigate = vi.fn();
const statsRange = vi.fn<(range: DashboardRangeKey) => void>();
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => navigate,
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-workload", () => ({
useDashboardWorkload: () => ({ data: [], isLoading: false, isFetching: false, error: null }),
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-performance", () => ({
useDashboardPerformance: () => ({ data: [], isLoading: false, isFetching: false, error: null }),
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-regions", () => ({
useDashboardRegions: () => ({ data: [], isLoading: false, isFetching: false, error: null }),
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-vendor-insights", () => ({
useDashboardVendorInsights: () => ({
data: [],
isLoading: false,
isFetching: false,
error: null,
}),
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-trend", () => ({
useDashboardTrend: () => ({
data: { granularity: null, points: [] },
isLoading: false,
isFetching: false,
error: null,
}),
}));
const statsData = {
scheduledTomorrow: 3,
pendingUplifts: 5,
avetaPending: 0,
breakdown: [
{ status: "PM", count: 7 },
{ status: "Completed", count: 0 },
],
};
vi.mock("@/domain/dashboard/use-cases/use-dashboard-stats", () => ({
useDashboardStats: (range: DashboardRangeKey) => {
statsRange(range);
return { data: statsData, isLoading: false, isFetching: false, error: null };
},
}));
import DashboardPage from "@/app/(protected)/dashboard";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils";
function renderDashboard() {
return renderWithProviders(<DashboardPage />);
}
describe("DashboardPage", () => {
beforeEach(() => {
navigate.mockReset();
statsRange.mockReset();
});
it("renders the prototype title, range options, and card titles", () => {
renderDashboard();
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
for (const label of ["This week", "Last 3 months", "Today", "All time"]) {
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
}
for (const title of [
"Scheduled Tomorrow",
"Pending Uplifts",
"Aveta Pending",
"Work Order Breakdown",
"Dispatcher Workload",
"Dispatcher Performance",
"Work Orders by Region",
"Vendor Insights",
]) {
expect(screen.getByText(title)).toBeInTheDocument();
}
expect(statsRange).toHaveBeenCalledWith("this-week");
});
it("exposes positive KPI cards as keyboard-accessible drill-downs", () => {
renderDashboard();
const tomorrowCard = screen.getByRole("button", { name: /Scheduled Tomorrow/ });
expect(tomorrowCard).toBeInTheDocument();
fireEvent.click(tomorrowCard);
const today = businessTodayIso();
expect(navigate).toHaveBeenCalledWith(
`/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`,
);
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));
expect(navigate).toHaveBeenCalledWith("/uplifts");
});
it("keeps zero counts inert", () => {
renderDashboard();
expect(screen.queryByRole("button", { name: /Aveta Pending/ })).toBeNull();
fireEvent.click(screen.getByText("Aveta Pending"));
expect(navigate).not.toHaveBeenCalled();
const inertRow = screen.getByText("Completed");
fireEvent.click(inertRow);
expect(navigate).not.toHaveBeenCalled();
});
it("drills the breakdown status into filtered work orders", () => {
renderDashboard();
fireEvent.click(screen.getByText("PM"));
const calls = navigate.mock.calls;
const [url] = calls[calls.length - 1] ?? [];
expect(String(url)).toContain("/workorders?");
expect(String(url)).toContain("types=PM");
});
it("switches the stats range from the toggle", () => {
renderDashboard();
fireEvent.click(screen.getByRole("button", { name: "Today" }));
expect(statsRange).toHaveBeenCalledWith("today");
});
it("shows the documented empty states when datasets are empty", () => {
renderDashboard();
// Breakdown and Trend share this empty copy, so both cards surface it.
expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(2);
expect(screen.getAllByText("No dispatchers are set up yet.")).toHaveLength(2);
expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument();
});
it("keeps drilldown dates deterministic on the business clock", () => {
const today = businessTodayIso();
expect(scheduledTomorrowDrilldownSearch(today).get("dateFrom")).toBe(addDaysIso(today, 1));
expect(avetaPendingDrilldownSearch(today).get("aveta")).toBe("1");
});
});