Files
PacketStation/tests/e2e/tests/qchelpers.ts
T
spencermandClaude Opus 5.5 1cd6688247
ci / frontend (pull_request) Successful in 57s
ci / server (pull_request) Successful in 12m33s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 5m4s
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- 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]>
2026-10-30 13:33:51 -06:00

389 lines
14 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",
reasonShort: "At least 8 characters.", // C2: under the reason while it is short
ncrOfReject: "NCR number of the reject", // C2: the accept's field when ncr_required
ncrFirst: "Enter the NCR number of the reject first.", // C2: the 422 text
tabMaterials: /^Materials/,
waitingApproval: "Waiting for the supervisor's approval.", // C7
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));
}