250 lines
13 KiB
TypeScript
250 lines
13 KiB
TypeScript
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();
|
||
|
|
});
|
||
|
|
});
|