384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
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));
|
|||
|
|
}
|