Files
PacketStation/tests/e2e/tests/qchelpers.ts
T
spencermandClaude Fable 5.1 00531cb329
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s
Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
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]>
2026-10-17 20:08:53 -06:00

384 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}