Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s

The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+541
View File
@@ -0,0 +1,541 @@
import { expect, test, type Page } from "@playwright/test";
import { PIN } from "./helpers";
import {
L as SL,
actor,
api,
closeAll,
packet,
packetTab,
pullAndRelease,
setPin,
sign,
signSheet,
signWithPassword,
signWithPin,
statusOf,
stepApi,
userId,
type Actor,
} from "./shophelpers";
import {
L,
NCR,
TEMPLATE_ITEMS,
atQcApi,
blockSign,
cardOf,
column,
enabledApprove,
itpBlock,
itpRow,
itpSection,
logActions,
makeQcDraft,
makeQcProject,
part,
pdfFile,
qcBar,
qcChecks,
textFile,
type Crew,
type QcPacket,
type QcProject,
} from "./qchelpers";
import type { Json } from "./whhelpers";
// Phase 6 Q.C. scenarios on the CI stack, modelled on the old tests/ps titp, tqcf and tpdocs, plus the accept and
// reject flows of the old qcTab. clerk.clara makes a project with two units; the crew (wh.wade, sup.wes,
// weld.andy) takes two packets of unit 1 to Q.C. through the API. Accept: qc.quinn inspects on the Q.C. tab and
// accepts to the data book with her PIN. Reject: one weld rejected, an NCR number only, her password; sup.wes sees
// the callout and sends it to Q.C. again. tqcf: project and unit folders, a new revision, a refused file, wh.wade
// has no Q.C. screen. tpdocs: a packet document added and taken out. titp: the digital ITP on unit 2 with the hold
// callout on a 03 and an 11 packet, every line and block signed, sign-off, and an admin reopen.
// Every UI label used here is in L (qchelpers.ts).
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
const PROJECT = `Q6${RUN}`;
const T = {
accept: `QC accept spool ${RUN}`,
reject: `QC reject spool ${RUN}`,
wp: `ITP weld spool ${RUN}`,
fa: `ITP final spool ${RUN}`,
};
let proj: QcProject;
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 open(page: Page, id: number, tabName?: RegExp): Promise<void> {
await page.goto(`/packets/${id}`);
await expect(page.getByRole("heading").first()).toBeVisible();
if (tabName) await packetTab(page, tabName).click();
}
async function openQcHome(page: Page): Promise<void> {
await page.goto("/qc");
await expect(page.getByRole("heading", { name: L.homeHead })).toBeVisible();
}
test.describe.serial("qc", () => {
test.setTimeout(300_000);
test("setup: a project with two units, two packets of unit 1 at Q.C., qc.quinn'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 makeQcProject(clara, PROJECT);
P.accept = await atQcApi(crew, proj.units[0], T.accept, "Stall 2", "Bay 2");
P.reject = await atQcApi(crew, proj.units[0], T.reject, "Stall 3", "Bay 2");
// unit 2: a 03 packet released to the shop, and a draft released straight to 11 Final Assembly - North
const wp = await makeQcDraft(clara, proj.units[1], T.wp);
await pullAndRelease(wade, wp.id);
P.wp = wp;
const fa = await makeQcDraft(clara, proj.units[1], T.fa, { dept: "11", stay: true });
const rel = await stepApi(clara, fa.id, "release_to_shop", { dept: "11" });
expect(rel.status).toBe("ready");
P.fa = fa;
await setPin(quinn, PIN);
await closeAll(clara, wade, wes, andy, quinn);
});
test("accept: inspect on the Q.C. tab and accept to the data book with the PIN", async ({ browser }) => {
const id = pk("accept").id;
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
// the Q.C. screen lists it as waiting, with the spool location
await openQcHome(page);
await expect(page.getByText(L.homeEyebrow).first()).toBeVisible();
for (const k of [L.kpiWaiting, L.kpiAccepted, L.kpiRejected])
await expect(page.getByText(k).first()).toBeVisible();
const ready = column(page, L.colReady);
const card = cardOf(ready, id);
await expect(card).toBeVisible();
await expect(card).toContainText(L.spoolAt("Bay 2"));
await card.click();
await expect(page).toHaveURL(new RegExp(`/packets/${id}`));
// the Q.C. bar lists what is missing and Accept is disabled
const bar = qcBar(page);
await expect(bar).toBeVisible();
const missing = bar.getByTestId(L.qcMissing);
await expect(missing).toContainText(L.stillNeeded);
await expect(missing).toContainText("10 inspection items");
await expect(missing).toContainText("2 welds without a visual result");
await expect(missing).toContainText("inspector name");
const acceptBtn = bar.getByRole("button", { name: L.accept, exact: true });
await expect(acceptBtn).toBeDisabled();
// the Q.C. tab: inspector and stamp first, so the ticks carry the stamp
await packetTab(page, L.tabQc).click();
await page.getByLabel(L.inspector, { exact: true }).fill("Quinn QC");
await page.getByLabel(L.stamp, { exact: true }).fill("Q1");
await page.getByLabel(L.stamp, { exact: true }).blur();
await expect.poll(async () => (await packet(quinn, id)).qc.stamp).toBe("Q1");
const boxes = qcChecks(page);
await expect(boxes).toHaveCount(10);
for (let i = 0; i < 10; i++) {
await boxes.nth(i).check();
await expect(boxes.nth(i)).toBeChecked();
}
await expect(page.getByText(L.stampTick).first()).toBeVisible();
// both welds accepted
const vt = part(page, L.vtSection);
for (const no of [1, 2]) {
const btn = vt.getByRole("row", { name: L.weldRow(no) }).getByRole("button", { name: L.vtAccept, exact: true });
await btn.click();
await expect(btn).toHaveAttribute("aria-pressed", "true");
}
// NDE: RT, Acceptable
await page.getByLabel(L.ndeMethod, { exact: true }).selectOption("RT");
await page.getByLabel(L.ndeResult, { exact: true }).selectOption("Acceptable");
await expect.poll(async () => (await packet(quinn, id)).qc.nde_result).toBe("Acceptable");
// Accept is enabled; route to the data book; sign with the PIN
await expect(missing).toBeHidden();
await expect(acceptBtn).toBeEnabled();
await bar.getByLabel(L.route, { exact: true }).selectOption({ label: L.routeDataBook });
await acceptBtn.click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect.poll(() => statusOf(quinn, id)).toBe("complete");
await expect(page.getByTestId(L.statusPill)).toContainText(L.statusRouted);
const after = await packet(quinn, id);
expect(after.routed_to).toBe("databook");
// History has the accept text
await packetTab(page, L.tabHistory).click();
await expect(page.getByText(`Q.C. accepted → ${L.routeDataBook}`).first()).toBeVisible();
expect(await logActions(quinn, id)).toContain(`Q.C. accepted → ${L.routeDataBook}`);
// the Q.C. screen's Accepted column has it
await openQcHome(page);
await expect(cardOf(column(page, L.colAccepted), id)).toBeVisible();
await closeAll(quinn);
});
test("reject: one weld rejected, an NCR number only, the password; the supervisor sends it again", async ({
browser,
}) => {
const id = pk("reject").id;
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
await open(page, id, L.tabQc);
// weld 1 rejected with a note
const w1 = part(page, L.vtSection).getByRole("row", { name: L.weldRow(1) });
await w1.getByRole("button", { name: L.vtReject, exact: true }).click();
const defect = w1.getByLabel(L.weldNote(1));
await expect(defect).toHaveAttribute("placeholder", L.defectNote);
await defect.fill("undercut");
await defect.blur();
await expect
.poll(async () => {
const exam = (await packet(quinn, id)).qc.weld_exam as Json;
return Object.values(exam).map((e) => `${(e as Json).result}:${(e as Json).note ?? ""}`);
})
.toContain("reject:undercut");
// the reject sheet: enabled on an NCR number alone
const bar = qcBar(page);
await bar.getByRole("button", { name: L.reject, exact: true }).click();
const sheet = page.getByRole("dialog", { name: L.rejectSheet, exact: true });
await expect(sheet).toBeVisible();
const submitReject = sheet.getByRole("button", { name: L.rejectSubmit });
await expect(submitReject).toBeDisabled();
await sheet.getByLabel(L.ncrNumber, { exact: true }).fill(NCR);
await expect(submitReject).toBeEnabled();
await submitReject.click();
await signWithPassword(page, "qc.quinn");
await expect.poll(() => statusOf(quinn, id)).toBe("review");
await expect(page.getByTestId(L.statusPill)).toContainText(L.statusReview);
const after = await packet(quinn, id);
expect(after.qc).toBeNull();
expect(after.qc_last.result).toBe("rejected");
expect(after.qc_last.ncr).toBe(NCR);
// the Q.C. screen's rejected column has it
await openQcHome(page);
await expect(cardOf(column(page, L.colRejected), id)).toBeVisible();
// sup.wes sees the callout with the NCR link and the rejected weld
const wes = await actor(browser, "sup.wes");
await open(wes.page, id);
const callout = wes.page.getByRole("note").filter({ hasText: L.qcRejected }).first();
await expect(callout).toBeVisible();
const link = callout.getByRole("link", { name: new RegExp(NCR) });
await expect(link).toHaveAttribute("href", new RegExp(`/ncrs/${NCR}$`));
await expect(link).toHaveAttribute("rel", /noopener/);
await expect(callout.getByText(L.rejectedWelds("W1"), { exact: false })).toBeVisible();
// the review box sends it to Q.C. again (password: sup.wes has no PIN)
await wes.page.getByLabel(SL.bookReady).check();
await wes.page.getByLabel(SL.spoolReady).check();
await wes.page.getByLabel(SL.spoolLocation).fill("Bay 4");
await wes.page.getByRole("button", { name: SL.sendToQc, exact: true }).click();
await signWithPassword(wes.page, "sup.wes");
await expect.poll(() => statusOf(wes, id)).toBe("qc");
// a fresh empty inspection; the first one is under Earlier inspections
await open(page, id, L.tabQc);
const boxes = qcChecks(page);
await expect(boxes).toHaveCount(10);
for (let i = 0; i < 10; i++) await expect(boxes.nth(i)).not.toBeChecked();
const earlier = part(page, L.earlier);
await expect(earlier).toBeVisible();
await expect(earlier).toContainText(L.rejectedPill);
await expect(earlier).toContainText(NCR);
await closeAll(quinn, wes);
});
test("tqcf: project and unit folders, a new revision, a refused file, no Q.C. screen for wh.wade", async ({
browser,
}) => {
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
const unit1 = proj.units[0];
// the home's folder card
await openQcHome(page);
const folderCard = part(page, L.folders).locator(L.pcard).filter({ hasText: PROJECT }).first();
await expect(folderCard).toBeVisible();
await expect(folderCard).toContainText(L.folderUnits(2));
await folderCard.click();
await expect(page).toHaveURL(new RegExp(`/qc/projects/${proj.id}`));
await expect(page.getByText(L.allFolders).first()).toBeVisible();
// a project WPS
await page.getByRole("button", { name: L.addQcDoc }).first().click();
let sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
await expect(sheet).toBeVisible();
await sheet.getByLabel(L.qdType, { exact: true }).selectOption("WPS / PQR");
await sheet.getByLabel(L.qdTitle, { exact: true }).fill("WPS-101 GTAW/SMAW");
await sheet.getByLabel(L.qdRev, { exact: true }).fill("2");
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("wps.pdf"));
await sheet.getByRole("button", { name: L.addToFolder }).click();
await expect(sheet).toBeHidden();
const projectDocs = part(page, L.projectDocs);
await expect(projectDocs.getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
await expect(projectDocs.getByText("Rev 2").first()).toBeVisible();
// unit 1: no ITP yet; add an ITP document
await page.locator(L.qcUnit).filter({ hasText: String(unit1.number) }).first().click();
await expect(page).toHaveURL(new RegExp(`/qc/units/${unit1.id}`));
await expect(page.getByText(L.noItpCallout)).toBeVisible();
await page.getByRole("button", { name: L.addQcDoc }).first().click();
sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
await expect(sheet.getByLabel(L.qdType, { exact: true })).toHaveValue("ITP");
await sheet.getByLabel(L.qdTitle, { exact: true }).fill(`ITP ${unit1.number}`);
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("itp.pdf"));
await sheet.getByRole("button", { name: L.addToFolder }).click();
await expect(sheet).toBeHidden();
const unitDocs = part(page, L.unitDocs);
await expect(unitDocs.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
await expect(page.getByText(L.noItpCallout)).toBeHidden();
// the project's WPS is listed as inherited
await expect(part(page, L.fromProject(PROJECT)).getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
// a text file is refused with the server's message
await page.getByRole("button", { name: L.addQcDoc }).first().click();
sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
await sheet.getByLabel(L.qdFile).setInputFiles(textFile());
await sheet.getByRole("button", { name: L.addToFolder }).click();
await expect(sheet.getByRole("alert")).toContainText(L.badFile);
await page.keyboard.press("Escape");
await expect(sheet).toBeHidden();
// a new revision of the ITP document
await unitDocs.getByRole("button", { name: L.newRevision }).first().click();
sheet = page.getByRole("dialog", { name: L.qcDocSheetRev });
await expect(sheet).toBeVisible();
await expect(sheet).toContainText("The old one stays on file under Superseded.");
await sheet.getByLabel(L.qdRev, { exact: true }).fill("1");
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("itp-r1.pdf"));
await sheet.getByRole("button", { name: L.fileNewRev }).click();
await expect(sheet).toBeHidden();
await expect(unitDocs.locator(L.qcDocRow)).toHaveCount(1);
await expect(unitDocs.getByText("Rev 1").first()).toBeVisible();
const old = part(page, L.superseded);
await old.getByRole("button", { name: L.show, exact: true }).click();
await expect(old.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
await expect(old.getByText(/superseded .* by /i).first()).toBeVisible();
// back to the project, back to home
await page.getByRole("button", { name: L.back }).or(page.getByRole("link", { name: L.back })).first().click();
await expect(page).toHaveURL(new RegExp(`/qc/projects/${proj.id}`));
await expect(page.locator(L.qcUnit)).toHaveCount(2);
await page.getByRole("button", { name: L.back }).or(page.getByRole("link", { name: L.back })).first().click();
await expect(page).toHaveURL(/\/qc\/?$/);
// a packet of unit 1 lists both documents in its Documents tab
await open(page, pk("accept").id, L.tabDocs);
const fromFolder = part(page, L.alsoInDataBook);
await expect(fromFolder.getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
await expect(fromFolder.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
// wh.wade has no Q.C. screen
const wade = await actor(browser, "wh.wade");
await wade.page.goto("/qc");
await expect(wade.page).not.toHaveURL(/\/qc(\/|$)/);
await closeAll(quinn, wade);
});
test("tpdocs: a packet document added, refused, seen read-only, taken out", async ({ browser }) => {
const id = pk("reject").id;
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
await open(page, id, L.tabDocs);
await expect(part(page, L.docsSection)).toBeVisible();
await page.getByRole("button", { name: L.addDocument }).click();
let sheet = page.getByRole("dialog", { name: L.addDocSheet });
await expect(sheet).toBeVisible();
await sheet.getByLabel(L.docType, { exact: true }).selectOption({ label: L.ndeKind });
await sheet.getByLabel(L.docTitle, { exact: true }).fill("RT report W1-W4");
await sheet.getByLabel(L.docNote).fill("RPT-2210, accept");
await sheet.getByLabel(L.docFile).setInputFiles(pdfFile("rt.pdf"));
await sheet.getByRole("button", { name: L.addToPacket }).click();
await expect(sheet).toBeHidden();
const docs = part(page, L.docsSection);
await expect(docs.getByText("RT report W1-W4").first()).toBeVisible();
expect(await logActions(quinn, id)).toContain("Document added");
await packetTab(page, L.tabHistory).click();
await expect(page.getByText("Document added").first()).toBeVisible();
// a text file is refused
await packetTab(page, L.tabDocs).click();
await page.getByRole("button", { name: L.addDocument }).click();
sheet = page.getByRole("dialog", { name: L.addDocSheet });
await sheet.getByLabel(L.docFile).setInputFiles(textFile());
await sheet.getByRole("button", { name: L.addToPacket }).click();
await expect(sheet.getByRole("alert")).toContainText(L.badFile);
await page.keyboard.press("Escape");
await expect(sheet).toBeHidden();
// wh.wade sees the row and no Add document
const wade = await actor(browser, "wh.wade");
await open(wade.page, id, L.tabDocs);
await expect(part(wade.page, L.docsSection).getByText("RT report W1-W4").first()).toBeVisible();
await expect(wade.page.getByRole("button", { name: L.addDocument })).toHaveCount(0);
await expect(wade.page.getByRole("button", { name: L.takeOut })).toHaveCount(0);
// qc.quinn takes it out with a reason
await docs.getByRole("button", { name: L.takeOut }).first().click();
const out = page.getByRole("dialog", { name: L.takeOutSheet });
await expect(out).toBeVisible();
await out.getByLabel(L.takeOutReason, { exact: true }).fill("wrong packet");
await out.getByRole("button", { name: L.takeOutSubmit, exact: true }).click();
await expect(out).toBeHidden();
const toggle = page.getByRole("button", { name: L.showTakenOut(1) });
await expect(toggle).toBeVisible();
await toggle.click();
await expect(page.getByText(L.takenOutBy).first()).toContainText("wrong packet");
const p = await packet(quinn, id);
const row = (p.docs as Json[]).find((d) => d.title === "RT report W1-W4") as Json;
expect(row.removed.reason).toBe("wrong packet");
await packetTab(page, L.tabHistory).click();
await expect(page.getByText("Document taken out of the data book").first()).toBeVisible();
await closeAll(quinn, wade);
});
test("titp: the digital ITP, the hold callout, every block signed, sign-off, reopen", async ({ browser }) => {
const unit2 = proj.units[1];
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
// start from the unit folder
await page.goto(`/qc/units/${unit2.id}`);
await expect(page.getByText(L.noItp)).toBeVisible();
await page.getByRole("button", { name: L.startItp }).click();
await expect(page).toHaveURL(new RegExp(`/qc/units/${unit2.id}/itp`));
const no = `${unit2.number}-ITP`;
await expect(page.getByRole("heading", { level: 1 })).toHaveText(no);
await expect(page.locator(L.itpRow)).toHaveCount(TEMPLATE_ITEMS);
const banners = page.locator(L.itpPage);
await expect(banners).toHaveCount(3);
for (let i = 0; i < 3; i++) await expect(banners.nth(i)).toContainText(L.pageBanners[i]);
await expect(page.getByLabel(L.hdr.no, { exact: true })).toHaveValue(no);
await expect(page.getByLabel(L.hdr.client, { exact: true })).toHaveValue("ACME");
await expect(page.getByLabel(L.hdr.pname, { exact: true })).toHaveValue("Separator skids");
await expect(page.getByLabel(L.hdr.pno, { exact: true })).toHaveValue(PROJECT);
await expect(page.getByLabel(L.hdr.po, { exact: true })).toHaveValue("5541");
await expect(page.getByLabel(L.hdr.unit, { exact: true })).toHaveValue(String(unit2.number));
// approve 1.1: the initials badge shows
await itpRow(page, "1.1").getByRole("button", { name: L.approve, exact: true }).click();
await expect(itpRow(page, "1.1").locator(L.initials)).toHaveText("QQ");
// section 5 N/A: its rows get the na class
await itpSection(page, "5").getByRole("button", { name: L.sectionNa }).click();
await expect(itpSection(page, "5").locator(`${L.itpRow}.na`)).toHaveCount(5);
// line 1.2 details: a customer witness and a note
await page.getByRole("button", { name: L.lineDetails("1.2") }).click();
const lineSheet = page.getByRole("dialog", { name: L.lineSheet });
await expect(lineSheet).toBeVisible();
await lineSheet.getByLabel(L.custWitness).fill("Rick Inspector");
await lineSheet.getByLabel(L.lineNote, { exact: true }).fill("Reviewed WPS-101 R2");
await lineSheet.getByRole("button", { name: L.save, exact: true }).click();
await expect(lineSheet).toBeHidden();
await expect(itpRow(page, "1.2")).toContainText("Rick Inspector");
// block 5 (all N/A) can be signed; block 3 cannot yet
await expect(itpBlock(page, "5")).toContainText(L.blockScope("Section 5"));
await expect(itpBlock(page, "3")).toContainText(L.blockScope("Sections 1–3"));
await expect(blockSign(page, "5")).toBeEnabled();
await expect(blockSign(page, "5")).toHaveText(L.signAllNa);
await expect(blockSign(page, "3")).toBeDisabled();
await blockSign(page, "5").click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect(itpBlock(page, "5")).toContainText("Quinn QC");
// sup.wes on the 03 packet of unit 2: WP points only; the 11 packet: FA points
const wes = await actor(browser, "sup.wes");
await open(wes.page, pk("wp").id);
const hold = wes.page.locator(L.holdCallout);
await expect(hold).toBeVisible();
await expect(hold).toContainText(`ITP ${no}`);
await expect(hold).toContainText("3.4");
await expect(hold).not.toContainText("4.2");
await expect(hold).toContainText(L.holdAdvice);
await open(wes.page, pk("fa").id);
await expect(wes.page.locator(L.holdCallout)).toContainText("9.6");
await expect(wes.page.locator(L.holdCallout)).not.toContainText("3.4");
// qc.quinn approves every remaining line
const approve = enabledApprove(page);
for (let k = 0; k < TEMPLATE_ITEMS + 5; k++) {
const n = await approve.count();
if (!n) break;
await approve.first().click();
await expect(approve).toHaveCount(n - 1);
}
await expect(approve).toHaveCount(0);
// the customer on block 3, then blocks 3, 4, 6, 7, 8, 9
const b3 = itpBlock(page, "3");
await b3.getByLabel(L.custRepName).fill("Rick Inspector");
await b3.getByRole("button", { name: L.record, exact: true }).click();
await expect(b3).toContainText("recorded by");
for (const key of ["3", "4", "6", "7", "8", "9"]) {
await expect(blockSign(page, key)).toBeEnabled();
await blockSign(page, key).click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect(blockSign(page, key)).toHaveCount(0);
}
// sign off with the PIN
const signOffBtn = page.getByRole("button", { name: L.signOff });
await expect(signOffBtn).toBeVisible();
await signOffBtn.click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect(page.getByRole("note").filter({ hasText: L.signedOffCallout }).first()).toBeVisible();
await expect(page.locator(L.itpRow).getByRole("button", { name: L.approve, exact: true })).toHaveCount(0);
await expect(page.getByLabel(L.hdr.client, { exact: true })).toBeDisabled();
const itp = await api(quinn, "get", `/api/units/${unit2.id}/itp`);
expect(itp.status).toBe("complete");
// the hold callout on the 03 packet is gone
await open(wes.page, pk("wp").id);
await expect(wes.page.locator(L.holdCallout)).toHaveCount(0);
// the project folder's unit tile shows ITP ✓
await page.goto(`/qc/projects/${proj.id}`);
await expect(page.locator(L.qcUnit).filter({ hasText: String(unit2.number) })).toContainText(L.itpTick);
// admin.ann reopens with a reason; the page is editable again
const ann = await actor(browser, "admin.ann");
await ann.page.goto(`/qc/units/${unit2.id}/itp`);
await ann.page.getByRole("button", { name: L.reopen, exact: true }).click();
const reopen = ann.page.getByRole("dialog", { name: L.reopenSheet });
await expect(reopen).toBeVisible();
await reopen.getByLabel(L.reopenReason, { exact: true }).fill("Customer asked to re-inspect 4.2");
await reopen.getByRole("button", { name: new RegExp(`^${L.reopen}`) }).click();
await expect(reopen).toBeHidden();
await expect.poll(async () => (await api(ann, "get", `/api/units/${unit2.id}/itp`)).status).toBe("open");
await page.goto(`/qc/units/${unit2.id}/itp`); // quinn's page was on the project folder
await expect(page.getByLabel(L.hdr.client, { exact: true })).toBeEnabled();
await closeAll(quinn, wes, ann);
});
test("api: the signature and the step body used here still answer as the contract says", async ({ browser }) => {
// A guard for the helpers: a reject without a reason or an NCR is refused before any signature is asked.
const quinn: Actor = await actor(browser, "qc.quinn");
const p = await packet(quinn, pk("reject").id);
expect(p.status).toBe("qc");
await stepApi(quinn, p.id, "qc_reject", { sign: sign("qc.quinn") }, 400);
await closeAll(quinn);
});
});
+383
View File
@@ -0,0 +1,383 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { section } from "./helpers";
import {
api,
assignApi,
finishStageApi,
pullAndRelease,
sign,
stepApi,
type Actor,
} from "./shophelpers";
import { onePagePdf, writeHeaders, type Json } from "./whhelpers";
// Helpers for the phase 6 Q.C. scenarios: a project with two units, packets taken to status qc through the API,
// and the UI labels the Q.C. spec depends on (L). The labels are the texts of the phase 6 design contract
// (Frontend B and C). The main session adjusts them here in one place when the components differ.
export const L = {
// Q.C. home (screens/qc/QcHome.tsx)
homeEyebrow: "Q.C. · Inspection",
homeHead: "Inspect the spool and the data book",
kpiWaiting: "Waiting for inspection",
kpiAccepted: "Accepted last 7 days",
kpiRejected: "Rejected last 7 days",
colReady: "Ready for inspection",
colAccepted: "Accepted",
colRejected: "Rejected — back with supervisor", // the old heading text, em dash kept
spoolAt: (loc: string) => `Spool at ${loc}`,
findJob: "Find a job",
folders: "Project folders",
chipActive: "Active",
chipAll: "All",
folderUnits: (n: number) => new RegExp(`${n} units?\\b`),
// packet page: tabs, status, callouts
tabQc: /^Q\.C\./,
tabDocs: /^Documents/,
tabHistory: /^History/,
statusPill: "status-pill", // testid (phase 5)
statusRouted: /Routed|Complete/, // the contract says "Routed"; the status label of complete is "Complete"
statusReview: "Supervisor review",
qcRejected: /^Q\.C\. rejected/,
rejectedWelds: (w: string) => `Rejected welds: ${w}`,
holdCallout: ".itphold", // .callout.warn.itphold, role note
holdAdvice: "Check with Q.C. before moving past these points.",
openItp: "Open ITP",
// the Q.C. bar (packet/QcActions.tsx)
qcBar: "Q.C. inspection", // region name
qcMissing: "qc-missing", // testid
stillNeeded: /^Still needed/,
route: "Route",
routeDataBook: "Clerical – Data Book", // the en dash is the old text
routeShipping: "Shipping",
accept: "Accept",
reject: "Reject",
rejectSheet: "Reject",
rejectReason: "Reason / what failed",
ncrNumber: "NCR number",
rejectSubmit: "Reject to supervisor",
// the Q.C. tab (packet/QcTab.tsx)
inspector: "Inspector",
stamp: "Stamp / ID",
checkItem: ".ck-item",
// the ten check boxes are named by their item text (B: aria-label is the text)
checkBoxes:
/^(Heat number recorded|MTRs on file|Weld map complete|Welds made to|Stall checklist|Dimensions verified|Heat numbers on material|Welds stamped|Clean: no slag|Quantity matches)/,
stampTick: /^Q1 · /,
vtSection: "Visual weld inspection", // region
weldRow: (no: number) => `Weld W${no}`, // role row
weldNote: (no: number) => `W${no} note`,
vtAccept: "Accept",
vtReject: "Reject",
defectNote: "Defect (undercut, porosity…)", // placeholder while rejected
ndeSection: /^NDE & testing/,
ndeMethod: "NDE method",
ndeResult: "NDE result",
reportNo: "Report no.",
inspectionNotes: "Inspection notes",
earlier: "Earlier inspections", // region
rejectedPill: "REJECTED",
// the Documents tab (packet/tabs.tsx)
docsSection: "Documents for the data book", // region
addDocument: /Add document$/, // an aria-hidden + comes first
addDocSheet: "Add a document",
docType: "Type",
docTitle: "Title",
docNote: "Note (optional)",
docFile: "File (PDF, PNG or JPG, up to 25 MB)",
addToPacket: "Add to packet",
view: "View",
takeOut: "Take out of data book",
takeOutSheet: "Take out of the data book",
takeOutReason: "Reason",
takeOutSubmit: "Take out",
showTakenOut: (n: number) => `Show ${n} taken out`,
takenOutBy: /^Taken out of the data book by/,
alsoInDataBook: "Also in the data book from the Q.C. folder", // region
ndeKind: "NDE report (RT / UT / MT / PT)",
badFile: "Only PDF, PNG and JPEG files can be stored.", // the upload's 415 text
// Q.C. folders (screens/qc/QcProjectFolder.tsx, QcUnitFolder.tsx, QcDocSheet.tsx)
back: /Back$/, // "← Back"
allFolders: "All project folders",
addQcDoc: /Add document$/, // "+ Add document"
qcDocSheetAdd: "Add document",
qcDocSheetRev: "New revision",
qdType: "Type",
qdTitle: "Title",
qdRev: "Rev",
qdFile: "File (PDF, PNG or JPG, up to 25 MB)",
qdNote: "Note",
addToFolder: "Add to folder",
fileNewRev: "File new revision",
newRevision: "New revision",
projectDocs: "Project documents", // region and list
unitFolders: /^Unit folders/,
unitDocs: "Unit documents", // region and list
fromProject: (n: string) => `From project ${n}`, // region
superseded: /^Superseded/, // region (the count follows)
show: "Show",
hide: "Hide",
noItpCallout: "No ITP on file for this unit yet.",
qcDocRow: ".qcdoc", // one document row (listitem)
qcUnit: ".qcunit",
pcard: ".pcard", // inside the region Project folders
itpTick: "ITP ✓",
// the ITP (screens/qc/ItpPage.tsx)
noItp: "No digital ITP yet.",
startItp: "Start ITP from template",
itpRow: ".itprow:not(.hd)", // the item rows; each section also has one .itprow.hd header row
itpPage: ".itppage",
itpSec: ".itpsec",
itpBlock: ".itpblock",
lineGroup: (n: string) => `Line ${n}`, // role group, exact
sectionRegion: (n: string) => new RegExp(`^${n}\\. `), // region "5. INTERNAL COATING"
blockGroup: (key: string) => `ITP block ${key}`, // role group
approve: "Approve",
sectionNa: "Section N/A",
sectionApplies: "Section applies",
lineDetails: (n: string) => `Line ${n} details`,
lineSheet: "ITP line",
custWitness: "Customer witness / reviewer",
lineNote: "Note",
save: "Save",
signSection: /^Sign section/,
signAllNa: /^Sign \(all N\/A\)/,
custRepName: "Customer rep name",
record: "Record",
signOff: "Sign off ITP",
reopen: "Reopen",
reopenSheet: "Reopen ITP",
reopenReason: "Reason",
signedOffCallout: /^Signed off .* by /,
initials: ".ini",
hdr: {
no: "Inspection plan no.",
client: "Client",
pname: "Project name",
pno: "Project no.",
po: "Purchase order",
unit: "Unit no.",
},
pageBanners: [/1 of 3 · WP Weld Parts/, /2 of 3 · C Code/, /3 of 3 · FA Final Assembly/],
blockScope: (scope: string) => `PESCO Q.C. signature · ${scope}`,
// shared (phase 5 review box, signature sheet)
bookReady: "Data book package ready",
spoolReady: "Spool ready for inspection",
spoolLocation: "Spool location",
sendToQc: "Send to Q.C.",
} as const;
export const NCR = "NCR-2026-0021";
export const TEMPLATE_ITEMS = 70; // counted from src/js/itp.js ITP_TMPL
export interface QcProject {
id: number;
number: string;
units: Json[]; // two units: [0] for the inspections and documents, [1] for the ITP
}
export interface QcPacket {
id: number;
title: string;
unitNumber: string;
}
/** As clerk.clara: a project with a customer and a PO, and two units. */
export async function makeQcProject(
clara: Actor,
number: string,
name = "Separator skids",
): Promise<QcProject> {
const project = await api(
clara,
"post",
"/api/projects",
{ number, name, customer: "ACME", po: "5541" },
{ status: 201 },
);
const units = (await api(
clara,
"post",
`/api/projects/${project.id}/units`,
{ count: 2 },
{ status: 201 },
)) as unknown as Json[];
expect(units.length).toBe(2);
return { id: Number(project.id), number: String(project.number), units };
}
/**
* As clerk.clara: a hand draft on `unit` in `dept` with two work orders and three BOM lines, a one-page PDF
* drawing attached with her password, the heat map answered. Sent to the warehouse unless `stay` is set.
*/
export async function makeQcDraft(
clara: Actor,
unit: Json,
title: string,
opts: { dept?: string; stay?: boolean } = {},
): Promise<QcPacket> {
const tag = `${unit.number ?? unit.id}`.slice(-8);
const draft = await api(
clara,
"post",
"/api/packets",
{
unit_id: unit.id,
dept_code: opts.dept ?? "03",
op: "WELD-PART",
title,
customer: "ACME",
drawing: `D-${tag}`,
rev: "A",
wps: "WPS-11",
work_orders: [
{ wo_base: `W${tag}`.slice(0, 20), lot: "001", split: "0", sub: "0", qty: 2, target_hrs: 2 },
{ wo_base: `X${tag}`.slice(0, 20), lot: "001", split: "0", sub: "0", qty: 1, target_hrs: 2.5 },
],
bom: [
{ item: 1, qty: 1, part_id: "PIPE-4", description: "4in pipe SCH40" },
{ item: 2, qty: 2, part_id: "FLG-4", description: "4in flange" },
{ item: 3, qty: 1, part_id: "STUD-58", description: "5/8 stud bolt" },
],
},
{ status: 201 },
);
const asset = await uploadPdf(clara, `${tag}.pdf`, "drawing");
await api(clara, "post", `/api/packets/${draft.id}/pages`, {
asset_id: asset.id,
type: "drawing",
sign: sign("clerk.clara"),
});
await api(clara, "patch", `/api/packets/${draft.id}`, { set: { heat_map: true }, base: {} });
if (!opts.stay) {
const sent = await stepApi(clara, Number(draft.id), "send_to_warehouse");
expect(sent.status).toBe("warehouse");
}
return { id: Number(draft.id), title, unitNumber: String(unit.number ?? "") };
}
/** Upload a one-page PDF with a purpose (document by default). */
export async function uploadPdf(a: Actor, name: string, purpose = "document"): Promise<Json> {
const up = await a.page.request.post("/api/assets", {
headers: writeHeaders(a.page),
multipart: {
file: { name, mimeType: "application/pdf", buffer: onePagePdf() },
purpose,
},
});
expect(up.status(), await up.text()).toBe(201);
return (await up.json()) as Json;
}
export interface Crew {
clara: Actor;
wade: Actor;
wes: Actor;
andy: Actor;
andyId: string;
}
/**
* Take a packet to status qc through the API: a draft on `unit` in 03, pulled and released by wh.wade, assigned by
* sup.wes to `stall` and weld.andy, started, two welds and the checklist, submitted, sent to Q.C. at `location`.
*/
export async function atQcApi(
crew: Crew,
unit: Json,
title: string,
stall = "Stall 2",
location = "Bay 2",
): Promise<QcPacket> {
const p = await makeQcDraft(crew.clara, unit, title);
await pullAndRelease(crew.wade, p.id);
await assignApi(crew.wes, p.id, stall, crew.andyId);
await stepApi(crew.andy, p.id, "start");
await finishStageApi(crew.andy, p.id, 2);
const sub = await stepApi(crew.andy, p.id, "submit", { sign: sign("weld.andy") });
expect(sub.status).toBe("review");
const qc = await stepApi(crew.wes, p.id, "send_to_qc", {
book: true,
spool: true,
location,
sign: sign("sup.wes"),
});
expect(qc.status).toBe("qc");
return p;
}
/** A board column of the Q.C. home by its heading. */
export function column(page: Page, heading: string): Locator {
return page
.getByRole("region", { name: new RegExp(`^${esc(heading)}`) })
.or(section(page, new RegExp(`^${esc(heading)}`)))
.first();
}
/** The Q.C. bar on a packet at Q.C. */
export function qcBar(page: Page): Locator {
return page.getByRole("region", { name: L.qcBar });
}
/** A region by its accessible name (the Q.C. tab and Documents tab parts, the folder sections). */
export function part(page: Page, name: string | RegExp): Locator {
return page.getByRole("region", { name }).first();
}
/** A packet card by its link. */
export function cardOf(scope: Locator, id: number): Locator {
return scope.locator(`a[href="/packets/${id}"]`).first();
}
/** The ten Q.C. check boxes. */
export function qcChecks(page: Page): Locator {
return page.getByRole("checkbox", { name: L.checkBoxes });
}
/** One ITP row by its line number (role group `Line 1.1`, exact: `Line 1.1` is inside `Line 1.10`). */
export function itpRow(page: Page, n: string): Locator {
return page.getByRole("group", { name: L.lineGroup(n), exact: true });
}
/** One ITP section by its number (region `5. INTERNAL COATING`). */
export function itpSection(page: Page, n: string): Locator {
return page.getByRole("region", { name: L.sectionRegion(n) }).first();
}
/** One ITP signature block by its key (group `ITP block 3`). */
export function itpBlock(page: Page, key: string): Locator {
return page.getByRole("group", { name: L.blockGroup(key), exact: true });
}
/** The block's sign button (`Sign section` or `Sign (all N/A)`). */
export function blockSign(page: Page, key: string): Locator {
const b = itpBlock(page, key);
return b
.getByRole("button", { name: L.signSection })
.or(b.getByRole("button", { name: L.signAllNa }))
.first();
}
/** Enabled Approve buttons on the ITP page. */
export function enabledApprove(page: Page): Locator {
return page.locator(`${L.itpRow} button:not([disabled])`, { hasText: /^Approve$/ });
}
export function esc(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Text file bytes, for the refused-file checks. */
export function textFile(name = "notes.txt"): { name: string; mimeType: string; buffer: Buffer } {
return { name, mimeType: "text/plain", buffer: Buffer.from("hello from the CI\n") };
}
/** A PDF file for an input[type=file]. */
export function pdfFile(name: string): { name: string; mimeType: string; buffer: Buffer } {
return { name, mimeType: "application/pdf", buffer: onePagePdf() };
}
/** The packet log actions, newest first. */
export async function logActions(a: Actor, id: number): Promise<string[]> {
const rows = (await api(a, "get", `/api/packets/${id}/log`)) as unknown as Json[];
return rows.map((r) => String(r.action));
}