- C1 (issue 1): over target follows VISUAL ticket hours, not the timer. - C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one. - C3 (issue 25): an incomplete unit ITP blocks filing. - C4 (issue 27): prints open the print dialog on the page, no new tab. - C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets. - C6 (issue 8): the data book leaves out the history pages. - C7 (issue 18): the supervisor approves each received short; accept and filing wait for it. - C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START. - C9 (issue 32): Q.C. and Clerical may run the stamp check. - C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only. - C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall. Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <[email protected]>
267 lines
14 KiB
TypeScript
267 lines
14 KiB
TypeScript
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { PacketRead, PacketSeal, SealView, VcloseCheck } from "../api/types";
|
|
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
|
import { callsTo, mockFetch, type Route } from "../test/fetchMock";
|
|
import { makePacket } from "../test/packets";
|
|
|
|
const CLARA = { id: "11111111-1111-1111-1111-111111111111", display_name: "Clara Clerk" };
|
|
const ANN = { id: "22222222-2222-2222-2222-222222222222", display_name: "Ann Admin" };
|
|
|
|
function check(over: Partial<VcloseCheck> = {}): VcloseCheck {
|
|
return { observed_at: "2026-10-17T07:00:00Z", closed_at: null, open_wos: [], agrees: true, ...over };
|
|
}
|
|
|
|
function seal(over: Partial<PacketSeal> = {}): PacketSeal {
|
|
return {
|
|
id: 4,
|
|
sealed_at: "2026-10-17T10:00:00Z",
|
|
sealed_by: CLARA,
|
|
key_id: "k1",
|
|
signed: true,
|
|
broken_at: null,
|
|
broken_by: null,
|
|
broken_reason: null,
|
|
open_wos: null,
|
|
databook: { asset_id: "a-db", url: "/_blob/a-db", download_url: "/_blob/a-db?download=1", pages: 14, size: 90000, file_name: "L999-LotL999001_CI-DWG-1_Manufacturing_Data_Book.pdf" },
|
|
...over,
|
|
};
|
|
}
|
|
|
|
/** Complete, routed to the data book, not confirmed closed in VISUAL. */
|
|
function toClose(over: Partial<PacketRead> = {}): PacketRead {
|
|
return makePacket({
|
|
status: "complete",
|
|
routed_to: "databook",
|
|
holder: "Clerical",
|
|
source_kind: "visual",
|
|
can: ["set_vclosed"],
|
|
blocked: { file: ["Close the job in VISUAL first."] },
|
|
vclose_check: check(),
|
|
...over,
|
|
});
|
|
}
|
|
|
|
/** Complete and confirmed closed: ready for the data book. */
|
|
function toFile(over: Partial<PacketRead> = {}): PacketRead {
|
|
return toClose({ vclosed: { at: "2026-10-17T09:00:00Z", by: CLARA }, can: ["file", "set_vclosed"], blocked: {}, ...over });
|
|
}
|
|
|
|
function filed(over: Partial<PacketRead> = {}): PacketRead {
|
|
return toFile({ status: "filed", version: 9, databook_at: "2026-10-17T10:00:00Z", seal: seal(), seals: 1, can: [], blocked: {}, ...over });
|
|
}
|
|
|
|
function base(packet: PacketRead, extra: Record<string, Route> = {}, roles = ["clerical"]) {
|
|
return {
|
|
"/api/auth/me": { body: makeMe({ roles }) },
|
|
"/api/departments": { body: DEPARTMENTS },
|
|
"/api/packets/1": { body: packet },
|
|
"/api/packets/1/log": { body: [] },
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function sealView(valid: boolean | null, s: PacketSeal = seal()): SealView {
|
|
return { seal: s, valid, seals: [s] };
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
const panel = () => screen.findByRole("region", { name: "Close and file" });
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("Close and file: ready to close", () => {
|
|
it("a hand packet: shows the state, the exports, and Clerical confirms the close", async () => {
|
|
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
const hand = { source_kind: "hand" as const, vclose_check: null };
|
|
const fetch = mockFetch(base(toClose(hand), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4, ...hand }) } }));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
expect(box).toHaveTextContent("Back from the shop, ready to close. Close the job in VISUAL, then click below.");
|
|
const file = within(box).getByRole("button", { name: "File in the data book" });
|
|
expect(file).toBeDisabled();
|
|
expect(within(box).getByText("Close the job in VISUAL first.")).toHaveClass("why");
|
|
|
|
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
|
|
expect(open).toHaveBeenCalledWith("/api/packets/1/pdf/databook", "_blank", "noopener");
|
|
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/api/packets/1/pdf/databook?download=1");
|
|
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("download");
|
|
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toHaveAttribute("href", "/api/packets/1/export/traceability.csv");
|
|
fireEvent.click(within(box).getByRole("button", { name: "Print…" }));
|
|
expect(await screen.findByRole("dialog", { name: "Print" })).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
|
|
fireEvent.click(within(box).getByRole("button", { name: "Job closed in VISUAL" }));
|
|
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book."));
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/vclosed")).toEqual([{ closed: true }]);
|
|
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent(/Job closed in VISUAL .* by Clara Clerk\./);
|
|
expect(screen.getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
|
|
});
|
|
|
|
it("a VISUAL packet waits for the feed: no manual close, File says why it is blocked", async () => {
|
|
const why = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
|
|
mockFetch(base(toClose({ blocked: { file: [why] }, vclose_check: check({ open_wos: ["L999001/001"] }) })));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
expect(box).toHaveTextContent("Back from the shop, ready to close. Close the job in VISUAL. The packet moves on to the data book when the feed sees it closed.");
|
|
expect(within(box).queryByRole("button", { name: "Job closed in VISUAL" })).not.toBeInTheDocument();
|
|
expect(within(box).getByRole("button", { name: "File in the data book" })).toBeDisabled();
|
|
expect(within(box).getByText(why)).toHaveClass("why");
|
|
});
|
|
|
|
it("a VISUAL packet the feed saw closed is ready for the data book", async () => {
|
|
mockFetch(base(toClose({ can: ["file"], blocked: {}, vclose_check: check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }) })));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
expect(box).toHaveTextContent(/^Ready for the data book\. VISUAL shows the job closed since .+\. Check the data book, then file it\./);
|
|
expect(within(box).getByRole("button", { name: "File in the data book" })).toBeEnabled();
|
|
expect(within(box).queryByRole("button", { name: "Not closed in VISUAL, move back" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it.each([
|
|
["work orders still open", check({ open_wos: ["L999001/001", "L999001/002"] }), /^VISUAL still shows 2 work orders open: L999001\/001, L999001\/002\.$/],
|
|
["one work order open", check({ open_wos: ["L999001/001"] }), /^VISUAL still shows 1 work order open: L999001\/001\.$/],
|
|
["the feed has not looked", check({ observed_at: null }), /^The feed has not checked VISUAL yet\.$/],
|
|
])("the VISUAL line when %s", async (_name, c, text) => {
|
|
mockFetch(base(toClose({ vclose_check: c })));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
expect(within(box).getByTestId("visual-line")).toHaveTextContent(text);
|
|
});
|
|
|
|
it("a hand packet has no VISUAL line", async () => {
|
|
mockFetch(base(toClose({ source_kind: "hand", vclose_check: null })));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
expect(box).toHaveTextContent("Back from the shop, ready to close.");
|
|
expect(within(box).queryByTestId("visual-line")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("is not shown before complete or to a role with nothing to do", async () => {
|
|
mockFetch(base(toClose({ can: [], blocked: {} }), {}, ["warehouse"]));
|
|
renderApp("/packets/1");
|
|
await screen.findByRole("tab", { name: /^Materials/ });
|
|
expect(screen.queryByRole("region", { name: "Close and file" })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Close and file: ready for the data book", () => {
|
|
it("files with the signature", async () => {
|
|
const fetch = mockFetch(
|
|
base(toFile(), {
|
|
"POST /api/packets/1/transitions": (req) =>
|
|
(req.body as { sign?: unknown }).sign
|
|
? { body: filed() }
|
|
: { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } },
|
|
"/api/packets/1/seal": { body: sealView(true) },
|
|
}),
|
|
);
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
expect(box).toHaveTextContent(/^Ready for the data book\. Job closed in VISUAL .+ by Clara Clerk\./);
|
|
// A VISUAL packet: the feed decides, so there is no manual move back.
|
|
expect(within(box).queryByRole("button", { name: "Not closed in VISUAL, move back" })).not.toBeInTheDocument();
|
|
fireEvent.click(within(box).getByRole("button", { name: "File in the data book" }));
|
|
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("File in the data book")).toBeInTheDocument();
|
|
await sign();
|
|
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
|
{ action: "file", expect: { status: "complete", dept: "03" } },
|
|
{ action: "file", expect: { status: "complete", dept: "03" }, sign: { factor: "password", secret: "pw-1" } },
|
|
]);
|
|
});
|
|
|
|
it("File is blocked while VISUAL shows work orders open, with no second confirmation", async () => {
|
|
const why = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
|
|
const fetch = mockFetch(base(toFile({ can: [], blocked: { file: [why] }, vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
const line = within(box).getByTestId("visual-line");
|
|
expect(line).toHaveClass("callout", "warn");
|
|
expect(line).toHaveTextContent("VISUAL still shows 1 work order open: L999001/001.");
|
|
const file = within(box).getByRole("button", { name: "File in the data book" });
|
|
expect(file).toBeDisabled();
|
|
expect(within(box).getByText(why)).toHaveClass("why");
|
|
fireEvent.click(file);
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(0);
|
|
});
|
|
|
|
it("shows the server's refusal when filing is refused after all", async () => {
|
|
const detail = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
|
|
const fetch = mockFetch(
|
|
base(toFile(), { "POST /api/packets/1/transitions": { status: 409, body: { code: "precondition_failed", detail } } }),
|
|
);
|
|
renderApp("/packets/1");
|
|
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
|
|
expect(await screen.findByRole("alert")).toHaveTextContent(detail);
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "file", expect: { status: "complete", dept: "03" } }]);
|
|
});
|
|
});
|
|
|
|
describe("Close and file: filed", () => {
|
|
it("shows the seal intact and opens the filed data book", async () => {
|
|
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(true) } }));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
expect(box).toHaveTextContent(/Filed in the data book .* by Clara Clerk\. The data book has 14 pages\./);
|
|
expect(await within(box).findByText("Seal intact (key k1)")).toHaveClass("pill", "st-ok");
|
|
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
|
|
expect(open).toHaveBeenCalledWith("/_blob/a-db", "_blank", "noopener");
|
|
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/_blob/a-db?download=1");
|
|
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toBeInTheDocument();
|
|
expect(within(box).getByRole("button", { name: "Print…" })).toBeInTheDocument();
|
|
expect(within(box).queryByRole("button", { name: "Reopen (IT)" })).not.toBeInTheDocument();
|
|
expect(within(box).queryByRole("button", { name: "File in the data book" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("says when the seal does not match", async () => {
|
|
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(false) } }));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
const state = await within(box).findByTestId("seal-state");
|
|
expect(state).toHaveClass("callout", "warn");
|
|
expect(state).toHaveTextContent("Seal does not match the record.");
|
|
});
|
|
|
|
it("says when the seal cannot be checked", async () => {
|
|
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(null) } }));
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
expect(await within(box).findByTestId("seal-state")).toHaveTextContent("Seal unknown (key not available)");
|
|
});
|
|
|
|
it("IT reopens with the reason and the signature; the panel keeps the broken seal line", async () => {
|
|
const reopened = toFile({
|
|
version: 10,
|
|
can: ["admin_reopen"],
|
|
seal: seal({ broken_at: "2026-10-17T11:00:00Z", broken_by: ANN, broken_reason: "wrong MTR filed" }),
|
|
seals: 1,
|
|
});
|
|
const fetch = mockFetch(
|
|
base(filed({ can: ["admin_reopen"] }), { "/api/packets/1/seal": { body: sealView(true) }, "POST /api/packets/1/reopen": { body: reopened } }, ["admin"]),
|
|
);
|
|
renderApp("/packets/1");
|
|
const box = await panel();
|
|
fireEvent.click(within(box).getByRole("button", { name: "Reopen (IT)" }));
|
|
const sheet = await screen.findByRole("dialog", { name: "Reopen a filed packet" });
|
|
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong MTR filed" } });
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Reopen" }));
|
|
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Reopen a filed packet")).toBeInTheDocument();
|
|
await sign();
|
|
await waitFor(() => expect(screen.getByTestId("reopened")).toHaveTextContent(/^Reopened by Ann Admin .+: wrong MTR filed\. The earlier seal is kept as broken\.$/));
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/reopen")).toEqual([{ reason: "wrong MTR filed", sign: { factor: "password", secret: "pw-1" } }]);
|
|
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
|
|
});
|
|
});
|