import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { BoardResponse, PacketCard, ShortRow } from "../../api/types"; import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth"; import { callsTo, mockFetch } from "../../test/fetchMock"; import { makeCard, makePacket } from "../../test/packets"; const WADE = makeMe({ roles: ["warehouse"], screens: ["warehouse"], user: { sam_account: "wh.wade", display_name: "Wade Warehouse" } }); const who = { id: "u-wade", display_name: "Wade Warehouse" }; const WELD = { code: "03", name: "Weld Parts" }; const ELEC = { code: "14", name: "Electrical" }; function whCard(id: number, over: Partial = {}): PacketCard { return makeCard({ id, status: "warehouse", holder: "Warehouse", unit: { id: 100 + id, number: `L99900${id}` }, wh_stage: "incoming", wh: null, wh_sent_at: "2026-10-15T08:00:00Z", visual_rel: null, bom_total: 3, bom_pulled: 0, bom_missing_heat: 0, ...over, }); } const SHORT: ShortRow = { packet: { id: 9, unit_number: "L999009", op: "FIT-UP", drawing: "D-9", status: "ready", wh_stage: null, station: null, holder: "Weld Parts", dept: WELD, }, item: 2, bom_line_id: 92, description: "Pipe 2in", part_id: "P-2", material: "A106", length: "20 ft", qty: 4, qty_open: 1, reason: "Not in stock", ref: "PO 4471", eta: "2026-10-01", note: null, opened_at: "2026-10-10T08:00:00Z", age_days: 6, late: true, short_id: 5, row_version: 1, }; function board(over: Partial = {}): BoardResponse { return { kpis: { waiting: 3, pulling: 1, hold: 1, pulled: 1, open_shorts: 1, released_24h: 2 }, columns: { incoming: [ whCard(1, { dept: WELD, wh_sent_at: "2026-10-15T10:00:00Z" }), whCard(2, { dept: ELEC, wh_sent_at: "2026-10-12T10:00:00Z" }), whCard(3, { dept: WELD, wh_sent_at: "2026-10-14T10:00:00Z" }), ], pulling: [whCard(4, { wh_stage: "pulling", wh: { stage: "pulling", by: who, at: "2026-10-16T07:00:00Z", reason: null, note: null, auto: true }, bom_pulled: 1, bom_missing_heat: 1 })], hold: [ whCard(5, { wh_stage: "hold", wh: { stage: "hold", by: who, at: "2026-10-16T07:00:00Z", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri", auto: false }, }), ], pulled: [whCard(6, { wh_stage: "pulled", wh: { stage: "pulled", by: who, at: "2026-10-16T07:00:00Z", reason: null, note: null, auto: false }, bom_pulled: 3 })], released: [makeCard({ id: 7, status: "ready", holder: "Weld Parts", unit: { id: 107, number: "L999007" }, released_at: "2026-10-16T06:00:00Z" })], }, shorts: [SHORT], ...over, }; } function base(b: BoardResponse = board(), me = WADE) { return { "/api/auth/me": { body: me }, "/api/departments": { body: DEPARTMENTS }, "/api/warehouse/board": { body: b }, "/api/projects": { body: [] }, }; } const column = (title: string) => screen.getByRole("heading", { name: new RegExp(`^${title.replace(/[()]/g, "\\$&")}`) }).closest("section") as HTMLElement; const cardOf = (name: string) => screen.getByRole("group", { name: new RegExp(`^${name}`) }); describe("WarehouseHome", () => { it("shows the numbers, the five columns, and groups cards by department oldest first", async () => { mockFetch(base()); renderApp("/warehouse"); expect(await screen.findByRole("heading", { name: /^Waiting to pull/ })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Warehouse" })).toBeInTheDocument(); expect(screen.getByText("Parts pull")).toBeInTheDocument(); for (const t of ["Waiting to pull", "Being pulled", "On hold", "Pulled, ready for production", "Released to shop (3 days)"]) { expect(column(t)).toBeInTheDocument(); } expect(screen.getByLabelText("Waiting to pull: 3")).toBeInTheDocument(); expect(screen.getByLabelText("On hold: 1")).toHaveClass("hot"); expect(screen.getByLabelText("Ready for production: 1")).toHaveClass("hot"); expect(screen.getByLabelText("Open shorts: 1")).toHaveClass("hot"); expect(screen.getByLabelText("Released last 24 h: 2")).toBeInTheDocument(); const waiting = column("Waiting to pull"); // Electrical has the oldest packet, so it comes first; Weld Parts cards go oldest first. const heads = Array.from(waiting.querySelectorAll(".whgrp")).map((g) => g.textContent); expect(heads).toEqual(["Electrical1", "Weld Parts2"]); const jobs = Array.from(waiting.querySelectorAll(".whcard .job")).map((j) => j.textContent); expect(jobs).toEqual(["L999002 · WELD-PART", "L999003 · WELD-PART", "L999001 · WELD-PART"]); expect(within(column("Released to shop (3 days)")).getByRole("link", { name: /L999007/ })).toBeInTheDocument(); }); it("shows the pill, the line and the buttons for each stage", async () => { mockFetch(base()); renderApp("/warehouse"); const inc = await screen.findByRole("group", { name: "L999002 · WELD-PART, Waiting to pull" }); expect(within(inc).getByText("WH · waiting")).toBeInTheDocument(); expect(within(inc).getByText(/^Waiting .*, not started yet$/)).toBeInTheDocument(); expect(within(inc).getByRole("button", { name: "Start pulling" })).toHaveClass("pri"); expect(within(inc).getByRole("button", { name: "Hold" })).toBeInTheDocument(); expect(within(inc).getByText((_, el) => el?.tagName === "SPAN" && el.textContent === "0/3 lines pulled")).toBeInTheDocument(); const pul = cardOf("L999004"); expect(within(pul).getByText("WH · being pulled")).toBeInTheDocument(); expect(within(pul).getByText(/^Being pulled by Wade Warehouse/)).toBeInTheDocument(); expect(within(pul).getByRole("button", { name: "Mark pulled" })).not.toHaveClass("pri"); expect(within(pul).getByText("1 heat missing")).toBeInTheDocument(); const hold = cardOf("L999005"); expect(within(hold).getByText("WH · on hold")).toBeInTheDocument(); expect(within(hold).getByText(/^On hold: Waiting on purchasing \/ PO · PO 4471 due Fri/)).toBeInTheDocument(); expect(within(hold).getByRole("button", { name: "Resume pulling" })).toHaveClass("pri"); expect(within(hold).queryByRole("button", { name: "Hold" })).not.toBeInTheDocument(); const done = cardOf("L999006"); expect(within(done).getByText("WH · pulled")).toBeInTheDocument(); expect(within(done).getByRole("button", { name: "Release…" })).toBeInTheDocument(); expect(within(done).getByRole("button", { name: "Back to pulling" })).toBeInTheDocument(); }); it("shows no stage buttons to a role outside the warehouse", async () => { const wes = makeMe({ roles: ["shop:03:supervisor"], screens: ["warehouse", "supervisor"] }); mockFetch(base(board(), wes)); renderApp("/warehouse"); const inc = await screen.findByRole("group", { name: /^L999002/ }); expect(within(inc).getByText("WH · waiting")).toBeInTheDocument(); expect(within(inc).queryByRole("button")).not.toBeInTheDocument(); }); it("Start pulling posts the stage with the stage it saw", async () => { const fetch = mockFetch({ ...base(), "POST /api/packets/2/wh": { body: makePacket({ id: 2, status: "warehouse" }) } }); renderApp("/warehouse"); const inc = await screen.findByRole("group", { name: /^L999002/ }); fireEvent.click(within(inc).getByRole("button", { name: "Start pulling" })); await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toEqual([{ stage: "pulling", expect_stage: "incoming" }])); }); it("the hold sheet refuses Other without a note, then posts the reason and note", async () => { const fetch = mockFetch({ ...base(), "POST /api/packets/2/wh": { body: makePacket({ id: 2, status: "warehouse" }) } }); renderApp("/warehouse"); const inc = await screen.findByRole("group", { name: /^L999002/ }); fireEvent.click(within(inc).getByRole("button", { name: "Hold" })); const sheet = await screen.findByRole("dialog", { name: "Put on hold" }); fireEvent.click(within(sheet).getByLabelText("Other")); fireEvent.click(within(sheet).getByRole("button", { name: "Put on hold" })); expect(within(sheet).getByRole("alert")).toHaveTextContent("Say what it's waiting on."); expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toHaveLength(0); fireEvent.click(within(sheet).getByLabelText("Waiting on purchasing / PO")); fireEvent.change(within(sheet).getByLabelText(/^Note/), { target: { value: "PO 4471 due Fri" } }); fireEvent.click(within(sheet).getByRole("button", { name: "Put on hold" })); await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toEqual([ { stage: "hold", expect_stage: "incoming", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri" }, ]), ); expect(screen.queryByRole("dialog", { name: "Put on hold" })).not.toBeInTheDocument(); }); it("Mark pulled asks first when lines remain", async () => { const fetch = mockFetch({ ...base(), "POST /api/packets/4/wh": { body: makePacket({ id: 4, status: "warehouse" }) } }); renderApp("/warehouse"); const pul = await screen.findByRole("group", { name: /^L999004/ }); fireEvent.click(within(pul).getByRole("button", { name: "Mark pulled" })); const sheet = await screen.findByRole("dialog", { name: "Not everything is pulled yet" }); expect(sheet).toHaveTextContent("2 lines not ticked Pulled and 1 heat number missing. Mark it pulled anyway?"); expect(callsTo(fetch, "POST", "/api/packets/4/wh")).toHaveLength(0); fireEvent.click(within(sheet).getByRole("button", { name: "Mark pulled" })); await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/4/wh")).toEqual([{ stage: "pulled", expect_stage: "pulling" }])); }); it("a drop on the released column is refused with the toast; a drop on a stage moves the card", async () => { const fetch = mockFetch({ ...base(), "POST /api/packets/6/wh": { body: makePacket({ id: 6, status: "warehouse" }) } }); renderApp("/warehouse"); const done = await screen.findByRole("group", { name: /^L999006/ }); const drag = (to: string) => { const col = document.querySelector(`[data-whcol="${to}"]`) as HTMLElement; const spy = vi.fn(() => [col]); Object.defineProperty(document, "elementsFromPoint", { value: spy, configurable: true }); fireEvent.pointerDown(done, { clientX: 10, clientY: 10, button: 0, pointerType: "mouse" }); fireEvent.pointerMove(window, { clientX: 60, clientY: 40 }); fireEvent.pointerUp(window, { clientX: 60, clientY: 40 }); }; drag("released"); expect(await screen.findByRole("status")).toHaveTextContent("Open the packet and use Release to supervisor. It checks heats and shorts first."); expect(callsTo(fetch, "POST", "/api/packets/6/wh")).toHaveLength(0); // A drag never counts as a click: the packet does not open. fireEvent.click(done); expect(screen.getByRole("heading", { name: "Warehouse" })).toBeInTheDocument(); drag("pulling"); await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/6/wh")).toEqual([{ stage: "pulling", expect_stage: "pulled" }])); }); it("lists the open shorts and receives one with a heat", async () => { const fetch = mockFetch({ ...base(), "POST /api/packets/9/bom/2/short/receipts": { body: makePacket({ id: 9, status: "ready" }) }, }); renderApp("/warehouse"); const table = await screen.findByRole("table", { name: "Open shorts" }); expect(within(table).getByText("PO 4471")).toBeInTheDocument(); expect(within(table).getByText(/· late$/)).toBeInTheDocument(); fireEvent.click(within(table).getByRole("button", { name: "Receive" })); const sheet = await screen.findByRole("dialog", { name: "Receive short part" }); fireEvent.click(within(sheet).getByRole("button", { name: "Receive" })); expect(within(sheet).getByRole("alert")).toHaveTextContent("Scan or type the heat number of the part received."); fireEvent.change(within(sheet).getByLabelText("Heat"), { target: { value: "HT-9" } }); fireEvent.click(within(sheet).getByRole("button", { name: "Receive" })); await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/9/bom/2/short/receipts")).toEqual([{ qty: 1, heat: "HT-9", note: null }])); await waitFor(() => expect(screen.queryByRole("dialog", { name: "Receive short part" })).not.toBeInTheDocument()); }); it("says so when there are no open shorts", async () => { mockFetch(base(board({ shorts: [] }))); renderApp("/warehouse"); expect(await screen.findByText("No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.")).toBeInTheDocument(); }); });