Files
PacketStation/frontend/src/packet/shop.test.tsx
T

311 lines
17 KiB
TypeScript
Raw Normal View History

import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { PacketRead, Stage } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
import { WeldSheet } from "./WeldSheet";
const DEPTS = DEPARTMENTS.map((d) => (d.code === "03" ? { ...d, weld: true, stations: [{ id: 1, name: "Stall 1" }, { id: 2, name: "Stall 2" }] } : d));
const CHECK = [
{ group: "Before welding", key: "dwg", text: "Drawing CI-DWG-1 Rev 0 matches the packet" },
{ group: "Before welding", key: "heat", text: "Heat numbers on material match the Materials tab" },
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
];
function stage(over: Partial<Stage> = {}): Stage {
return {
id: 5,
dept_code: "03",
station: { id: 1, name: "Stall 1" },
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
checklist: {},
filler: null,
filler_lot: null,
preheat: null,
notes: null,
clock: { elapsed_ms: 3_723_000, running: true, running_since: "2026-10-16T08:00:00Z", started_at: "2026-10-16T08:00:00Z" },
opened_at: "2026-10-16T07:00:00Z",
row_version: 1,
...over,
};
}
function shopPacket(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "in_progress",
holder: "Stall 1",
station: { id: 1, name: "Stall 1" },
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
heat_map: true,
bom: [
{ item: 1, qty: 1, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H1", row_version: 1 },
{ item: 2, qty: 2, part_id: "P-2", description: "Flange", dwg_ref: null, length: null, material: "A105", heat: null, row_version: 1 },
],
work_orders: [{ id: 1, wo_base: "L999001", lot: "001", split: "0", sub: "0", qty: 3, target_hrs: 4.5, row_version: 1 }],
checklist_items: CHECK,
stage: stage(),
can: [],
blocked: {},
...over,
});
}
function base(packet: PacketRead, roles: string[], name = "Wes Super") {
return {
"/api/auth/me": { body: makeMe({ roles, screens: ["supervisor", "stall"], user: { id: name === "Andy Welder" ? "andy" : "wes", display_name: name } }) },
"/api/departments": { body: DEPTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
"/api/departments/03/people": { body: [{ id: "andy", display_name: "Andy Welder", stamp: "75", level: "worker", station_limits: [] }] },
};
}
async function sign(secret = "pw-1") {
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
}
describe("Checklist tab", () => {
it("each tick PUTs its key and the answer replaces the packet", async () => {
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
const after = shopPacket({ version: 4, can: ["edit_checklist", "edit_stage_fields"], stage: stage({ checklist: { dwg: { done: true, at: "2026-10-16T09:00:00Z", stamp: "75" } } }) });
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PUT /api/packets/1/stage/checklist/dwg": { body: after } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
const box = screen.getByRole("checkbox", { name: /Drawing CI-DWG-1 Rev 0 matches the packet/ });
expect(box).toBeEnabled();
fireEvent.click(box);
await waitFor(() => expect(screen.getByRole("checkbox", { name: /Drawing CI-DWG-1/ })).toBeChecked());
expect(callsTo(fetch, "PUT", "/api/packets/1/stage/checklist/dwg")).toEqual([{ done: true }]);
expect(screen.getByText(/^75 · /)).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Weld record" })).toBeInTheDocument();
});
it("is read-only without edit_checklist", async () => {
mockFetch(base(shopPacket({ can: [] }), ["shop:03:lead"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
expect(screen.getByText("Checklist is completed at the stall.")).toBeInTheDocument();
});
});
describe("Weld record", () => {
it("saves a field on blur with its base", async () => {
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PATCH /api/packets/1/stage": { body: shopPacket({ version: 4, stage: stage({ filler: "ER70S-6" }) }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
const filler = screen.getByLabelText("Filler metal");
fireEvent.change(filler, { target: { value: "ER70S-6" } });
fireEvent.blur(filler);
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/stage")).toEqual([{ set: { filler: "ER70S-6" }, base: { filler: null } }]));
});
});
describe("Weld sheet", () => {
it("needs at least one item, then sends the point and the default label position", () => {
const onSave = vi.fn();
render(
<WeldSheet
p={shopPacket()}
weld={null}
at={{ page: 1, fx: 0.5, fy: 0.5 }}
editable
myStamp="75"
sub="L999001 · WELD-PART"
onSave={onSave}
onRemove={() => undefined}
onClose={() => undefined}
/>,
);
expect(screen.getByLabelText("Size")).toHaveValue('5/16"');
expect(screen.getByLabelText("Type")).toHaveValue("Fillet");
expect(screen.getByLabelText("Process")).toHaveValue("GMAW");
expect(screen.getByLabelText("Welder stamp")).toHaveValue("75");
expect(screen.getByLabelText("Piece")).toHaveTextContent("1 of 3");
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
expect(screen.getByRole("alert")).toHaveTextContent("Pick at least one BOM item this weld joins.");
expect(onSave).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Item 2" }));
fireEvent.click(screen.getByRole("button", { name: "Item 1" }));
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
expect(onSave).toHaveBeenCalledTimes(1);
const body = onSave.mock.calls[0]?.[0] as Record<string, unknown>;
expect(body).toMatchObject({ page: 1, fx: 0.5, fy: 0.5, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75" });
expect(body.bfx).toBeCloseTo(0.55);
expect(body.bfy).toBeCloseTo(0.43);
});
it("is read-only without edit rights", () => {
render(
<WeldSheet
p={shopPacket()}
weld={{ weld_no: 4, page: 1, fx: 0.2, fy: 0.3, wo_base: "L999001", piece: 1, items: [1], type: "Groove", size: "1/4\"", process: "GTAW", stamp: "75", at: null, row_version: 1, welder: null }}
at={null}
editable={false}
myStamp={null}
sub=""
onSave={() => undefined}
onRemove={() => undefined}
onClose={() => undefined}
/>,
);
expect(screen.getByRole("heading", { name: "Weld W4" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
expect(screen.getByLabelText("Type")).toBeDisabled();
});
});
describe("Packet page action bars", () => {
it("sup.wes on a review packet: Send to Q.C. waits for both boxes and a location, then signs", async () => {
const p = shopPacket({ status: "review", holder: "Supervisor", stage: stage({ clock: { elapsed_ms: 1000, running: false, running_since: null, started_at: null } }), can: ["send_to_qc", "return_to_station", "route_from_review"] });
const fetch = mockFetch({
...base(p, ["shop:03:supervisor"]),
"POST /api/packets/1/transitions": { body: shopPacket({ status: "qc", version: 5, holder: "Q.C.", can: [], blocked: {} }) },
});
renderApp("/packets/1");
const box = await screen.findByRole("region", { name: "Supervisor review" });
const send = within(box).getByRole("button", { name: "Send to Q.C." });
expect(send).toBeDisabled();
fireEvent.click(within(box).getByLabelText("Data book package ready"));
expect(send).toBeDisabled();
fireEvent.click(within(box).getByLabelText("Spool ready for inspection"));
expect(send).toBeDisabled();
fireEvent.change(within(box).getByLabelText("Spool location"), { target: { value: "Bay 2" } });
expect(send).toBeEnabled();
expect(within(box).getByRole("button", { name: "Return to station" })).toBeEnabled();
expect(within(box).getByRole("button", { name: "Send to department" })).toBeEnabled();
fireEvent.click(send);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Send to Q.C.")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "send_to_qc", expect: { status: "review", dept: "03" }, book: true, spool: true, location: "Bay 2", sign: { factor: "password", secret: "pw-1" } },
]);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Q.C. inspection"));
});
it("a lead sees the review read-only", async () => {
mockFetch(base(shopPacket({ status: "review", holder: "Supervisor", can: [] }), ["shop:03:lead"], "Lou Lead"));
renderApp("/packets/1");
expect(await screen.findByTestId("review-readonly")).toHaveTextContent("A supervisor for 03 Weld Parts reviews it and sends it on.");
expect(screen.queryByRole("button", { name: "Send to Q.C." })).not.toBeInTheDocument();
});
it("weld.andy in progress: the clock, Pause, and Submit with the missing steps as links", async () => {
const p = shopPacket({
can: ["clock", "edit_checklist", "edit_welds", "edit_stage_fields"],
blocked: { submit: ["Finish the checklist first.", "Record at least one weld first.", "Enter the filler metal first."] },
labor: { hours: 3.24, tickets: 2, per_wo: [], observed_at: null },
});
mockFetch(base(p, ["shop:03:worker"], "Andy Welder"));
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Station" });
expect(within(bar).getByRole("button", { name: "Submit to supervisor" })).toBeDisabled();
expect(within(bar).getByTestId("submit-missing")).toHaveTextContent("Before submitting: 3 checklist items · no welds marked on the drawing · filler metal");
expect(within(bar).getByRole("button", { name: "Pause" })).toBeEnabled();
expect(within(bar).getByTestId("clock")).toHaveTextContent(/01:02:0\d running · target 4.5 h/);
expect(within(bar).getByTestId("labor")).toHaveTextContent("VISUAL tickets 3.2 h");
fireEvent.click(within(bar).getByRole("button", { name: "no welds marked on the drawing" }));
expect(screen.getByRole("tab", { name: /Weld map/ })).toHaveAttribute("aria-selected", "true");
});
it("Submit signs and sends the expect guard; a stale step reloads and toasts the server text", async () => {
let fresh = false;
const p = shopPacket({ can: ["clock", "submit"], stage: stage({ filler: "ER70S-6", checklist: { dwg: { done: true }, heat: { done: true }, vt: { done: true } } }), welds: [{ weld_no: 1, page: 1, fx: 0.4, fy: 0.4, wo_base: "L999001", piece: 1, items: [1], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null, dept_code: "03" }] });
const back = shopPacket({ status: "ready", version: 9, station: null, welder: null, stage: null, holder: "Supervisor", can: [], blocked: {} });
const fetch = mockFetch({
...base(p, ["shop:03:worker"], "Andy Welder"),
"/api/packets/1": () => ({ body: fresh ? back : p }),
"POST /api/packets/1/transitions": () => {
fresh = true;
return { status: 409, body: { detail: "Someone else already took this step.", code: "stale_step" } };
},
});
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Station" });
expect(within(bar).getByText(/Everything is filled in/)).toBeInTheDocument();
fireEvent.click(within(bar).getByRole("button", { name: "Submit to supervisor" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "submit", expect: { status: "in_progress", dept: "03" }, sign: { factor: "password", secret: "pw-1" } }]);
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to assign"));
});
it("the assign sheet sends the station, the welder and the stamp", async () => {
const p = shopPacket({ status: "ready", station: null, welder: null, stage: null, holder: "Supervisor", can: ["assign", "send_back_to_warehouse"] });
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/transitions": { body: shopPacket({ status: "assigned", version: 5, can: [] }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send to station" }));
const sheet = await screen.findByRole("dialog", { name: "Send to station" });
fireEvent.change(within(sheet).getByLabelText("Station"), { target: { value: "2" } });
await within(sheet).findByRole("option", { name: "Andy Welder [75]" });
fireEvent.change(within(sheet).getByLabelText("Find a welder"), { target: { value: "andy" } });
fireEvent.change(within(sheet).getByLabelText("Welder"), { target: { value: "andy" } });
expect(within(sheet).getByLabelText("Stamp")).toHaveValue("75");
fireEvent.click(within(sheet).getByRole("button", { name: "Send to station" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "assign", expect: { status: "ready", dept: "03" }, station_id: 2, welder_id: "andy", stamp: "75" },
]),
);
});
});
describe("Changes tab, verify", () => {
it("a supervisor of a pending department ticks, verifies and signs", async () => {
const co = {
id: 9,
co_no: 2,
label: "CO-2",
origin: "manual",
review: "approved",
minor: false,
hold: false,
ecn: null,
reason: "New rev",
issued_at: "2026-10-16T08:00:00Z",
issued_by: { id: "clara", display_name: "Clara Clerk" },
items: [],
required: ["03", "09"],
verified: { "09": { by: { id: "x", display_name: "Hal Hydro" }, at: "2026-10-16T09:00:00Z", note: null, signed: true } },
pending_depts: ["03"],
open: true,
} as const;
const p = shopPacket({ change_orders: [{ ...co, items: [], verified: { ...co.verified }, required: [...co.required], pending_depts: [...co.pending_depts] }] });
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/change-orders/2/verify": { body: shopPacket({ version: 6 }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
const box = await screen.findByRole("group", { name: "Verify CO-2 for 03 Weld Parts" });
const verify = within(box).getByRole("button", { name: "Verify" });
expect(verify).toBeDisabled();
fireEvent.click(within(box).getByLabelText("I reviewed this change with my department"));
fireEvent.change(within(box).getByLabelText("Note (optional)"), { target: { value: "Checked with the crew" } });
fireEvent.click(verify);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Verify a change order for the department")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/verify")).toEqual([
{ dept: "03", note: "Checked with the crew", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("a lead gets no verify box", async () => {
const p = shopPacket({
change_orders: [
{ id: 9, co_no: 2, origin: "manual", review: "approved", minor: false, hold: false, ecn: null, reason: null, issued_at: null, issued_by: null, items: [], required: ["03"], verified: {} },
],
});
mockFetch(base(p, ["shop:03:lead"], "Lou Lead"));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
await screen.findByRole("region", { name: "Change order CO-2" });
expect(screen.queryByRole("button", { name: "Verify" })).not.toBeInTheDocument();
});
});