311 lines
17 KiB
TypeScript
311 lines
17 KiB
TypeScript
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();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|