Merge branch 'dev' into fix/ab/sh-368-completion-uploads

This commit is contained in:
Alexandre Brandizzi 2026-09-17 15:02:16 -03:00 • committed by GitHub
commit f5da80cd6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 96 additions and 32 deletions

View file

@ -4,6 +4,7 @@ import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
import {
buildSelectedDayGroup,
@ -70,9 +71,7 @@ export function useWorkOrdersTableDayView({
});
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
const todayKey =
filters.weekMonday === currentWeekMonday()
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
: null;
filters.weekMonday === currentWeekMonday() ? deriveDayFields(todayIso()).dayGroup : null;
return {
scheduled,

View file

@ -10,6 +10,7 @@ import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-
import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
@ -17,8 +18,10 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean {
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
return false;
}
const today = new Date().toISOString().slice(0, 10);
return row.scheduledOn.slice(0, 10) < today;
// scheduledOn is a calendar date, so compare it to the user's local calendar
// day. A UTC "today" rolls over hours early west of UTC and flags work
// scheduled for today as Past Due every evening.
return row.scheduledOn.slice(0, 10) < todayIso();
}
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {

View file

@ -1,6 +1,9 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PmSchedulesListPage from "@/app/(protected)/pmschedules/index";
// Static import: a dynamic import inside the test transforms the edit page's
// module graph against the 5s test budget, which times out under load.
import EditPage from "@/app/(protected)/pmschedules/edit/[id]";
import { renderWithProviders } from "@/test/test-utils";
const usePmSchedulesList = vi.fn();
@ -75,9 +78,7 @@ describe("PmSchedulesListPage actions", () => {
});
describe("PmScheduleEditUnavailablePage", () => {
it("shows unavailable message instead of edit form when canEdit is false", async () => {
const EditPage = (await import("@/app/(protected)/pmschedules/edit/[id]")).default;
it("shows unavailable message instead of edit form when canEdit is false", () => {
renderWithProviders(<EditPage />, {
route: "/pmschedules/edit/1",
routerProps: { initialEntries: ["/pmschedules/edit/1"] },

View file

@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
import {
@ -49,6 +49,10 @@ function renderCreateModal() {
});
}
// delay: null skips user-event's per-keystroke setTimeout yields, which only
// add real-timer latency here.
let user = userEvent.setup({ delay: null });
async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
target: { value: name },
@ -57,7 +61,7 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
// phone-or-email rule, so the address is filled to keep the form's only
// outstanding problem the one under test.
fillRequiredAddress();
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
}
function fillRequiredAddress({
@ -74,7 +78,15 @@ function fillRequiredAddress({
pickVendorState(state);
}
describe("VendorCreateModal validation", () => {
// Each test mounts the full MUI dialog (Autocompletes, 50-option State list)
// and costs ~1s of CPU when idle; the first also pays JIT warm-up. Under
// parallel agent load the 5s default is exceeded by CPU starvation alone,
// matching the 10s budgets already used by vendors-list.test.tsx.
describe("VendorCreateModal validation", { timeout: 15_000 }, () => {
beforeEach(() => {
user = userEvent.setup({ delay: null });
});
it("explains the company phone-or-email requirement after submission", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
@ -138,8 +150,8 @@ describe("VendorCreateModal validation", () => {
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
await userEvent.type(phone, "314");
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
await user.type(phone, "314");
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument();
expect(phone).toHaveAttribute("aria-invalid", "true");
@ -153,8 +165,8 @@ describe("VendorCreateModal validation", () => {
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
await userEvent.type(email, "not-an-email");
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
await user.type(email, "not-an-email");
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(await screen.findByText("Invalid email")).toBeInTheDocument();
expect(email).toHaveAttribute("aria-invalid", "true");
@ -169,7 +181,7 @@ describe("VendorCreateModal validation", () => {
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
await userEvent.type(phone, "3145550100");
await user.type(phone, "3145550100");
await waitFor(() =>
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(),
);

View file

@ -68,11 +68,19 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
};
}
// delay: null skips user-event's per-keystroke setTimeout yields, which only
// add real-timer latency here.
let user = userEvent.setup({ delay: null });
beforeEach(() => {
saveMutate.mockReset();
user = userEvent.setup({ delay: null });
});
describe("VendorDetailDrawer selected-technician display", () => {
// The edit-mode drawer is a heavy MUI render and the first test pays JIT
// warm-up; under parallel agent load the 5s default is exceeded by CPU
// starvation alone, matching the 10s budgets in vendors-list.test.tsx.
describe("VendorDetailDrawer selected-technician display", { timeout: 15_000 }, () => {
it.each([
{ removedPosition: 1, remainingId: 2, remainingName: "Beth" },
{ removedPosition: 2, remainingId: 1, remainingName: "Adam" },
@ -107,10 +115,10 @@ describe("VendorDetailDrawer selected-technician display", () => {
{ route: "/vendors", withAuth: false },
);
await userEvent.click(
await user.click(
screen.getByRole("button", { name: `Remove technician ${removedPosition}` }),
);
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1));
expect(saveMutate).toHaveBeenCalledWith(
@ -161,12 +169,12 @@ describe("VendorDetailDrawer selected-technician display", () => {
{ route: "/vendors", withAuth: false },
);
await userEvent.click(screen.getByRole("button", { name: "Remove technician 2" }));
await user.click(screen.getByRole("button", { name: "Remove technician 2" }));
const save = screen.getByRole("button", { name: "Save changes" });
expect(save).toBeEnabled();
await userEvent.click(save);
await user.click(save);
expect(
await screen.findByText("Provide a company phone or email (at least one required)"),
@ -444,10 +452,10 @@ describe("VendorDetailDrawer deactivation prompt", () => {
);
const toggle = screen.getByRole("switch", { name: "Active status" });
await userEvent.click(toggle);
await user.click(toggle);
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await user.click(toggle);
expect(toggle).not.toBeChecked();
expect(onRequestDeactivation).not.toHaveBeenCalled();
@ -472,7 +480,7 @@ describe("VendorDetailDrawer deactivation prompt", () => {
{ route: "/vendors", withAuth: false },
);
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
await user.click(screen.getByRole("switch", { name: "Active status" }));
expect(onRequestDeactivation).toHaveBeenCalledWith(vendor);
});

View file

@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
@ -120,6 +120,12 @@ function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number)
};
}
// Role and text queries over the whole board scan every row cell; scoping them
// to the pagination footer keeps each waitFor poll cheap on a loaded machine.
function pagination() {
return within(screen.getByTestId("wo-day-pagination"));
}
function pagesAfter(callCount: number): number[] {
return searchBoard.mock.calls.slice(callCount).map((call) => {
const params = call[0] as WorkOrderBoardSearchQueryParams;
@ -213,14 +219,14 @@ describe("advanced search page sync after mutation", () => {
);
});
expect(screen.getByText("WO-0")).toBeInTheDocument();
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
expect(pagination().getByText("Page 1 of 2")).toBeInTheDocument();
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
fireEvent.click(pagination().getByRole("button", { name: "Next page" }));
await waitFor(() => {
expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1");
expect(screen.getByText("WO-25")).toBeInTheDocument();
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
expect(pagination().getByText("Page 2 of 2")).toBeInTheDocument();
});
const callsBeforeMutation = searchBoard.mock.calls.length;
@ -236,12 +242,12 @@ describe("advanced search page sync after mutation", () => {
expect(screen.getByTestId("harness-state")).toHaveTextContent(
"adv=true rows=20 total=20 page=0",
);
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
expect(pagination().getByText("Page 1 of 1")).toBeInTheDocument();
});
expect(screen.getByText("WO-0")).toBeInTheDocument();
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
expect(screen.getByText("RESULTS · 20 items")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
expect(pagination().getByRole("button", { name: "Previous page" })).toBeDisabled();
expect(pagination().getByRole("button", { name: "Next page" })).toBeDisabled();
}, 15000);
});

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
@ -526,6 +526,41 @@ describe("computeIsPastDue", () => {
});
});
describe("computeIsPastDue across the local/UTC day boundary", () => {
beforeEach(() => {
// UTC-3 with no DST: local evening is already the next day in UTC.
vi.stubEnv("TZ", "America/Sao_Paulo");
vi.useFakeTimers({ toFake: ["Date"] });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it("does not flag today's work as Past Due at 23:30 local (02:30 UTC next day)", () => {
vi.setSystemTime(new Date("2026-09-17T02:30:00.000Z"));
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe(
false,
);
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-15", status: "Scheduled" }))).toBe(
true,
);
});
it("flags yesterday's work as Past Due at 00:30 local", () => {
vi.setSystemTime(new Date("2026-09-17T03:30:00.000Z"));
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe(
true,
);
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-17", status: "Scheduled" }))).toBe(
false,
);
});
});
describe("enrichTableRow", () => {
it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => {
const enriched = enrichTableRow(