Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
ci / frontend (push) Successful in 34s
ci / server (push) Successful in 7m15s
ci / image (push) Successful in 39s
ci / e2e (push) Successful in 2m4s

The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.

Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 22:46:56 -06:00
co-authored by Claude Fable 5.1
parent 39a4eb5213
commit 5a4174e0c2
81 changed files with 11532 additions and 352 deletions
+644
View File
@@ -0,0 +1,644 @@
import { expect, test, type Page } from "@playwright/test";
import { PIN } from "./helpers";
import {
L,
actor,
api,
assignApi,
closeAll,
dragBy,
dragTo,
drawing,
finishStageApi,
makeShopDraft,
packet,
packetTab,
pullAndRelease,
setPin,
sign,
signSheet,
signWithPassword,
signWithPin,
statusOf,
stepApi,
tapDrawing,
userId,
type Actor,
type ShopPacket,
drawingReady,
} from "./shophelpers";
import type { Json } from "./whhelpers";
// Phase 5 shop scenarios on the CI stack, modelled on the old tests/ps tsec2, tht, tcosup, tcust and tsync.
// clerk.clara makes the packets by API and wh.wade pulls and releases them, so each starts Ready to assign in 03.
// tsec2: sup.wes assigns through the assign sheet, weld.andy works it on the Stall screen (start, a weld on the
// Weld map, the weld record, the checklist, submit with a wrong PIN then the right one), sup.wes cancels and then
// signs Send to Q.C.; the History tab has every step. tht: the heat table placed, dragged, sized, removed and
// dragged back from the panel; Q.C. sees it without controls. tcosup and tcust: a change order requiring 03 shows
// on the Supervisor screen and is verified with a signature; custody shows View only and the lead's read-only
// review; multi.mia cannot assign a 03 packet. tsync: two contexts mark welds and tick keys at once; a pull back
// makes weld.andy's submit stale; two supervisors verify one change order at once. Skip Q.C.: routed to 09 Hydro.
// Every UI label used here is in L (shophelpers.ts).
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
const PROJECT = `S5${RUN}`;
const T = {
sec: `Stall spool ${RUN}`,
ht: `Heat spool ${RUN}`,
co: `CO spool ${RUN}`,
sync: `Sync spool ${RUN}`,
route: `Route spool ${RUN}`,
wh: `WH spool ${RUN}`,
mia: `Mia spool ${RUN}`,
};
const P: Record<keyof typeof T, ShopPacket> = {} as Record<
keyof typeof T,
ShopPacket
>;
let andyId = "";
async function open(page: Page, id: number, tabName?: RegExp): Promise<void> {
await page.goto(`/packets/${id}`);
await expect(page.getByRole("heading").first()).toBeVisible();
if (tabName) await packetTab(page, tabName).click();
}
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));
}
test.describe.serial("shop", () => {
test.setTimeout(240_000);
test("clerk.clara and wh.wade make the shop packets", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const wade = await actor(browser, "wh.wade");
for (const k of Object.keys(T) as Array<keyof typeof T>) {
P[k] = await makeShopDraft(clara, PROJECT, T[k]);
if (k !== "wh") await pullAndRelease(wade, P[k].id);
}
const andy = await actor(browser, "weld.andy");
await setPin(andy, PIN);
andyId = await userId(andy, "weld.andy");
expect(andyId).not.toBe("");
await closeAll(clara, wade, andy);
});
test("tsec2: assign, work at the stall, submit, send to Q.C.", async ({
browser,
}) => {
const id = P.sec.id;
const wes = await actor(browser, "sup.wes");
// sup.wes assigns Stall 1 and weld.andy through the assign sheet
await open(wes.page, id);
await expect(wes.page.getByText(L.readyToAssign).first()).toBeVisible();
await wes.page
.getByRole("button", { name: L.assignOpen, exact: true })
.first()
.click();
const sheet = wes.page.getByRole("dialog", { name: L.assignSheet });
await expect(sheet).toBeVisible();
await sheet
.getByLabel(L.station, { exact: true })
.selectOption({ label: "Stall 1" });
await sheet.getByLabel(L.welderSearch).fill("Andy");
await sheet.getByLabel(L.welder, { exact: true }).selectOption(andyId);
await sheet.getByLabel(L.stamp, { exact: true }).fill("A7");
await sheet
.getByRole("button", { name: L.sendToStation, exact: true })
.click();
await expect(sheet).toBeHidden();
await expect.poll(() => statusOf(wes, id)).toBe("assigned");
const assigned = await packet(wes, id);
expect(assigned.station.name).toBe("Stall 1");
expect(assigned.welder.id).toBe(andyId);
expect(assigned.welder.stamp).toBe("A7");
// weld.andy on the Stall screen
const andy = await actor(browser, "weld.andy");
await andy.page.goto("/stall");
const picker = andy.page.getByRole("combobox", { name: L.stallPicker });
if (await picker.count())
await picker.first().selectOption({ label: L.stallOption("Stall 1") });
const myHead = andy.page.getByRole("heading", {
name: new RegExp(`^${L.myPackets}`),
});
const mine = andy.page
.getByRole("region", { name: new RegExp(`^${L.myPackets}`) })
.or(andy.page.locator("section").filter({ has: myHead }))
.first();
const myCard = mine
.getByText(T.sec)
.or(mine.getByText(P.sec.unitNumber))
.first();
await expect(myCard).toBeVisible();
await myCard.click();
await expect(andy.page).toHaveURL(new RegExp(`/packets/${id}`));
await andy.page
.getByRole("button", { name: L.startWork, exact: true })
.click();
await expect.poll(() => statusOf(andy, id)).toBe("in_progress");
await expect(andy.page.getByTestId(L.clock)).toHaveText(L.clockRunning);
// a weld on the Weld map: Mark a weld, tap the drawing, pick the items, Log weld
await packetTab(andy.page, L.tabWeldMap).click();
await andy.page
.getByRole("button", { name: L.markWeld, exact: true })
.click();
await tapDrawing(andy.page, L.weldMap, 0.45, 0.5);
const ws = andy.page.getByRole("dialog", { name: L.newWeldSheet });
await expect(ws).toBeVisible();
await ws.getByRole("button", { name: L.item(1), exact: true }).click();
await ws.getByRole("button", { name: L.item(2), exact: true }).click();
await ws.getByRole("button", { name: L.logWeld, exact: true }).click();
await expect(ws).toBeHidden();
await expect(
andy.page.getByRole("button", { name: L.weldMark(1) }).first(),
).toBeVisible();
await expect
.poll(async () =>
((await packet(andy, id)).welds as Json[]).map((w) => w.weld_no),
)
.toEqual([1]);
// the weld record: filler and filler lot (each saves on Enter)
const filler = andy.page.getByLabel(L.filler, { exact: true }).first();
await filler.fill("ER70S-6");
await filler.press("Enter");
const lot = andy.page.getByLabel(L.fillerLot, { exact: true }).first();
await lot.fill("1234");
await lot.press("Enter");
await expect
.poll(async () => (await packet(andy, id)).stage.filler_lot)
.toBe("1234");
expect((await packet(andy, id)).stage.filler).toBe("ER70S-6");
// the checklist, every box
await packetTab(andy.page, L.tabChecklist).click();
const boxes = andy.page.getByRole("tabpanel").getByRole("checkbox");
await expect(boxes.first()).toBeVisible();
const n = await boxes.count();
expect(n).toBe(9);
for (let i = 0; i < n; i++) {
await boxes.nth(i).check();
await expect(boxes.nth(i)).toBeChecked();
}
await expect
.poll(
async () =>
Object.values(
(await packet(andy, id)).stage.checklist as Json,
).filter((v) => v.done).length,
)
.toBe(9);
// submit: a wrong PIN first (the sheet stays open), then the right one
const submit = andy.page.getByRole("button", {
name: new RegExp(`^${L.submit}`),
});
await expect(submit).toBeEnabled();
await submit.click();
await signWithPin(andy.page, "135790");
await expect(signSheet(andy.page).getByRole("alert")).toBeVisible();
expect(await statusOf(andy, id)).toBe("in_progress");
await signWithPin(andy.page, PIN);
await expect(signSheet(andy.page)).toBeHidden();
await expect.poll(() => statusOf(andy, id)).toBe("review");
await expect(andy.page.getByText(L.supervisorReview).first()).toBeVisible();
// sup.wes: Send to Q.C. needs both boxes and the location; cancel the signature first, nothing changes
await open(wes.page, id);
const toQc = wes.page.getByRole("button", {
name: L.sendToQc,
exact: true,
});
await expect(toQc).toBeDisabled();
await wes.page.getByLabel(L.bookReady).check();
await wes.page.getByLabel(L.spoolReady).check();
await expect(toQc).toBeDisabled();
await wes.page.getByLabel(L.spoolLocation, { exact: true }).fill("Bay 2");
await expect(toQc).toBeEnabled();
await toQc.click();
await expect(signSheet(wes.page)).toBeVisible();
await signSheet(wes.page)
.getByRole("button", { name: L.cancel, exact: true })
.click();
await expect(signSheet(wes.page)).toBeHidden();
expect(await statusOf(wes, id)).toBe("review");
await toQc.click();
await signWithPassword(wes.page, "sup.wes");
await expect.poll(() => statusOf(wes, id)).toBe("qc");
await expect(wes.page.getByTestId(L.statusPill).first()).toHaveText(L.atQc);
const sent = await packet(wes, id);
expect(sent.qc.location).toBe("Bay 2");
// the History tab has every step
await packetTab(wes.page, L.tabHistory).click();
for (const text of L.history) {
await expect(
wes.page.getByRole("tabpanel").getByText(text).first(),
).toBeVisible();
}
const actions = await logActions(wes, id);
for (const text of L.history) expect(actions).toContain(text);
await closeAll(wes, andy);
});
test("tht: the heat table on the drawing", async ({ browser }) => {
const id = P.ht.id;
const wes = await actor(browser, "sup.wes");
await assignApi(wes, id, "Stall 2", andyId);
await open(wes.page, id, L.tabHeatMap);
await drawingReady(wes.page, L.heatMap);
const panel = wes.page.getByRole("group", { name: L.heatTablePanel });
await expect(panel).toBeVisible();
// place it: the table shows the caption, the BOM rows and their heats
await panel
.getByRole("button", { name: L.placeOnSheet, exact: true })
.click();
const onDwg = wes.page.getByTestId(L.heatTable);
await expect(onDwg).toBeVisible();
await expect(onDwg.getByText(L.heatCaption)).toBeVisible();
for (const heat of ["HT-E5-1", "HT-E5-2", "HT-E5-3"])
await expect(onDwg.getByText(heat)).toBeVisible();
await expect
.poll(async () => ((await packet(wes, id)).heat_table as Json[]).length)
.toBe(1);
const at0 = ((await packet(wes, id)).heat_table as Json[])[0] as Json;
// drag it, and drag it again
await dragBy(wes.page, onDwg, 140, 90);
await expect
.poll(
async () =>
(((await packet(wes, id)).heat_table as Json[])[0] as Json).x,
)
.not.toBe(at0.x);
const at1 = ((await packet(wes, id)).heat_table as Json[])[0] as Json;
await dragBy(wes.page, onDwg, 60, 120);
await expect
.poll(
async () =>
(((await packet(wes, id)).heat_table as Json[])[0] as Json).y,
)
.not.toBe(at1.y);
const at2 = ((await packet(wes, id)).heat_table as Json[])[0] as Json;
// pan the drawing on an empty spot: the view moves, the table placement does not
const viewer = wes.page.getByRole("group", {
name: L.heatMap,
exact: true,
});
const stage = viewer.locator(".stage").first();
const before = await stage.getAttribute("style");
await viewer.scrollIntoViewIfNeeded();
const canvas = drawing(wes.page, L.heatMap);
const box = await canvas.boundingBox();
if (!box) throw new Error("no drawing");
await wes.page.mouse.move(
box.x + box.width * 0.85,
box.y + box.height * 0.85,
);
await wes.page.mouse.down();
await wes.page.mouse.move(
box.x + box.width * 0.85 - 80,
box.y + box.height * 0.85 - 60,
{ steps: 10 },
);
await wes.page.mouse.up();
await expect.poll(() => stage.getAttribute("style")).not.toBe(before);
const at3 = ((await packet(wes, id)).heat_table as Json[])[0] as Json;
expect([at3.x, at3.y]).toEqual([at2.x, at2.y]);
// A+ twice: s 1.3
await panel.getByRole("button", { name: L.larger, exact: true }).click();
await expect
.poll(
async () =>
(((await packet(wes, id)).heat_table as Json[])[0] as Json).s,
)
.toBeCloseTo(1.15, 2);
await panel.getByRole("button", { name: L.larger, exact: true }).click();
await expect
.poll(
async () =>
(((await packet(wes, id)).heat_table as Json[])[0] as Json).s,
)
.toBeCloseTo(1.3, 2);
// remove, then drag it from the panel onto the drawing
await panel
.getByRole("button", { name: L.removeFromSheet, exact: true })
.click();
await expect(onDwg).toBeHidden();
await expect
.poll(async () => ((await packet(wes, id)).heat_table as Json[]).length)
.toBe(0);
await dragTo(wes.page, panel.getByTestId(L.heatGrab), canvas, 0.4, 0.4);
await expect(onDwg).toBeVisible();
await expect
.poll(async () => ((await packet(wes, id)).heat_table as Json[]).length)
.toBe(1);
// qc.quinn sees the table and no place, size, remove or drag controls
const quinn = await actor(browser, "qc.quinn");
await open(quinn.page, id, L.tabHeatMap);
await expect(
quinn.page.getByRole("group", { name: L.heatTableOnDrawing }),
).toBeVisible();
for (const name of [
L.placeOnSheet,
L.removeFromSheet,
L.larger,
L.smaller,
]) {
await expect(
quinn.page.getByRole("button", { name, exact: true }),
).toHaveCount(0);
}
await expect(quinn.page.getByTestId(L.heatGrab)).toHaveCount(0);
await closeAll(wes, quinn);
});
test("tcosup: a change order requiring 03 on the Supervisor screen, verified with a signature", async ({
browser,
}) => {
const id = P.co.id;
const clara = await actor(browser, "clerk.clara");
const wes = await actor(browser, "sup.wes");
await assignApi(wes, id, "Stall 3", andyId);
const co = await api(clara, "post", `/api/packets/${id}/change-orders`, {
reason: "Nozzle N2 moved 2 in on rev B",
required_depts: ["03"],
sign: sign("clerk.clara"),
});
const label = `CO-${(co.change_orders as Json[]).at(-1)?.co_no}`;
await wes.page.goto("/supervisor");
await expect(wes.page.getByText(L.supervisorHead)).toBeVisible();
const banner = wes.page.getByRole("alert", { name: L.coBanner });
await expect(banner).toBeVisible();
await expect(banner.getByText(L.coBannerRow(label)).first()).toBeVisible();
const tile = wes.page.getByRole("group", {
name: L.stationTile("Stall 3"),
});
await expect(
tile.getByRole("button", { name: L.coTag(label) }),
).toBeVisible();
await banner
.getByRole("button", { name: L.reviewAndVerify(label) })
.first()
.click();
await expect(wes.page).toHaveURL(new RegExp(`/packets/${id}`));
const box = wes.page
.getByRole("group", { name: L.verifyBox(label) })
.first();
await expect(box).toBeVisible();
const verify = box.getByRole("button", { name: L.verify, exact: true });
await expect(verify).toBeDisabled();
await box.getByLabel(L.reviewedBox).check();
await box.getByLabel(L.verifyNote).fill("Reviewed with the crew");
await verify.click();
await signWithPassword(wes.page, "sup.wes");
await expect
.poll(async () =>
Object.keys(
((await packet(wes, id)).change_orders as Json[]).at(-1)?.verified ??
{},
),
)
.toEqual(["03"]);
await expect(box).toHaveCount(0);
await wes.page.goto("/supervisor");
await expect(wes.page.getByText(L.supervisorHead)).toBeVisible();
await expect(wes.page.getByRole("alert", { name: L.coBanner })).toHaveCount(
0,
);
await closeAll(clara, wes);
});
test("tcust: custody, view only, the lead's read-only review, multi.mia cannot assign 03", async ({
browser,
}) => {
const wes = await actor(browser, "sup.wes");
// a packet with the warehouse: view only, no shop controls
await open(wes.page, P.wh.id);
await expect(wes.page.getByText(L.viewOnly).first()).toBeVisible();
for (const name of [L.startWork, L.sendToStation, L.pullBack]) {
await expect(
wes.page.getByRole("button", { name, exact: true }),
).toHaveCount(0);
}
await expect(
wes.page.getByRole("button", { name: L.assignOpen, exact: true }),
).toHaveCount(0);
await packetTab(wes.page, L.tabWeldMap).click();
await expect(
wes.page.getByRole("button", { name: L.markWeld, exact: true }),
).toHaveCount(0);
await packetTab(wes.page, L.tabHeatMap).click();
await expect(
wes.page.getByRole("button", { name: L.placeOnSheet, exact: true }),
).toHaveCount(0);
// a packet in review: lead.lou reads the review, sup.wes acts on it
const andy = await actor(browser, "weld.andy");
const id = P.route.id;
await assignApi(wes, id, "Stall 5", andyId);
await stepApi(andy, id, "start");
await finishStageApi(andy, id, 1);
await stepApi(andy, id, "submit", { sign: sign("weld.andy") });
expect(await statusOf(andy, id)).toBe("review");
const lou = await actor(browser, "lead.lou");
await open(lou.page, id);
await expect(lou.page.getByTestId(L.reviewReadonly)).toContainText(
L.leadReviewNote,
);
for (const name of [L.sendToQc, L.returnToStation, L.sendToDept]) {
await expect(
lou.page.getByRole("button", { name, exact: true }),
).toHaveCount(0);
}
// multi.mia (06) has no assign button on a 03 packet, and the API refuses her
const mia = await actor(browser, "multi.mia");
await open(mia.page, P.mia.id);
await expect(mia.page.getByText(L.readyToAssign).first()).toBeVisible();
await expect(
mia.page.getByRole("button", { name: L.assignOpen, exact: true }),
).toHaveCount(0);
const refused = await stepApi(
mia,
P.mia.id,
"assign",
{ station_id: 1, welder_id: andyId },
403,
);
expect(refused.code).toBe("forbidden");
await closeAll(wes, andy, lou, mia);
});
test("tsync: two sessions at once", async ({ browser }) => {
const id = P.sync.id;
const wes = await actor(browser, "sup.wes");
const andy = await actor(browser, "weld.andy");
await assignApi(wes, id, "Stall 4", andyId);
await stepApi(andy, id, "start");
// both mark a weld at nearly the same time: W1 and W2, never the same
await open(wes.page, id, L.tabWeldMap);
await open(andy.page, id, L.tabWeldMap);
const sheets = [];
for (const [a, fx] of [
[wes, 0.3],
[andy, 0.7],
] as const) {
await a.page
.getByRole("button", { name: L.markWeld, exact: true })
.click();
await tapDrawing(a.page, L.weldMap, fx, 0.5);
const ws = a.page.getByRole("dialog", { name: L.newWeldSheet });
await expect(ws).toBeVisible();
await ws.getByRole("button", { name: L.item(1), exact: true }).click();
sheets.push(ws);
}
await Promise.all(
sheets.map((ws) =>
ws.getByRole("button", { name: L.logWeld, exact: true }).click(),
),
);
await expect
.poll(async () =>
((await packet(wes, id)).welds as Json[]).map((w) => w.weld_no).sort(),
)
.toEqual([1, 2]);
for (const a of [wes, andy]) {
await a.page.reload();
await packetTab(a.page, L.tabWeldMap).click();
await expect(
a.page.getByRole("button", { name: L.weldMark(1) }).first(),
).toBeVisible();
await expect(
a.page.getByRole("button", { name: L.weldMark(2) }).first(),
).toBeVisible();
}
// both tick a different checklist key at once; both stay ticked after a reload
await packetTab(wes.page, L.tabChecklist).click();
await packetTab(andy.page, L.tabChecklist).click();
const boxOf = (a: Actor, i: number) =>
a.page.getByRole("tabpanel").getByRole("checkbox").nth(i);
await expect(boxOf(wes, 0)).toBeEnabled();
await expect(boxOf(andy, 1)).toBeEnabled();
await Promise.all([boxOf(andy, 0).check(), boxOf(wes, 1).check()]);
await expect
.poll(async () => {
const ck = (await packet(wes, id)).stage.checklist as Json;
return ["dwg", "heat"].every((k) => ck[k]?.done === true);
})
.toBe(true);
for (const a of [wes, andy]) {
await a.page.reload();
await packetTab(a.page, L.tabChecklist).click();
await expect(boxOf(a, 0)).toBeChecked();
await expect(boxOf(a, 1)).toBeChecked();
}
// weld.andy is ready to submit; sup.wes pulls the packet back while Andy's page still shows it in progress
await finishStageApi(andy, id, 0);
await andy.page.reload();
const submit = andy.page.getByRole("button", {
name: new RegExp(`^${L.submit}`),
});
await expect(submit).toBeEnabled();
await open(wes.page, id);
await wes.page
.getByRole("button", { name: L.pullBack, exact: true })
.click();
await expect.poll(() => statusOf(wes, id)).toBe("ready");
await submit.click();
await signWithPin(andy.page, PIN);
await expect(andy.page.getByTestId(L.toast)).toContainText(L.stale);
await expect(andy.page.getByText(L.readyToAssign).first()).toBeVisible();
expect(await statusOf(andy, id)).toBe("ready");
// two supervisors (03 and 06) verify one change order at once; both land
const clara = await actor(browser, "clerk.clara");
const co = await api(clara, "post", `/api/packets/${id}/change-orders`, {
reason: "Flange rating changed on rev B",
required_depts: ["03", "06"],
sign: sign("clerk.clara"),
});
const label = `CO-${(co.change_orders as Json[]).at(-1)?.co_no}`;
const mia = await actor(browser, "multi.mia");
const boxes = [];
for (const [a, name] of [
[wes, L.verifyBox(label)],
[mia, L.verifyBox06(label)],
] as const) {
await a.page.goto(`/packets/${id}?tab=changes`);
const box = a.page.getByRole("group", { name }).first();
await expect(box).toBeVisible();
await box.getByLabel(L.reviewedBox).check();
boxes.push(box);
}
await Promise.all(
boxes.map((b) =>
b.getByRole("button", { name: L.verify, exact: true }).click(),
),
);
await Promise.all([
signWithPassword(wes.page, "sup.wes"),
signWithPassword(mia.page, "multi.mia"),
]);
await expect
.poll(async () =>
Object.keys(
((await packet(wes, id)).change_orders as Json[]).at(-1)?.verified ??
{},
).sort(),
)
.toEqual(["03", "06"]);
expect((await packet(wes, id)).open_cos).toEqual([]);
await closeAll(wes, andy, clara, mia);
});
test("skip Q.C.: sup.wes routes a review packet to 09 Hydro", async ({
browser,
}) => {
const id = P.route.id;
const wes = await actor(browser, "sup.wes");
expect(await statusOf(wes, id)).toBe("review");
await open(wes.page, id);
const select = wes.page.getByLabel(L.skipQcSelect, { exact: true });
await expect(select).toBeVisible();
await select.selectOption({ label: L.hydroOption });
await wes.page
.getByRole("button", { name: L.sendToDept, exact: true })
.click();
await signWithPassword(wes.page, "sup.wes");
await expect.poll(async () => (await packet(wes, id)).dept.code).toBe("09");
const p = await packet(wes, id);
expect(p.status).toBe("ready");
await expect(wes.page.getByTestId(L.statusPill).first()).toHaveText(
L.readyToAssign,
);
await expect(wes.page.getByText(/Hydro/).first()).toBeVisible();
await packetTab(wes.page, L.tabWeldMap).click();
await expect(
wes.page.getByRole("button", { name: L.carriedWeld(1) }).first(),
).toBeVisible();
await expect(wes.page.getByText(L.carriedPanel)).toBeVisible();
await closeAll(wes);
});
});
+511
View File
@@ -0,0 +1,511 @@
import {
expect,
type Browser,
type BrowserContext,
type Locator,
type Page,
} from "@playwright/test";
import { dcUser, PIN, signInPassword } from "./helpers";
import { getJson, onePagePdf, writeHeaders, type Json } from "./whhelpers";
// Helpers for the phase 5 shop scenarios: packets made and moved through the API, one signed-in browser context
// per user, and the UI labels the shop spec depends on (L). The labels are the texts of the phase 5 design
// contract (Frontend B and C) and of the components as built; the main session adjusts them here in one place.
export const L = {
// packet page tabs
tabHeatMap: /^Heat map/,
tabChecklist: /^Checklist/,
tabWeldMap: /^Weld map/,
tabChanges: /^Changes/, // "Change orders" in the contract; B named the tab "Changes"
tabHistory: /^History/,
// status pills and texts
readyToAssign: "Ready to assign",
supervisorReview: "Supervisor review",
atQc: /^(At Q\.C\.|Q\.C\. inspection)$/, // the status pill shows the status label
statusPill: "status-pill",
viewOnly: "View only.",
leadReviewNote: "A supervisor for 03 Weld Parts reviews it and sends it on.",
stale: "Someone else already took this step.",
// assign sheet (supervisor and lead, packet in Ready)
assignOpen: "Send to station", // the bar button; the dialog's submit has the same name
assignSheet: "Send to station",
station: "Station",
welderSearch: "Find a welder",
welder: "Welder", // a select; option text "Andy Welder [75]", value the user id
stamp: "Stamp",
sendToStation: "Send to station",
sendBackToWarehouse: "Send back to Warehouse",
pullBack: "Pull back to queue",
// stall
stallPicker: "Station", // combobox, option text "03 Weld Parts · Stall 1"
stallOption: (stall: string) => `03 Weld Parts · ${stall}`,
myPackets: "My packets",
submittedCol: "Submitted, waiting on supervisor",
startWork: "Start work",
pause: "Pause",
resume: "Resume",
submit: "Submit to supervisor",
clock: "clock", // testid: "01:02:03 running · target 4.5 h"
clockRunning: /\d\d:\d\d:\d\d\s*running/,
toast: "toast", // testid of the stale-step toast (role status)
reviewReadonly: "review-readonly", // testid of the lead's read-only review
// weld map
markWeld: "Mark a weld",
weldMap: "Weld map",
drawingCanvas: /^Drawing page \d+/,
newWeldSheet: "New weld",
item: (n: number) => `Item ${n}`,
logWeld: "Log weld",
weldMark: (n: number) => new RegExp(`^Weld W${n}(,|$)`),
carriedWeld: (n: number) => `Weld W${n}, carried`,
carriedPanel: "Carried from an earlier department",
filler: "Filler metal",
fillerLot: "Filler lot / heat",
// heat map
heatMap: "Heat map",
heatTablePanel: "Heat table panel",
heatTableOnDrawing: "Heat table on the drawing", // role group
heatTable: "heat-table", // its testid
placeOnSheet: "Place on this sheet",
removeFromSheet: "Remove from this sheet",
larger: "Larger table",
smaller: "Smaller table",
heatGrab: "heat-table-grab", // data-testid of the drag source in the panel
heatCaption: "HEAT NUMBERS",
// review (supervisor)
bookReady: "Data book package ready",
spoolReady: "Spool ready for inspection",
spoolLocation: "Spool location",
noteToQc: "Note to Q.C.",
sendToQc: "Send to Q.C.",
returnToStation: "Return to station",
skipQcSelect: "Skip Q.C. - send to department", // a select, values are department codes
sendToDept: "Send to department",
hydroOption: "09 Hydro",
// change orders
coBanner: "Change orders to verify",
coBannerRow: (label: string) =>
new RegExp(`Change order ${label} on .* needs your verification`),
reviewAndVerify: (label: string) =>
new RegExp(`^Review & verify ${label} on`),
coTag: (label: string) =>
new RegExp(`^CHANGE ORDER ${label} . drawing changed, verify`),
verifyBox: (label: string) => `Verify ${label} for 03 Weld Parts`,
verifyBox06: (label: string) => `Verify ${label} for 06 Code Bldg 2`,
reviewedBox: "I reviewed this change with my department",
verifyNote: /^Note/,
verify: "Verify",
stationTile: (name: string) => new RegExp(`^${name}, \\d+ job`),
supervisorHead: "Dispatch, review, route",
// signature sheet (components/SignatureSheet.tsx)
signSheet: "Sign",
signButton: "Sign",
cancel: "Cancel",
usePassword: "Use your password instead",
// history texts
history: [
"Sent to Stall 1",
"Work started",
"Weld W1 marked",
"Submitted to supervisor",
"Sent to Q.C.",
],
} as const;
export const WELD_KEYS = [
"dwg",
"heat",
"wps",
"fit",
"pre",
"vt",
"stamp",
"clean",
"qty",
] as const;
export interface Actor {
sam: string;
ctx: BrowserContext;
page: Page;
}
/** A fresh browser context signed in as `sam` (password). */
export async function actor(browser: Browser, sam: string): Promise<Actor> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await signInPassword(page, sam);
return { sam, ctx, page };
}
export async function api(
a: Actor | Page,
method: "get" | "post" | "patch" | "put" | "delete",
url: string,
data?: Json,
opts: { status?: number; headers?: Record<string, string> } = {},
): Promise<Json> {
const page = "page" in a ? a.page : a;
const r = await page.request.fetch(url, {
method: method.toUpperCase(),
headers: { ...writeHeaders(page), ...(opts.headers ?? {}) },
...(data !== undefined ? { data } : {}),
});
const want = opts.status ?? 200;
expect(r.status(), `${method} ${url}: ${await r.text()}`).toBe(want);
const text = await r.text();
return text ? (JSON.parse(text) as Json) : {};
}
export async function packet(a: Actor | Page, id: number): Promise<Json> {
const page = "page" in a ? a.page : a;
return getJson(page.request, `/api/packets/${id}`);
}
export function sign(sam: string): Json {
return { factor: "password", secret: dcUser(sam).password };
}
/** POST a transition with the expect taken from the packet's current state. */
export async function stepApi(
a: Actor,
id: number,
action: string,
body: Json = {},
status = 200,
): Promise<Json> {
const p = await packet(a, id);
return api(
a,
"post",
`/api/packets/${id}/transitions`,
{ action, expect: { status: p.status, dept: p.dept.code }, ...body },
{ status },
);
}
/** Set the user's signature PIN through the account API (idempotent: the same PIN again is fine). */
export async function setPin(a: Actor, pin: string = PIN): Promise<void> {
await api(a, "post", "/api/account/pin", {
password: dcUser(a.sam).password,
pin,
});
}
export interface ShopPacket {
id: number;
title: string;
unitNumber: string;
}
/**
* As clerk.clara: a project (made once), a new unit, a hand draft in 03 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.
*/
export async function makeShopDraft(
clara: Actor,
projectNumber: string,
title: string,
): Promise<ShopPacket> {
const projects = (await getJson(
clara.page.request,
"/api/projects?status=all",
)) as unknown as Json[];
let project = projects.find((p) => p.number === projectNumber);
if (!project)
project = await api(
clara,
"post",
"/api/projects",
{ number: projectNumber, name: "CI shop" },
{ status: 201 },
);
const units = (await api(
clara,
"post",
`/api/projects/${project.id}/units`,
{},
{ status: 201 },
)) as unknown as Json[];
const unit = units[0] as Json;
const draft = await api(
clara,
"post",
"/api/packets",
{
unit_id: unit.id,
dept_code: "03",
op: "WELD-PART",
title,
customer: "CI Customer",
drawing: `D-${projectNumber}`,
rev: "A",
wps: "WPS-11",
work_orders: [
{
wo_base: `W${projectNumber}`.slice(0, 20),
lot: "001",
split: "0",
sub: "0",
qty: 2,
target_hrs: 2,
},
{
wo_base: `X${projectNumber}`.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 up = await clara.page.request.post("/api/assets", {
headers: writeHeaders(clara.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 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: {},
});
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 ?? sent.unit?.number ?? ""),
};
}
/** As wh.wade: every line pulled with a heat, then released: the packet is Ready to assign in 03. */
export async function pullAndRelease(wade: Actor, id: number): Promise<Json> {
let p = await packet(wade, id);
for (const line of p.bom as Json[]) {
const item = Number(line.item);
let cur = (p.bom as Json[]).find((b) => b.item === item) as Json;
p = await api(
wade,
"patch",
`/api/packets/${id}/bom/${item}`,
{ pulled: true },
{ headers: { "If-Match": String(cur.row_version) } },
);
cur = (p.bom as Json[]).find((b) => b.item === item) as Json;
p = await api(
wade,
"patch",
`/api/packets/${id}/bom/${item}`,
{ heat: `HT-E5-${item}` },
{ headers: { "If-Match": String(cur.row_version) } },
);
}
const out = await stepApi(wade, id, "wh_release");
expect(out.status).toBe("ready");
return out;
}
export async function userId(a: Actor, sam: string): Promise<string> {
const people = (await getJson(
a.page.request,
"/api/departments/03/people",
)) as unknown as Json[];
const me = people.find((x) => x.display_name === dcUser(sam).display_name);
if (!me) throw new Error(`${sam} is not in 03 people`);
return String(me.id);
}
export async function stationId(
a: Actor,
dept: string,
name: string,
): Promise<number> {
const depts = (await getJson(
a.page.request,
"/api/departments",
)) as unknown as Json[];
const d = depts.find((x) => x.code === dept) as Json;
const st = (d.stations as Json[]).find((x) => x.name === name);
if (!st) throw new Error(`no station ${name} in ${dept}`);
return Number(st.id);
}
/** As sup.wes through the API: assign to a stall and weld.andy. */
export async function assignApi(
wes: Actor,
id: number,
stall: string,
welderId: string,
): Promise<Json> {
return stepApi(wes, id, "assign", {
station_id: await stationId(wes, "03", stall),
welder_id: welderId,
});
}
/** As the holder through the API: every checklist key, `welds` welds on page 1, the filler. */
export async function finishStageApi(
a: Actor,
id: number,
welds = 1,
): Promise<Json> {
let p: Json = {};
for (const k of WELD_KEYS)
p = await api(a, "put", `/api/packets/${id}/stage/checklist/${k}`, {
done: true,
});
for (let i = 0; i < welds; i++) {
p = await api(a, "post", `/api/packets/${id}/welds`, {
page: 1,
fx: 0.3 + 0.1 * i,
fy: 0.4,
items: [1, 2],
type: "Fillet",
process: "GMAW",
});
}
p = await api(a, "patch", `/api/packets/${id}/stage`, {
set: { filler: "ER70S-6", filler_lot: "1234" },
base: {},
});
return p;
}
/** The status the packet shows on the server, for expect.poll. */
export async function statusOf(a: Actor, id: number): Promise<string> {
return String((await packet(a, id)).status);
}
/** A tab of the packet page by name (role=tab, or a button). */
export function packetTab(page: Page, name: RegExp): Locator {
return page
.getByRole("tab", { name })
.or(page.getByRole("button", { name }))
.first();
}
/** The signature sheet. */
export function signSheet(page: Page): Locator {
return page.getByRole("dialog", { name: L.signSheet, exact: true });
}
/** Sign with the password in the open signature sheet. */
export async function signWithPassword(page: Page, sam: string): Promise<void> {
const sheet = signSheet(page);
await expect(sheet).toBeVisible();
const toPw = sheet.getByRole("button", { name: L.usePassword });
if (await toPw.count()) await toPw.click();
await sheet
.getByLabel("Password", { exact: true })
.fill(dcUser(sam).password);
await sheet.getByRole("button", { name: L.signButton, exact: true }).click();
}
/** Sign with a PIN in the open signature sheet (the PIN field, typed). */
export async function signWithPin(page: Page, pin: string): Promise<void> {
const sheet = signSheet(page);
await expect(sheet).toBeVisible();
const field = sheet.getByLabel("PIN", { exact: true });
await field.fill(pin);
await sheet.getByRole("button", { name: L.signButton, exact: true }).click();
}
/** The drawing canvas of the open viewer. */
export function drawing(page: Page, viewer: string): Locator {
return page
.getByRole("group", { name: viewer, exact: true })
.getByRole("img", { name: L.drawingCanvas })
.first();
}
/** Waits until the viewer has rendered its page (data-ready="1"); taps before that are ignored. */
export async function drawingReady(page: Page, viewer: string): Promise<void> {
await expect(
page
.getByRole("group", { name: viewer, exact: true })
.and(page.locator('[data-ready="1"]')),
).toBeVisible({ timeout: 20000 });
}
/** Click a point on the drawing given as fractions of its box. */
export async function tapDrawing(
page: Page,
viewer: string,
fx: number,
fy: number,
): Promise<void> {
await drawingReady(page, viewer);
// page.mouse works in viewport coordinates, so the viewer must be on screen first.
await page
.getByRole("group", { name: viewer, exact: true })
.scrollIntoViewIfNeeded();
const c = drawing(page, viewer);
await expect(c).toBeVisible();
const b = await c.boundingBox();
if (!b) throw new Error("drawing not visible");
await page.mouse.click(b.x + b.width * fx, b.y + b.height * fy);
}
/** Drag with the pointer, in steps, from the middle of `from` by (dx, dy), or to the middle of `to`. */
export async function dragBy(
page: Page,
from: Locator,
dx: number,
dy: number,
): Promise<void> {
await from.scrollIntoViewIfNeeded();
const a = await from.boundingBox();
if (!a) throw new Error("drag: element not visible");
const x = a.x + a.width / 2;
const y = a.y + Math.min(a.height / 2, 14);
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + 8, y + 8, { steps: 3 });
await page.mouse.move(x + dx, y + dy, { steps: 14 });
await page.mouse.up();
}
export async function dragTo(
page: Page,
from: Locator,
to: Locator,
fx = 0.5,
fy = 0.5,
): 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");
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 10, a.y + a.height / 2 + 10, {
steps: 3,
});
await page.mouse.move(b.x + b.width * fx, b.y + b.height * fy, { steps: 16 });
await page.mouse.up();
}
export async function closeAll(...actors: Actor[]): Promise<void> {
for (const a of actors) await a.ctx.close();
}