Phase 7: close, file, PDFs and prints
Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { PIN } from "./helpers";
|
||||
import {
|
||||
actor,
|
||||
api,
|
||||
closeAll,
|
||||
packet,
|
||||
packetTab,
|
||||
setPin,
|
||||
sign,
|
||||
signSheet,
|
||||
signWithPassword,
|
||||
signWithPin,
|
||||
statusOf,
|
||||
stepApi,
|
||||
userId,
|
||||
} from "./shophelpers";
|
||||
import type { Crew, QcPacket } from "./qchelpers";
|
||||
import {
|
||||
L,
|
||||
atCompleteApi,
|
||||
cardOf,
|
||||
clickAndCatch,
|
||||
column,
|
||||
expectPdf,
|
||||
fetchOk,
|
||||
makeCloseProject,
|
||||
openClericalHome,
|
||||
openPacket,
|
||||
panel,
|
||||
printCount,
|
||||
prints,
|
||||
signedOffItpApi,
|
||||
type CloseProject,
|
||||
} from "./closehelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
// Phase 7 close and file scenarios on the CI stack, modelled on the old tests/ps tclose and tpr. clerk.clara makes
|
||||
// a project with one unit; the crew (wh.wade, sup.wes, weld.andy) and qc.quinn take two packets, R1 and R2, to
|
||||
// status complete routed to the data book through the API. Then the Clerical home lists both as ready to close,
|
||||
// clara confirms R1 closed in VISUAL, R2 cannot be filed yet, the data book, the CSV and the prints answer as
|
||||
// files, R1 is filed with her PIN and the seal holds, admin.ann reopens it with a reason and her password, and
|
||||
// clara files it again on a second seal. Last, the unit's ITP is signed off through the API and its PDF is filed in
|
||||
// the unit folder. Every UI label used here is in L (closehelpers.ts).
|
||||
|
||||
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
|
||||
const PROJECT = `C7${RUN}`;
|
||||
const T = {
|
||||
r1: `Close R1 spool ${RUN}`,
|
||||
r2: `Close R2 spool ${RUN}`,
|
||||
};
|
||||
|
||||
let proj: CloseProject;
|
||||
const P: Partial<Record<keyof typeof T, QcPacket>> = {};
|
||||
|
||||
function pk(k: keyof typeof T): QcPacket {
|
||||
const p = P[k];
|
||||
if (!p) throw new Error(`packet ${k} was not made`);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function homeShows(page: Page, heading: string, id: number): Promise<void> {
|
||||
await openClericalHome(page, PROJECT);
|
||||
await expect(cardOf(column(page, heading), id)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe.serial("close", () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test("setup: a project with one unit, two packets complete routed to the data book, clara's PIN", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const andy = await actor(browser, "weld.andy");
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const andyId = await userId(andy, "weld.andy");
|
||||
const crew: Crew = { clara, wade, wes, andy, andyId };
|
||||
proj = await makeCloseProject(clara, PROJECT);
|
||||
P.r1 = await atCompleteApi(crew, quinn, proj.unit, T.r1, "Stall 2");
|
||||
P.r2 = await atCompleteApi(crew, quinn, proj.unit, T.r2, "Stall 3");
|
||||
for (const id of [P.r1.id, P.r2.id]) {
|
||||
const p = await packet(clara, id);
|
||||
expect(p.vclosed).toBeFalsy();
|
||||
expect(p.seal).toBeFalsy();
|
||||
}
|
||||
await setPin(clara, PIN);
|
||||
await closeAll(clara, wade, wes, andy, quinn);
|
||||
});
|
||||
|
||||
test("clerical home: both packets are ready to close", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openClericalHome(page, PROJECT);
|
||||
const toClose = column(page, L.colToClose);
|
||||
await expect(cardOf(toClose, pk("r1").id)).toBeVisible();
|
||||
await expect(cardOf(toClose, pk("r2").id)).toBeVisible();
|
||||
await expect(page.locator(`[aria-label="${L.kpiToClose(2)}"]`)).toBeVisible();
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: confirmed closed in VISUAL, it moves to ready for the data book", async ({ browser }) => {
|
||||
const id = pk("r1").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box).toBeVisible();
|
||||
await expect(box.getByText(L.readyToClose)).toBeVisible();
|
||||
// a hand packet has no feed state, so no VISUAL line
|
||||
expect((await packet(clara, id)).vclose_check ?? null).toBeNull();
|
||||
await expect(box.getByTestId(L.visualLine)).toHaveCount(0);
|
||||
const close = box.getByRole("button", { name: L.jobClosed, exact: true });
|
||||
await expect(close).toBeEnabled();
|
||||
await close.click();
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
await expect.poll(async () => Boolean((await packet(clara, id)).vclosed)).toBe(true);
|
||||
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
|
||||
await expect(box.getByRole("button", { name: L.moveBack, exact: true })).toBeVisible();
|
||||
|
||||
await homeShows(page, L.colToBook, id);
|
||||
await expect(cardOf(column(page, L.colToClose), id)).toHaveCount(0);
|
||||
await expect(page.locator(`[aria-label="${L.kpiToClose(1)}"]`)).toBeVisible();
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R2: filing is blocked until the job is closed in VISUAL; move back undoes the close", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("r2").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.readyToClose)).toBeVisible();
|
||||
const file = box.getByRole("button", { name: L.fileIt, exact: true });
|
||||
await expect(file).toBeVisible();
|
||||
await expect(file).toBeDisabled();
|
||||
await expect(box.getByText(L.blocked)).toBeVisible();
|
||||
// the API refuses it the same way (403 precondition)
|
||||
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
|
||||
expect(await statusOf(clara, id)).toBe("complete");
|
||||
|
||||
// close, then move back: it is ready to close again (the old vReopen)
|
||||
await box.getByRole("button", { name: L.jobClosed, exact: true }).click();
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
await box.getByRole("button", { name: L.moveBack, exact: true }).click();
|
||||
await expect(box.getByText(L.readyToClose)).toBeVisible();
|
||||
await expect.poll(async () => Boolean((await packet(clara, id)).vclosed)).toBe(false);
|
||||
await homeShows(page, L.colToClose, id);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: the data book and the traceability CSV answer as files", async ({ browser }) => {
|
||||
const id = pk("r1").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
await expect(box.getByRole("button", { name: L.download, exact: true }).or(
|
||||
box.getByRole("link", { name: L.download, exact: true }),
|
||||
).first()).toBeVisible();
|
||||
|
||||
// Data book (PDF): the browser asks for the preview data book; the same URL answers a PDF
|
||||
const bookBtn = box
|
||||
.getByRole("button", { name: L.dataBookPdf, exact: true })
|
||||
.or(box.getByRole("link", { name: L.dataBookPdf, exact: true }))
|
||||
.first();
|
||||
const bookReq = await clickAndCatch(clara.ctx, page, bookBtn, `/api/packets/${id}/pdf/databook`);
|
||||
await expectPdf(clara, bookReq.url());
|
||||
await expect(page).toHaveURL(new RegExp(`/packets/${id}`));
|
||||
|
||||
// Traceability (CSV): a link to the CSV; text/csv with the record's first line
|
||||
const csvLink = box
|
||||
.getByRole("link", { name: L.csv, exact: true })
|
||||
.or(box.getByRole("button", { name: L.csv, exact: true }))
|
||||
.first();
|
||||
const csvReq = await clickAndCatch(clara.ctx, page, csvLink, `/api/packets/${id}/export/traceability.csv`);
|
||||
const r = await fetchOk(clara, csvReq.url());
|
||||
expect(r.headers()["content-type"]).toContain("text/csv");
|
||||
const text = await r.text();
|
||||
expect(text.split("\r\n")[0]).toBe("PESCO weld & heat traceability record");
|
||||
expect(text).toContain("BOM item");
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: the print menu lists the kinds; a traveler print is logged and shows on the History tab", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("r1").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
|
||||
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
|
||||
let sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
|
||||
await expect(sheet).toBeVisible();
|
||||
for (const row of L.printRows) await expect(sheet.getByRole("button", { name: row, exact: true })).toBeVisible();
|
||||
// a hand packet with no change orders has no void pages
|
||||
await expect(sheet.getByRole("button", { name: L.voidRow, exact: true })).toHaveCount(0);
|
||||
await expect(sheet.getByText(L.printFooter)).toBeVisible();
|
||||
await sheet.getByRole("button", { name: L.cancel, exact: true }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
|
||||
// a row opens the print in a new tab and closes the sheet; the server logs it
|
||||
const before = await printCount(clara, id, "traveler");
|
||||
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
|
||||
sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
|
||||
await expect(sheet).toBeVisible();
|
||||
await clickAndCatch(
|
||||
clara.ctx,
|
||||
page,
|
||||
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
|
||||
`/api/packets/${id}/print/traveler`,
|
||||
);
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 1);
|
||||
|
||||
// the same print through the API: a PDF, and one more log row
|
||||
await expectPdf(clara, `/api/packets/${id}/print/traveler`);
|
||||
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 2);
|
||||
const rows = await prints(clara, id);
|
||||
expect(rows[0].kind).toBe("traveler");
|
||||
expect(rows[0].what).toBe("Job traveler");
|
||||
expect(Number(rows[0].pages)).toBeGreaterThan(0);
|
||||
|
||||
// the Work orders tab has the traveler print and the traveler PDF
|
||||
await packetTab(page, L.tabWorkOrders).click();
|
||||
await expect(page.getByRole("button", { name: L.printTraveler, exact: true })).toBeVisible();
|
||||
const trav = page.getByRole("link", { name: L.travelerPdf, exact: true });
|
||||
await expect(trav).toBeVisible();
|
||||
await expect(trav).toHaveAttribute("href", new RegExp(`/api/packets/${id}/pdf/traveler$`));
|
||||
|
||||
// the History tab lists the printed copies
|
||||
await packetTab(page, L.tabHistory).click();
|
||||
const hist = page.getByRole("region", { name: L.printsSection });
|
||||
await expect(hist).toBeVisible();
|
||||
await expect(hist.getByText(/Job traveler/).first()).toBeVisible();
|
||||
await expect(hist.getByText(L.nothingPrinted)).toHaveCount(0);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: filed in the data book with the PIN; the seal holds and the filed data book downloads", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("r1").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
const file = box.getByRole("button", { name: L.fileIt, exact: true });
|
||||
await expect(file).toBeEnabled();
|
||||
await file.click();
|
||||
await signWithPin(page, PIN);
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect.poll(() => statusOf(clara, id)).toBe("filed");
|
||||
await expect(box.getByText(L.filed).first()).toBeVisible();
|
||||
await expect(box.getByText(L.sealIntact).first()).toBeVisible();
|
||||
await expect(box.getByText(L.sealBroken)).toHaveCount(0);
|
||||
|
||||
const p = await packet(clara, id);
|
||||
expect(p.seals).toBe(1);
|
||||
expect(p.databook_at).toBeTruthy();
|
||||
expect(Number(p.seal.databook.pages)).toBeGreaterThan(0);
|
||||
expect(p.seal.broken_at ?? null).toBeNull();
|
||||
const seal = await api(clara, "get", `/api/packets/${id}/seal`);
|
||||
expect(seal.valid).toBe(true);
|
||||
const dl = await fetchOk(clara, String(p.seal.databook.download_url));
|
||||
expect(dl.headers()["content-type"]).toContain("application/pdf");
|
||||
expect(dl.headers()["content-disposition"] ?? "").toMatch(/^attachment/);
|
||||
expect((await dl.body()).subarray(0, 4).toString("latin1")).toBe("%PDF");
|
||||
// the export endpoint hands back the stored copy once filed
|
||||
await expectPdf(clara, `/api/packets/${id}/pdf/databook`);
|
||||
|
||||
// the home lists it as filed
|
||||
await openClericalHome(page, PROJECT);
|
||||
const card = cardOf(column(page, L.colFiled), id);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText(L.filedPill);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: admin.ann reopens with a reason and her password; clara files it again on a second seal", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("r1").id;
|
||||
const ann = await actor(browser, "admin.ann");
|
||||
const page = ann.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.filed).first()).toBeVisible();
|
||||
await box.getByRole("button", { name: L.reopenIt, exact: true }).click();
|
||||
const reason = page.getByRole("dialog", { name: L.reopenSheet, exact: true });
|
||||
await expect(reason).toBeVisible();
|
||||
const field = reason.getByRole("textbox").first();
|
||||
await field.fill("Wrong heat number filed on item 2");
|
||||
await field.press("Enter");
|
||||
await signWithPassword(page, "admin.ann");
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect.poll(() => statusOf(ann, id)).toBe("complete");
|
||||
await expect(box.getByText(L.reopenedLine).first()).toBeVisible();
|
||||
await expect(box.getByText(L.reopenedLine).first()).toContainText("Wrong heat number filed on item 2");
|
||||
|
||||
let seal = await api(ann, "get", `/api/packets/${id}/seal`);
|
||||
expect((seal.seals as Json[]).length).toBe(1);
|
||||
expect((seal.seals as Json[])[0].broken_reason).toBe("Wrong heat number filed on item 2");
|
||||
const reopened = await packet(ann, id);
|
||||
expect(reopened.seal.broken_at).toBeTruthy();
|
||||
expect(reopened.seal.databook).toBeTruthy();
|
||||
|
||||
// clara files it again through the API (the UI path is covered above)
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
if (!(await packet(clara, id)).vclosed) await api(clara, "post", `/api/packets/${id}/vclosed`, { closed: true });
|
||||
const out = await stepApi(clara, id, "file", { sign: sign("clerk.clara") });
|
||||
expect(out.status).toBe("filed");
|
||||
expect(out.seals).toBe(2);
|
||||
seal = await api(clara, "get", `/api/packets/${id}/seal`);
|
||||
expect((seal.seals as Json[]).length).toBe(2);
|
||||
expect(seal.valid).toBe(true);
|
||||
await openPacket(clara.page, id);
|
||||
await expect(panel(clara.page).getByText(L.sealIntact).first()).toBeVisible();
|
||||
await closeAll(ann, clara);
|
||||
});
|
||||
|
||||
test("ITP: signed off through the API, its PDF is filed in the unit folder and linked on the ITP page", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const unitId = Number(proj.unit.id);
|
||||
const itp = await signedOffItpApi(quinn, unitId);
|
||||
const no = String(itp.no);
|
||||
expect(itp.signoff.asset_id).toBeTruthy();
|
||||
|
||||
// the ITP PDF, built now
|
||||
await expectPdf(quinn, `/api/itps/${itp.id}/pdf`);
|
||||
// the signed copy filed at sign-off
|
||||
await expectPdf(quinn, `/_blob/${itp.signoff.asset_id}`);
|
||||
|
||||
// the unit folder lists the automatic ITP document
|
||||
const folder = await api(quinn, "get", `/api/qc/folders/units/${unitId}`);
|
||||
const titles = ((folder.docs ?? []) as Json[]).map((d) => String(d.title));
|
||||
expect(titles).toContain(L.signedOffDoc(no));
|
||||
await quinn.page.goto(`/qc/units/${unitId}`);
|
||||
await expect(quinn.page.getByText(L.signedOffDoc(no)).first()).toBeVisible();
|
||||
|
||||
// the ITP page has the PDF button and the signed PDF link
|
||||
await quinn.page.goto(`/qc/units/${unitId}/itp`);
|
||||
await expect(quinn.page.getByRole("heading", { level: 1 })).toHaveText(no);
|
||||
const pdfBtn = quinn.page
|
||||
.getByRole("button", { name: L.itpPdf, exact: true })
|
||||
.or(quinn.page.getByRole("link", { name: L.itpPdf, exact: true }))
|
||||
.first();
|
||||
const req = await clickAndCatch(quinn.ctx, quinn.page, pdfBtn, `/api/itps/${itp.id}/pdf`);
|
||||
await expectPdf(quinn, req.url());
|
||||
const signed = quinn.page.getByRole("link", { name: L.signedPdf, exact: true });
|
||||
await expect(signed).toBeVisible();
|
||||
await expect(signed).toHaveAttribute("href", new RegExp(`/_blob/${itp.signoff.asset_id}`));
|
||||
await closeAll(quinn);
|
||||
});
|
||||
|
||||
test("api: print guards and a signed-out read", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const id = pk("r2").id;
|
||||
// R2 has no void pages
|
||||
const v = await clara.page.request.get(`/api/packets/${id}/print/void`);
|
||||
expect(v.status()).toBe(404);
|
||||
expect(((await v.json()) as Json).code).toBe("nothing_to_print");
|
||||
// a document that is not one of the packet's is refused
|
||||
const d = await clara.page.request.get(`/api/packets/${id}/print/doc?asset=00000000-0000-0000-0000-000000000000`);
|
||||
expect(d.status()).toBe(404);
|
||||
// nobody signed in: 401
|
||||
const anon = await browser.newContext();
|
||||
const r = await anon.request.get(new URL(`/api/packets/${id}/pdf/traveler`, clara.page.url()).toString());
|
||||
expect(r.status()).toBe(401);
|
||||
await anon.close();
|
||||
await closeAll(clara);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { expect, type APIResponse, type BrowserContext, type Locator, type Page } from "@playwright/test";
|
||||
import { api, packet, sign, stepApi, type Actor } from "./shophelpers";
|
||||
import { atQcApi, type Crew, type QcPacket } from "./qchelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
// Helpers for the phase 7 close and file scenarios: a project with one unit, packets taken to complete routed to
|
||||
// the data book through the API, the ITP signed off through the API, and the UI labels the close spec depends on
|
||||
// (L). The labels are the "Labels" section of the phase 7 design contract and the components as agent B built
|
||||
// them. The main session adjusts them here in one place when the components differ.
|
||||
|
||||
export const L = {
|
||||
// the Clerical home (screens/clerical/ClericalHome.tsx, boards.ts)
|
||||
clericalHead: "Clerical",
|
||||
findJob: "Find a job",
|
||||
colToClose: "Ready to close",
|
||||
colToBook: "Ready for data book",
|
||||
colFiled: "Filed",
|
||||
kpiToClose: (n: number) => `Ready to close: ${n}`, // the kpi tile's aria-label
|
||||
filedPill: "Filed in data book", // the status label of filed; the card's aria-label ends with it
|
||||
// the close and file panel
|
||||
panel: "Close and file", // section aria-label (role region)
|
||||
readyToClose: "Back from the shop, ready to close.",
|
||||
readyForBook: "Ready for the data book.",
|
||||
filed: "Filed in the data book",
|
||||
sealIntact: "Seal intact",
|
||||
sealBroken: "Seal does not match the record",
|
||||
sealUnknown: "Seal unknown",
|
||||
reopenedLine: /^Reopened by .+: .+\. The earlier seal is kept as broken\./,
|
||||
blocked: "Close the job in VISUAL first.",
|
||||
visualLine: "visual-line", // testid of the VISUAL observed line
|
||||
sealState: "seal-state", // testid of the seal line
|
||||
reopened: "reopened", // testid of the reopen line
|
||||
// panel buttons
|
||||
jobClosed: "Job closed in VISUAL",
|
||||
moveBack: "Not closed in VISUAL, move back",
|
||||
fileIt: "File in the data book",
|
||||
fileAnyway: "File anyway",
|
||||
dataBookPdf: "Data book (PDF)",
|
||||
download: "Download",
|
||||
csv: "Traceability (CSV)",
|
||||
printOpen: "Print…", // U+2026, as the contract writes it
|
||||
reopenIt: "Reopen (IT)",
|
||||
// sheets
|
||||
printSheet: "Print",
|
||||
openWosSheet: "File with open work orders?",
|
||||
reopenSheet: "Reopen a filed packet",
|
||||
// print rows (each a button whose name starts with the row title)
|
||||
printRows: ["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Data book"],
|
||||
voidRow: "Superseded drawings (VOID)",
|
||||
printFooter: /Every printed page is stamped with who printed it and when/,
|
||||
// other print entry points
|
||||
printTraveler: "Print traveler",
|
||||
travelerPdf: "Traveler (PDF)",
|
||||
printSheetBtn: "Print this sheet",
|
||||
// the packet tabs
|
||||
tabWorkOrders: /^Work orders/,
|
||||
tabHistory: /^History/,
|
||||
// the History tab
|
||||
printsSection: "Printed copies and exports", // region
|
||||
nothingPrinted: "Nothing printed yet.",
|
||||
// the ITP page
|
||||
itpPdf: "ITP (PDF)",
|
||||
signedPdf: "Signed PDF",
|
||||
signedOffDoc: (no: string) => `${no} — signed off`, // the automatic Q.C. folder document, em dash is the server's text
|
||||
// shared
|
||||
cancel: "Cancel",
|
||||
statusPill: "status-pill", // testid (phase 5)
|
||||
} as const;
|
||||
|
||||
/** A regex that matches a text literally from its start. */
|
||||
export function starts(s: string): RegExp {
|
||||
return new RegExp(`^${s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
||||
}
|
||||
|
||||
export interface CloseProject {
|
||||
id: number;
|
||||
number: string;
|
||||
unit: Json;
|
||||
}
|
||||
|
||||
/** As clerk.clara: a project with a customer and a PO, and one unit. */
|
||||
export async function makeCloseProject(clara: Actor, number: string, name = "Close and file skid"): Promise<CloseProject> {
|
||||
const project = await api(
|
||||
clara,
|
||||
"post",
|
||||
"/api/projects",
|
||||
{ number, name, customer: "ACME", po: "7731" },
|
||||
{ status: 201 },
|
||||
);
|
||||
const units = (await api(clara, "post", `/api/projects/${project.id}/units`, {}, { status: 201 })) as unknown as Json[];
|
||||
expect(units.length).toBe(1);
|
||||
return { id: Number(project.id), number: String(project.number), unit: units[0] as Json };
|
||||
}
|
||||
|
||||
/** The ten Q.C. check keys (tests/server/qchelpers.py QC_KEYS). */
|
||||
export const QC_KEYS = [
|
||||
"db_heats",
|
||||
"db_mtr",
|
||||
"db_weldmap",
|
||||
"db_wps",
|
||||
"db_ck",
|
||||
"ph_dim",
|
||||
"ph_heat",
|
||||
"ph_stamp",
|
||||
"ph_clean",
|
||||
"ph_qty",
|
||||
] as const;
|
||||
|
||||
/** As qc.quinn through the API: inspector and stamp, every check, every live weld accepted. */
|
||||
export async function finishInspectionApi(quinn: Actor, id: number): Promise<Json> {
|
||||
let p = await api(quinn, "patch", `/api/packets/${id}/qc`, { set: { inspector: "Quinn QC", stamp: "Q1" }, base: {} });
|
||||
for (const k of QC_KEYS) p = await api(quinn, "put", `/api/packets/${id}/qc/checks/${k}`, { done: true });
|
||||
const welds = (p.welds as Json[]).filter((w) => !w.removed_at && !w.removed).map((w) => Number(w.weld_no));
|
||||
for (const no of welds) p = await api(quinn, "put", `/api/packets/${id}/qc/welds/${no}`, { result: "accept" });
|
||||
expect(p.qc.missing, JSON.stringify(p.qc.missing)).toEqual([]);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A packet taken through the shop and Q.C. to status complete, routed to the data book, all through the API. */
|
||||
export async function atCompleteApi(crew: Crew, quinn: Actor, unit: Json, title: string, stall: string): Promise<QcPacket> {
|
||||
const p = await atQcApi(crew, unit, title, stall, "Bay 7");
|
||||
await finishInspectionApi(quinn, p.id);
|
||||
const out = await stepApi(quinn, p.id, "qc_accept", { routed_to: "databook", sign: sign("qc.quinn") });
|
||||
expect(out.status).toBe("complete");
|
||||
expect(out.routed_to).toBe("databook");
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A board column of the Clerical home by its heading (the heading ends with the card count). */
|
||||
export function column(page: Page, heading: string): Locator {
|
||||
return page.getByRole("region", { name: new RegExp(`^${heading}\\s*\\d*$`) }).first();
|
||||
}
|
||||
|
||||
/** A packet card by its link. */
|
||||
export function cardOf(scope: Locator, id: number): Locator {
|
||||
return scope.locator(`a[href="/packets/${id}"]`).first();
|
||||
}
|
||||
|
||||
/** The Clerical home, narrowed to one project through the find box so the numbers count only this run. */
|
||||
export async function openClericalHome(page: Page, project: string): Promise<void> {
|
||||
await page.goto("/clerical");
|
||||
await expect(page.getByRole("heading", { level: 1, name: L.clericalHead })).toBeVisible();
|
||||
await page.getByRole("searchbox", { name: L.findJob }).fill(project);
|
||||
}
|
||||
|
||||
/** The close and file panel. */
|
||||
export function panel(page: Page): Locator {
|
||||
return page.getByRole("region", { name: L.panel, exact: true });
|
||||
}
|
||||
|
||||
/** Open a packet page and wait for its heading. */
|
||||
export async function openPacket(page: Page, id: number): Promise<void> {
|
||||
await page.goto(`/packets/${id}`);
|
||||
await expect(page.getByRole("heading").first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** A packet's current state on the server, for expect.poll. */
|
||||
export async function field(a: Actor, id: number, pick: (p: Json) => unknown): Promise<unknown> {
|
||||
return pick(await packet(a, id));
|
||||
}
|
||||
|
||||
/** What a click asked the browser to open: the URL, plus the server's answer fetched through the page's session. */
|
||||
export interface Opened {
|
||||
url: () => string;
|
||||
response: APIResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click `target` and catch the URL it opens, then fetch it through the page's own session so the server sees the
|
||||
* request and logs it. A link is read from its href (a download link would start a download, not a navigation).
|
||||
* A button that calls window.open is caught through a stub, because a noopener tab in headless Chromium does not
|
||||
* report its request on the context. The URL must contain `part`.
|
||||
*/
|
||||
export async function clickAndCatch(ctx: BrowserContext, page: Page, target: Locator, part: string): Promise<Opened> {
|
||||
await expect(target).toBeVisible();
|
||||
let url: string | null = null;
|
||||
const href = await target.getAttribute("href");
|
||||
if (href) {
|
||||
url = new URL(href, page.url()).toString();
|
||||
} else {
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __opened: string[]; open: (u?: string | URL) => Window | null };
|
||||
w.__opened = [];
|
||||
w.open = (u?: string | URL) => {
|
||||
w.__opened.push(String(u ?? ""));
|
||||
return null;
|
||||
};
|
||||
});
|
||||
await target.click();
|
||||
await expect
|
||||
.poll(async () => (await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).length, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
const opened = await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);
|
||||
url = new URL(opened[0] ?? "", page.url()).toString();
|
||||
}
|
||||
expect(url, `opened ${url}`).toContain(part);
|
||||
const response = await page.request.get(url);
|
||||
for (const other of ctx.pages()) if (other !== page) await other.close();
|
||||
const fixed = url;
|
||||
return { url: () => fixed, response };
|
||||
}
|
||||
|
||||
/** GET a URL through the actor's API context and check the status. */
|
||||
export async function fetchOk(a: Actor, url: string): Promise<APIResponse> {
|
||||
const r = await a.page.request.get(url);
|
||||
expect(r.status(), `GET ${url}: ${r.status()}`).toBe(200);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** GET a PDF through the actor's API context: 200, application/pdf, the body starts with %PDF. */
|
||||
export async function expectPdf(a: Actor, url: string): Promise<Buffer> {
|
||||
const r = await fetchOk(a, url);
|
||||
expect(r.headers()["content-type"]).toContain("application/pdf");
|
||||
const body = await r.body();
|
||||
expect(body.subarray(0, 4).toString("latin1")).toBe("%PDF");
|
||||
return body;
|
||||
}
|
||||
|
||||
/** The packet's print log rows, newest first (GET /api/packets/{id}/prints). */
|
||||
export async function prints(a: Actor, id: number): Promise<Json[]> {
|
||||
const out = await api(a, "get", `/api/packets/${id}/prints`);
|
||||
return (out.prints ?? []) as Json[];
|
||||
}
|
||||
|
||||
/** How many print log rows of `kind` the packet has. */
|
||||
export async function printCount(a: Actor, id: number, kind: string): Promise<number> {
|
||||
return (await prints(a, id)).filter((r) => r.kind === kind).length;
|
||||
}
|
||||
|
||||
/** The ITP of a unit, or null. */
|
||||
export async function itpOf(a: Actor, unitId: number): Promise<Json | null> {
|
||||
const out = await api(a, "get", `/api/units/${unitId}/itp`);
|
||||
return out.itp === null ? null : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* As qc.quinn through the API: start the unit's ITP, approve every open line, sign every block with the
|
||||
* password, sign off. Returns the signed-off ITP.
|
||||
*/
|
||||
export async function signedOffItpApi(quinn: Actor, unitId: number): Promise<Json> {
|
||||
let itp = (await itpOf(quinn, unitId)) ?? (await api(quinn, "post", `/api/units/${unitId}/itp`, {}));
|
||||
for (const line of Object.values(itp.lines as Record<string, Json>)) {
|
||||
if (line.result === null || line.result === undefined)
|
||||
itp = await api(quinn, "post", `/api/itps/${itp.id}/lines/${line.key}/sign`, {});
|
||||
}
|
||||
for (const b of itp.blocks as Json[]) {
|
||||
if (b.signed) continue;
|
||||
itp = await api(quinn, "post", `/api/itps/${itp.id}/blocks/${b.key}/sign`, { sign: sign("qc.quinn") });
|
||||
}
|
||||
itp = await api(quinn, "post", `/api/itps/${itp.id}/signoff`, { sign: sign("qc.quinn") });
|
||||
expect(itp.status).toBe("complete");
|
||||
return itp;
|
||||
}
|
||||
Reference in New Issue
Block a user