The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
191 lines
9.8 KiB
TypeScript
191 lines
9.8 KiB
TypeScript
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
|
import { callsTo, mockFetch } from "../test/fetchMock";
|
|
import { makePacket } from "../test/packets";
|
|
|
|
function base(packet = makePacket()) {
|
|
return {
|
|
"/api/auth/me": { body: makeMe() },
|
|
"/api/departments": { body: DEPARTMENTS },
|
|
"/api/packets/1": { body: packet },
|
|
"/api/packets/1/log": { body: [] },
|
|
};
|
|
}
|
|
|
|
describe("PacketPage", () => {
|
|
it("shows Send to warehouse disabled with the blocked reason", async () => {
|
|
mockFetch(base());
|
|
renderApp("/packets/1");
|
|
const send = await screen.findByRole("button", { name: "Send to warehouse" });
|
|
expect(send).toBeDisabled();
|
|
expect(screen.getByText("Say whether this packet needs a heat map.")).toHaveClass("why");
|
|
expect(screen.getByRole("heading", { name: "L999001 · WELD-PART" })).toBeInTheDocument();
|
|
expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to release");
|
|
expect(screen.queryByText("View only.")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("enables Send to warehouse when can includes it, and posts the transition with expect", async () => {
|
|
const ready = makePacket({ heat_map: true, can: ["edit_header", "send_to_warehouse"], blocked: {} });
|
|
const fetch = mockFetch({
|
|
...base(ready),
|
|
"POST /api/packets/1/transitions": { body: makePacket({ status: "warehouse", version: 4, can: [], blocked: {}, holder: "Warehouse" }) },
|
|
});
|
|
renderApp("/packets/1");
|
|
const send = await screen.findByRole("button", { name: "Send to warehouse" });
|
|
expect(send).toBeEnabled();
|
|
expect(screen.queryByText("Say whether this packet needs a heat map.")).not.toBeInTheDocument();
|
|
fireEvent.click(send);
|
|
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } }]);
|
|
expect(screen.getByText("View only.")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Send to warehouse" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("heat map chips PATCH with the base value", async () => {
|
|
const fetch = mockFetch({
|
|
...base(),
|
|
"PATCH /api/packets/1": { body: makePacket({ heat_map: true, version: 4 }) },
|
|
});
|
|
renderApp("/packets/1");
|
|
const group = await screen.findByRole("group", { name: "Heat map required?" });
|
|
fireEvent.click(within(group).getByRole("button", { name: "Yes" }));
|
|
await waitFor(() => expect(within(group).getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true"));
|
|
expect(callsTo(fetch, "PATCH", "/api/packets/1")).toEqual([{ set: { heat_map: true }, base: { heat_map: null } }]);
|
|
});
|
|
|
|
it("attaching a drawing uploads it, then signs and sends sign", async () => {
|
|
const fetch = mockFetch({
|
|
...base(),
|
|
"POST /api/assets": {
|
|
status: 201,
|
|
body: { id: "a1b2", mime: "application/pdf", size: 900, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/a1b2" },
|
|
},
|
|
"POST /api/packets/1/pages": { body: makePacket({ version: 4, drawing_state: "hand" }) },
|
|
});
|
|
renderApp("/packets/1");
|
|
const input = await screen.findByLabelText("Attach drawing (PDF)");
|
|
const file = new File(["%PDF-1.4\n"], "d.pdf", { type: "application/pdf" });
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
|
|
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
|
expect(within(sheet).getByText("Attach a drawing by hand")).toBeInTheDocument();
|
|
expect(within(sheet).getByText(/You are signing as/)).toHaveTextContent("You are signing as Clara Clerk.");
|
|
expect(callsTo(fetch, "POST", "/api/assets")).toHaveLength(1);
|
|
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-123" } });
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
|
|
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/pages")).toEqual([
|
|
{ asset_id: "a1b2", type: "drawing", dwg: "CI-DWG-1", rev: "0", sign: { factor: "password", secret: "pw-123" } },
|
|
]);
|
|
expect(await screen.findByText("Drawing attached (1 page).")).toBeInTheDocument();
|
|
});
|
|
|
|
it("a wrong signature keeps the sheet open with the attempts left", async () => {
|
|
mockFetch({
|
|
...base(),
|
|
"POST /api/assets": { status: 201, body: { id: "a1", mime: "application/pdf", size: 9, sha256: "00", pages: 2, original_name: "d.pdf", url: "/_blob/a1" } },
|
|
"POST /api/packets/1/pages": { status: 403, body: { detail: "Wrong password.", code: "wrong_secret", attempts_left: 2 } },
|
|
});
|
|
renderApp("/packets/1");
|
|
fireEvent.change(await screen.findByLabelText("Attach drawing (PDF)"), { target: { files: [new File(["%PDF"], "d.pdf")] } });
|
|
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
|
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "nope" } });
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
|
expect(await within(sheet).findByText("Wrong password.")).toBeInTheDocument();
|
|
expect(within(sheet).getByText("2 attempts left.")).toBeInTheDocument();
|
|
expect(within(sheet).getByText("Three wrong attempts end the session.")).toBeInTheDocument();
|
|
});
|
|
|
|
it("a stale step shows the server message and reloads the packet", async () => {
|
|
let gets = 0;
|
|
const fetch = mockFetch({
|
|
...base(),
|
|
"/api/packets/1": () => {
|
|
gets++;
|
|
return gets === 1
|
|
? { body: makePacket({ heat_map: true, can: ["send_to_warehouse", "edit_header"], blocked: {} }) }
|
|
: { body: makePacket({ status: "warehouse", heat_map: true, can: [], blocked: {}, holder: "Warehouse" }) };
|
|
},
|
|
"POST /api/packets/1/transitions": {
|
|
status: 409,
|
|
body: { detail: "Someone else already took this step.", code: "stale_step", current: { status: "warehouse", dept: "03", version: 5 } },
|
|
},
|
|
});
|
|
renderApp("/packets/1");
|
|
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
|
|
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
|
|
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
|
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
|
|
});
|
|
|
|
it("asks an admin for the IT reason and retries with it", async () => {
|
|
const admin = makeMe({ roles: ["admin"], screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"] });
|
|
let tries = 0;
|
|
const fetch = mockFetch({
|
|
...base(makePacket({ heat_map: true, can: ["send_to_warehouse"], blocked: {} })),
|
|
"/api/auth/me": { body: admin },
|
|
"POST /api/packets/1/transitions": () =>
|
|
++tries === 1
|
|
? { status: 400, body: { detail: "Give an IT reason.", code: "it_reason_required" } }
|
|
: { body: makePacket({ status: "warehouse", can: [], blocked: {} }) },
|
|
});
|
|
renderApp("/packets/1");
|
|
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
|
|
const sheet = await screen.findByRole("dialog", { name: "IT reason" });
|
|
fireEvent.change(within(sheet).getByLabelText("Why IT is making this change"), { target: { value: "Clerk is out sick today" } });
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Continue" }));
|
|
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
|
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } },
|
|
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" }, it_reason: "Clerk is out sick today" },
|
|
]);
|
|
});
|
|
|
|
it("shows View only with the holder when the user has no actions", async () => {
|
|
mockFetch({
|
|
...base(makePacket({ can: [], blocked: {}, holder: "Clerical" })),
|
|
"/api/auth/me": { body: makeMe({ roles: ["warehouse"], screens: ["warehouse"] }) },
|
|
});
|
|
renderApp("/packets/1");
|
|
const note = (await screen.findByText("View only.")).closest(".viewonly") as HTMLElement;
|
|
expect(note).toHaveTextContent("This packet is with Clerical.");
|
|
expect(screen.queryByRole("button", { name: /Send/ })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("group", { name: "Heat map required?" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("reloads when a changed event names this packet", async () => {
|
|
const sources: Array<{ emit: (t: string, d: unknown) => void }> = [];
|
|
class FakeEventSource {
|
|
listeners = new Map<string, (ev: MessageEvent) => void>();
|
|
constructor() {
|
|
sources.push(this);
|
|
}
|
|
addEventListener(type: string, fn: (ev: MessageEvent) => void) {
|
|
this.listeners.set(type, fn);
|
|
}
|
|
close() {}
|
|
emit(type: string, data: unknown) {
|
|
this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) }));
|
|
}
|
|
}
|
|
vi.stubGlobal("EventSource", FakeEventSource);
|
|
const fetch = mockFetch(base());
|
|
renderApp("/packets/1");
|
|
await screen.findByRole("heading", { name: "L999001 · WELD-PART" });
|
|
expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(1);
|
|
vi.useFakeTimers();
|
|
try {
|
|
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 2, version: 9 }));
|
|
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 1, version: 9 }));
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
|
});
|
|
});
|