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

204 lines
8.1 KiB
TypeScript

import { expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
import { dcUser } from "./helpers";
// Helpers for the phase 4 warehouse scenarios: packets made through the API, and the board locators.
// The labels are the exact texts of the phase 4 design contract (Frontend (C)). The frontend matches them.
export type Json = Record<string, any>;
export const L = {
pageHead: "Warehouse",
eyebrow: "Parts pull",
colIncoming: "Waiting to pull",
colPulling: "Being pulled",
colHold: "On hold",
colPulled: "Pulled, ready for production",
colReleased: "Released to shop (3 days)",
pillWaiting: "WH · waiting",
pillPulling: "WH · being pulled",
pillHold: "WH · on hold",
pillPulled: "WH · pulled",
startPulling: "Start pulling",
hold: "Hold",
markPulled: "Mark pulled",
resumePulling: "Resume pulling",
backToPulling: "Back to pulling",
releaseCard: "Release…",
putOnHold: "Put on hold",
cancel: "Cancel",
dropReleasedToast: "Open the packet and use Release to supervisor. It checks heats and shorts first.",
sendBack: "Send back to Clerical",
releaseToSupervisor: "Release to supervisor",
materialsTab: "Materials",
heatPlaceholder: "Scan / type heat",
recordShort: "Record short",
shortButton: "Short",
line: (n: number, what: "pulled" | "issued" | "location" | "heat" | "N/A" | "MTR file") => `Line ${n} ${what}`,
qtyShort: "Qty short",
reason: "Reason",
heatForPulled: "Heat no. for the pulled pieces",
holdSheet: "Put on hold",
holdNote: /^Note/,
recordShortSheet: "Record short",
receiveSheet: "Receive short part",
notAllPulled: "Not everything is pulled yet",
saveShort: "Save short",
receive: "Receive",
qtyReceived: "Qty received",
heat: "Heat",
note: "Note",
openShorts: "Open shorts",
noShorts: "No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.",
readyToAssign: "Ready to assign",
msgPull: "Pull every material line or mark it short first.",
msgHeat: "Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability.",
} as const;
export function writeHeaders(page: Page): Record<string, string> {
const base = new URL(page.url()).origin;
return { Origin: base, "X-Requested-With": "PacketStation" };
}
export async function getJson(req: APIRequestContext, url: string): Promise<Json> {
const r = await req.get(url);
expect(r.status(), `${url}: ${await r.text()}`).toBe(200);
return (await r.json()) as Json;
}
async function sendJson(page: Page, method: "post" | "patch", url: string, data: Json, status = 200): Promise<Json> {
const r = await page.request[method](url, { headers: writeHeaders(page), data });
expect(r.status(), `${method} ${url}: ${await r.text()}`).toBe(status);
return (await r.json()) as Json;
}
/** A minimal valid one-page PDF, built from raw bytes with a correct xref table. */
/** A one-page PDF. A `label` goes into a comment line, so the file has its own sha256 (the server dedupes by it). */
export function onePagePdf(label?: string): Buffer {
const content = "0 0 m 612 792 l S";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>",
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
];
let out = label ? `%PDF-1.4\n%${label.replace(/[\r\n]/g, " ")}\n` : "%PDF-1.4\n";
const offsets: number[] = [];
objects.forEach((body, i) => {
offsets.push(out.length);
out += `${i + 1} 0 obj\n${body}\nendobj\n`;
});
const xref = out.length;
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
out += offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return Buffer.from(out, "latin1");
}
/** A 1x1 PNG. */
export function tinyPng(): Buffer {
return Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
"base64",
);
}
export interface MadePacket {
id: number;
title: string;
unitNumber: string;
}
/**
* As the signed-in Clerical user: a project with one unit, a hand draft with BOM lines 1 to 3, a drawing
* attached with a password signature, the heat map answered, sent to the warehouse.
*/
export async function makeWarehousePacket(page: Page, projectNumber: string, title: string): Promise<MadePacket> {
const projects = (await getJson(page.request, "/api/projects?status=all")) as unknown as Json[];
let project = projects.find((p) => p.number === projectNumber);
if (!project) {
project = await sendJson(page, "post", "/api/projects", { number: projectNumber, name: "CI warehouse" }, 201);
}
const units = (await sendJson(page, "post", `/api/projects/${project.id}/units`, {}, 201)) as unknown as Json[];
const unit = units[0];
const draft = await sendJson(
page,
"post",
"/api/packets",
{
unit_id: unit.id,
dept_code: "03",
op: "WELD-PART",
title,
customer: "CI Customer",
drawing: `D-${projectNumber}`,
rev: "A",
work_orders: [],
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" },
],
},
201,
);
const up = await page.request.post("/api/assets", {
headers: writeHeaders(page),
multipart: {
file: { name: `${projectNumber}.pdf`, mimeType: "application/pdf", buffer: onePagePdf() },
purpose: "drawing",
},
});
expect(up.status(), await up.text()).toBe(201);
const asset = (await up.json()) as Json;
await sendJson(page, "post", `/api/packets/${draft.id}/pages`, {
asset_id: asset.id,
type: "drawing",
sign: { factor: "password", secret: dcUser("clerk.clara").password },
});
await sendJson(page, "patch", `/api/packets/${draft.id}`, { set: { heat_map: true }, base: {} });
const pkt = await getJson(page.request, `/api/packets/${draft.id}`);
const sent = await sendJson(page, "post", `/api/packets/${draft.id}/transitions`, {
action: "send_to_warehouse",
expect: { status: pkt.status, dept: pkt.dept.code },
});
expect(sent.status).toBe("warehouse");
return { id: Number(draft.id), title, unitNumber: String(unit.number ?? pkt.unit?.number ?? "") };
}
/** A board column by its title: a region named by the title, or a block that has it as a heading. */
export function column(page: Page, title: string): Locator {
return page
.getByRole("region", { name: title, exact: true })
.or(page.locator("section, [data-whcol]").filter({ has: page.getByRole("heading", { name: title, exact: true }) }))
.first();
}
/** A packet card, found by its unique title, anywhere under `scope`. */
export function card(scope: Page | Locator, title: string): Locator {
// Warehouse cards are role=group named "{job}, {stage name}"; the released column shows plain cards.
return scope
.getByRole("group")
.filter({ hasText: title })
.or(scope.locator(".card").filter({ hasText: title }))
.first();
}
/** Drag with the pointer from the middle of `from` to the middle of `to`, in steps (a real drag, not a click). */
export async function drag(page: Page, from: Locator, to: Locator): Promise<void> {
await from.scrollIntoViewIfNeeded();
const a = await from.boundingBox();
const b = await to.boundingBox();
if (!a || !b) throw new Error("drag: element not visible");
// Grab the card on its title line, away from its buttons.
await page.mouse.move(a.x + a.width / 2, a.y + 12);
await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 15, a.y + 20, { steps: 4 });
await page.mouse.move(b.x + b.width / 2, b.y + Math.min(b.height / 2, 120), { steps: 16 });
await page.mouse.up();
}
export async function whStage(page: Page, id: number): Promise<string | null> {
const p = await getJson(page.request, `/api/packets/${id}`);
return p.wh ? String(p.wh.stage) : null;
}