diff --git a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts new file mode 100644 index 00000000..d53ce622 --- /dev/null +++ b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper"; +import { + EMPTY_WIZARD_DRAFT, + canContinueWizardStep1, + canCreateWorkOrderFromWizard, + isWizardStepValid, +} from "@/domain/work-orders/types/work-order-wizard"; + +describe("mapWizardDraftToFormValues", () => { + it("maps minimal draft with type and site to valid form values", () => { + const draft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM" as const, + locationId: "42", + locationName: "DFW8 · Dallas, TX", + woNumber: "24818", + assigneeName: "Sarah Chen", + status: "Incomplete" as const, + }; + + const values = mapWizardDraftToFormValues(draft); + + expect(values.title).toBe("PM — #24818"); + expect(values.workOrderNumber).toBe("24818"); + expect(values.locationId).toBe("42"); + expect(values.assignedTo).toBe("Sarah Chen"); + expect(values.status).toBe("Open"); + expect(values.priority).toBe("Sev 3"); + expect(values.source).toBe("PM"); + expect(values.createdDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("maps emergency type to Sev 1 priority", () => { + const values = mapWizardDraftToFormValues({ + ...EMPTY_WIZARD_DRAFT, + type: "Emergency", + locationId: "1", + locationName: "Site A", + }); + + expect(values.priority).toBe("Sev 1"); + expect(values.title).toBe("Emergency — Site A"); + }); + + it("includes appointment and notes in description", () => { + const values = mapWizardDraftToFormValues({ + ...EMPTY_WIZARD_DRAFT, + type: "Reactive", + locationId: "1", + locationName: "Site A", + apptStart: "09:00", + apptEnd: "11:00", + pocNotes: "Call ahead", + vendorName: "Roto-Rooter", + vendorNotes: "Prefers morning", + }); + + expect(values.description).toContain("Appt:"); + expect(values.description).toContain("POC notes: Call ahead"); + expect(values.description).toContain("Vendor: Roto-Rooter"); + }); +}); + +describe("wizard step validation", () => { + it("blocks step 1 continue without type", () => { + expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false); + expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false); + }); + + it("allows step 1 continue when type is selected", () => { + const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const }; + expect(canContinueWizardStep1(draft)).toBe(true); + expect(isWizardStepValid(1, draft)).toBe(true); + }); + + it("blocks create without location", () => { + const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const }; + expect(canCreateWorkOrderFromWizard(draft)).toBe(false); + expect(isWizardStepValid(2, draft)).toBe(false); + }); + + it("allows create when location is selected", () => { + const draft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM" as const, + locationId: "99", + }; + expect(canCreateWorkOrderFromWizard(draft)).toBe(true); + expect(isWizardStepValid(2, draft)).toBe(true); + }); +}); diff --git a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts new file mode 100644 index 00000000..f96d19ee --- /dev/null +++ b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + ASSIGNEE_FILTER_UNASSIGNED, + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + buildAdvancedFilterChips, + clearAdvancedFilterGroup, + cloneAdvancedFilters, + countAdvancedFilters, +} from "@/domain/work-orders/utils/advanced-filters-utils"; + +function withSites(...sites: string[]): WorkOrderAdvancedFilters { + return { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + sites: new Set(sites), + }; +} + +describe("countAdvancedFilters", () => { + it("returns 0 for default empty filters", () => { + expect(countAdvancedFilters(EMPTY_ADVANCED_FILTERS)).toBe(0); + }); + + it("counts non-default date range as one active group", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + dateRange: "last-week" as const, + }; + expect(countAdvancedFilters(filters)).toBe(1); + }); + + it("counts each active filter group once", () => { + const filters = { + ...withSites("1", "2"), + types: new Set(["PM" as const]), + statuses: new Set(["Pending" as const]), + }; + expect(countAdvancedFilters(filters)).toBe(3); + }); +}); + +describe("buildAdvancedFilterChips", () => { + it("shows site labels when two or fewer sites are selected", () => { + const chips = buildAdvancedFilterChips(withSites("1"), { + siteNameOf: (id) => (id === "1" ? "DFW8" : id), + }); + + expect(chips).toHaveLength(1); + expect(chips[0]).toMatchObject({ + key: "sites", + group: "Site", + value: "DFW8", + }); + }); + + it("shows a count badge when more than two sites are selected", () => { + const chips = buildAdvancedFilterChips(withSites("1", "2", "3")); + + expect(chips).toHaveLength(1); + expect(chips[0]).toMatchObject({ + key: "sites", + group: "Site", + count: 3, + }); + }); + + it("creates a separate unassigned chip for dispatchers", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + dispatchers: new Set([ASSIGNEE_FILTER_UNASSIGNED, "42"]), + }; + + const chips = buildAdvancedFilterChips(filters, { + dispatcherNameOf: (id) => (id === "42" ? "Sarah Chen" : id), + }); + + expect( + chips.some((chip) => chip.key === "disp-unassigned" && chip.value === "Unassigned"), + ).toBe(true); + expect(chips.some((chip) => chip.key === "disp" && chip.value === "Sarah Chen")).toBe(true); + }); +}); + +describe("clearAdvancedFilterGroup", () => { + it("removes only the targeted filter group", () => { + const filters = { + ...withSites("1"), + types: new Set(["PM" as const]), + }; + + const next = clearAdvancedFilterGroup(filters, "sites"); + + expect(next?.sites.size).toBe(0); + expect(next?.types.has("PM")).toBe(true); + }); + + it("returns null when clearing the last active group", () => { + const next = clearAdvancedFilterGroup(withSites("1"), "sites"); + expect(next).toBeNull(); + }); +}); diff --git a/src/test/domain/work-orders/utils/list-week-utils.test.ts b/src/test/domain/work-orders/utils/list-week-utils.test.ts new file mode 100644 index 00000000..3c221b9b --- /dev/null +++ b/src/test/domain/work-orders/utils/list-week-utils.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + buildWeekWindow, + compactWeekLabel, + currentWeekMonday, + isFutureWeek, + nextWeekMonday, + weekRange, +} from "@/domain/work-orders/utils/list-week-utils"; +import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils"; + +describe("list-week-utils", () => { + it("currentWeekMonday returns a Monday ISO date", () => { + const monday = currentWeekMonday(); + expect(monday).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(mondayOf(monday)).toBe(monday); + }); + + it("compactWeekLabel formats Mon–Fri range", () => { + expect(compactWeekLabel("2026-06-01")).toBe("6/1–6/5"); + expect(compactWeekLabel("2026-05-25")).toBe("5/25–5/29"); + }); + + it("weekRange returns Monday through Friday", () => { + expect(weekRange("2026-06-01")).toEqual(["2026-06-01", "2026-06-05"]); + }); + + it("buildWeekWindow generates ordered weeks around anchor", () => { + const weeks = buildWeekWindow("2026-06-01", 2, 2); + expect(weeks.length).toBeGreaterThanOrEqual(3); + expect(weeks[0].mondayIso).toBe("2026-05-18"); + expect(weeks.some((week) => week.mondayIso === "2026-06-01")).toBe(true); + expect(weeks.every((week) => week.label.length > 0)).toBe(true); + }); + + it("buildWeekWindow always includes the next week even when it is in the future", () => { + const upcoming = nextWeekMonday(); + const weeks = buildWeekWindow(currentWeekMonday(), 2, 0); + expect(weeks.some((week) => week.mondayIso === upcoming)).toBe(true); + if (isFutureWeek(upcoming)) { + expect(isFutureWeek(upcoming)).toBe(true); + } + }); + + it("buildWeekWindow always includes the current live week", () => { + const liveMonday = currentWeekMonday(); + const weeks = buildWeekWindow("2000-01-03", 2, 0); + expect(weeks.some((week) => week.mondayIso === liveMonday && week.live)).toBe(true); + }); + + it("isFutureWeek identifies weeks after the current week", () => { + const live = currentWeekMonday(); + expect(isFutureWeek(live)).toBe(false); + expect(isFutureWeek("2099-01-05")).toBe(true); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts new file mode 100644 index 00000000..8e066e18 --- /dev/null +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +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"; +import { + buildDayGroups, + matchesAdvancedFilters, + splitScheduledUnscheduled, +} from "@/domain/work-orders/utils/table-filter-utils"; +import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "12345678901", + site: "SITE01", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Alice", + scheduledOn: "2026-06-23", + targetWeek: "", + dayGroup: "06/23", + dayLabel: "TUE · 06/23", + location: "Main St", + locationId: "10", + pm: "HVAC", + company: "Acme", + tech: "Bob", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "2026-06-30", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "Test WO", + priority: "Sev 3", + ...overrides, + }; +} + +describe("deriveDayFields", () => { + it("derives MM/DD group and weekday label", () => { + expect(deriveDayFields("2026-06-23")).toEqual({ + dayGroup: "06/23", + dayLabel: "TUE · 06/23", + }); + }); +}); + +describe("splitScheduledUnscheduled", () => { + it("splits rows without scheduledOn into unscheduled", () => { + const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" }); + const unsched = makeRow({ id: "b", scheduledOn: "" }); + const result = splitScheduledUnscheduled([scheduled, unsched]); + expect(result.scheduled).toHaveLength(1); + expect(result.unscheduled).toHaveLength(1); + expect(result.unscheduled[0].id).toBe("b"); + }); +}); + +describe("buildDayGroups", () => { + it("groups by dayGroup and sorts chronologically", () => { + const later = makeRow({ + id: "2", + scheduledOn: "2026-06-24", + dayGroup: "06/24", + dayLabel: "WED · 06/24", + }); + const earlier = makeRow({ + id: "1", + scheduledOn: "2026-06-23", + dayGroup: "06/23", + dayLabel: "TUE · 06/23", + }); + const groups = buildDayGroups([later, earlier]); + expect(groups).toHaveLength(2); + expect(groups[0].key).toBe("06/23"); + expect(groups[1].key).toBe("06/24"); + }); +}); + +describe("matchesAdvancedFilters", () => { + it("filters by site and type", () => { + const row = makeRow({ site: "SITE01", type: "PM" }); + const filters = { + ...EMPTY_ADVANCED_FILTERS, + sites: new Set(["SITE01"]), + types: new Set(["PM" as const]), + }; + expect(matchesAdvancedFilters(row, filters)).toBe(true); + + const noMatch = { + ...filters, + types: new Set(["Emergency" as const]), + }; + expect(matchesAdvancedFilters(row, noMatch)).toBe(false); + }); +}); + +describe("mapWorkOrderTableRow", () => { + it("maps API list fields to table row", () => { + const row = mapWorkOrderTableRow({ + id: 42, + internalWONumber: "12345678901", + title: "Leak repair", + location: "Building A", + status: "Open", + scheduledDate: "2026-06-23", + dueDate: "2026-06-30", + assignedTo: "Alice", + problem: "Plumbing", + source: "Reactive", + }); + expect(row.id).toBe(42); + expect(row.woNumber).toBe("12345678901"); + expect(row.scheduledOn).toBe("2026-06-23"); + expect(row.dayGroup).toBe("06/23"); + expect(row.type).toBe("Reactive"); + expect(row.status).toBe("Incomplete"); + }); +});