Files
PacketStation/frontend/src/screens/warehouse/WarehouseHome.test.tsx
T

250 lines
13 KiB
TypeScript
Raw Normal View History

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> = {}): 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> = {}): 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();
});
});