Files
PacketStation/tests/e2e/tests/qchelpers.ts
T

384 lines
13 KiB
TypeScript
Raw Normal View History

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));
}