- 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]>
423 lines
18 KiB
TypeScript
423 lines
18 KiB
TypeScript
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,
|
|
clickAndPrint,
|
|
column,
|
|
expectPdf,
|
|
fetchOk,
|
|
itpOf,
|
|
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 closes the sheet and prints on the same page (C4): the stamped PDF is fetched and the print dialog
|
|
// opens, no new tab; 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 clickAndPrint(
|
|
page,
|
|
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
|
|
`/api/packets/${id}/print/traveler`,
|
|
);
|
|
await expect(sheet).toBeHidden();
|
|
await expect(page).toHaveURL(new RegExp(`/packets/${id}$`));
|
|
expect(clara.ctx.pages().length).toBe(1);
|
|
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("C3: an open ITP on the unit blocks filing until Q.C. signs it off", async ({ browser }) => {
|
|
const id = pk("r1").id;
|
|
const quinn = await actor(browser, "qc.quinn");
|
|
const unitId = Number(proj.unit.id);
|
|
const started = await api(quinn, "post", `/api/units/${unitId}/itp`, {});
|
|
expect(started.status).not.toBe("complete");
|
|
|
|
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();
|
|
const file = box.getByRole("button", { name: L.fileIt, exact: true });
|
|
await expect(file).toBeDisabled();
|
|
await expect(box.getByText(L.itpOpen)).toBeVisible();
|
|
expect((await packet(clara, id)).blocked.file).toContain(L.itpOpen);
|
|
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
|
|
expect(await statusOf(clara, id)).toBe("complete");
|
|
|
|
// signed off: File is enabled again
|
|
await signedOffItpApi(quinn, unitId);
|
|
await page.reload();
|
|
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
|
|
await expect(box.getByText(L.itpOpen)).toHaveCount(0);
|
|
await closeAll(quinn, 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, 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);
|
|
// signed off in the C3 scenario above
|
|
const itp = (await itpOf(quinn, unitId)) as Json;
|
|
expect(itp.status).toBe("complete");
|
|
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());
|
|
// Print goes to the print dialog on the same page (C4)
|
|
await clickAndPrint(
|
|
quinn.page,
|
|
quinn.page.getByRole("button", { name: "Print", exact: true }),
|
|
`/api/itps/${itp.id}/print`,
|
|
);
|
|
await expect(quinn.page).toHaveURL(new RegExp(`/qc/units/${unitId}/itp$`));
|
|
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);
|
|
});
|
|
});
|