389 lines
19 KiB
TypeScript
389 lines
19 KiB
TypeScript
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { type APIRequestContext, type Page } from "@playwright/test";
|
|
import { cardOf, column } from "./closehelpers";
|
|
import { expect, test, dcUser, signInPassword } from "./helpers";
|
|
import { expectShowing, filtersButton, fltSelect, pickProject } from "./pmhelpers";
|
|
import { actor, closeAll, packetTab, signWithPassword } from "./shophelpers";
|
|
import { onePagePdf } from "./whhelpers";
|
|
|
|
// Phase 3 VISUAL feed scenario on the CI stack. The worker reads the synthetic fixture files
|
|
// (tests/fixtures/generator.py) mounted at /fixtures; this container mounts them read-write so the
|
|
// scenario can switch the state. clerk.clara presses Run now, the feed makes project L900 and its
|
|
// drafts, the chained drawing run attaches the WELD-PART drawing, the packet goes to the warehouse and
|
|
// wh.wade releases it to the shop. State 2 moves that drawing to rev 2: the next run opens a change
|
|
// order, the drawing run stages the rev 2 PDF, and clara approves it with her password.
|
|
//
|
|
// Phase 10 (old tflag, tflagsearch, trevchk2, thand, tmat), in state 1 before the drawing moves: the Flagged column
|
|
// and its flag texts, the Flag filter, a rev mismatch approved by a Clerical supervisor (clerk.leo) with a reason
|
|
// and a signature, the drawing and the BOM uploaded by hand on a flagged draft, and the VISUAL materials on the
|
|
// WELD-PART packet. As in the server tests (test_feed_api.py), drawings.1.json loses L900-0201's revisions before
|
|
// the first run, so M-Files has no rev 2 and the rev mismatch stays flagged.
|
|
|
|
const FIXTURE_DIR = process.env.FIXTURE_DIR ?? "/fixtures";
|
|
const KEY_WELD_PART = "L900001|WELD-PART|L900-0101";
|
|
const KEY_TWO_OPS = "L900001|WELD-CODE|L900-0201"; // VISUAL rev 2, M-Files rev 3: rev mismatch
|
|
const KEY_DRAFTING = "L900002|LAYOUT|L900-0501"; // in Drafting review: not released
|
|
const KEY_NOT_FOUND = "L900002|WELD-PART|L900-0701"; // not in M-Files
|
|
const POLL_MS = 60_000;
|
|
|
|
type Json = Record<string, any>;
|
|
|
|
function writeHeaders(page: Page): Record<string, string> {
|
|
const base = new URL(page.url()).origin;
|
|
return { Origin: base, "X-Requested-With": "PacketStation" };
|
|
}
|
|
|
|
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 lastRunId(page: Page): Promise<number> {
|
|
const o = await getJson(page.request, "/api/feed/overview");
|
|
return o.last_run ? Number(o.last_run.id) : 0;
|
|
}
|
|
|
|
/** Wait until a visual-feed run newer than `after` has finished ok (or degraded). Returns it. */
|
|
async function waitForRun(page: Page, after: number): Promise<Json> {
|
|
let run: Json | null = null;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const o = await getJson(page.request, "/api/feed/overview");
|
|
run = (o.runs as Json[]).find((r) => Number(r.id) > after && r.status !== "running") ?? null;
|
|
return run ? run.status : "waiting";
|
|
},
|
|
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
|
|
)
|
|
.toMatch(/^(ok|degraded)$/);
|
|
return run as unknown as Json;
|
|
}
|
|
|
|
async function pressRunNow(page: Page): Promise<void> {
|
|
const button = page.getByRole("button", { name: /run now/i }).first();
|
|
await expect(button).toBeEnabled({ timeout: POLL_MS });
|
|
await button.click();
|
|
}
|
|
|
|
/** The live packet for a candidate key "<unit>|<op>|<drawing>", found by its card fields. */
|
|
async function packetByKey(page: Page, key: string): Promise<Json> {
|
|
const [unit, op, drawing] = key.split("|");
|
|
const list = await getJson(page.request, "/api/packets?limit=2000");
|
|
const card = (list.packets as Json[]).find(
|
|
(p) => p.unit?.number === unit && p.op === op && (p.drawing ?? "") === drawing && p.source_kind === "visual",
|
|
);
|
|
if (!card) throw new Error(`no packet for ${key}`);
|
|
return getJson(page.request, `/api/packets/${card.id}`);
|
|
}
|
|
|
|
async function transition(page: Page, pkt: Json, action: string, extra: Json = {}) {
|
|
return page.request.post(`/api/packets/${pkt.id}/transitions`, {
|
|
headers: writeHeaders(page),
|
|
data: { action, expect: { status: pkt.status, dept: pkt.dept?.code ?? pkt.dept_code }, ...extra },
|
|
});
|
|
}
|
|
|
|
let packetId = 0;
|
|
|
|
test.describe.serial("VISUAL feed", () => {
|
|
test.setTimeout(300_000);
|
|
|
|
test("Run now makes project L900, its drafts and attaches the WELD-PART drawing", async ({ page }) => {
|
|
writeFileSync(join(FIXTURE_DIR, "state"), "1");
|
|
// M-Files has no rev 2 of L900-0201 at all (the server tests' set_revisions): the rev mismatch stays flagged
|
|
const dwgFile = join(FIXTURE_DIR, "drawings.1.json");
|
|
const dwgs = JSON.parse(readFileSync(dwgFile, "utf-8")) as Json;
|
|
delete dwgs["L900-0201"].revisions;
|
|
writeFileSync(dwgFile, JSON.stringify(dwgs, null, 1));
|
|
await signInPassword(page, "clerk.clara");
|
|
await expect(page).toHaveURL(/\/clerical/);
|
|
await expect(page.getByText(/from visual/i).first()).toBeVisible();
|
|
|
|
const before = await lastRunId(page);
|
|
await pressRunNow(page);
|
|
const run = await waitForRun(page, before);
|
|
expect(run.counts.new_drafts).toBeGreaterThan(0);
|
|
|
|
const projects = await getJson(page.request, "/api/projects");
|
|
const list = projects as unknown as Json[];
|
|
const l900 = list.find((p) => p.number === "L900");
|
|
expect(l900, "project L900").toBeTruthy();
|
|
const proj = await getJson(page.request, `/api/projects/${l900!.id}`);
|
|
const unitNumbers = ((proj.units ?? []) as Json[]).map((u) => u.number);
|
|
expect(unitNumbers).toEqual(expect.arrayContaining(["L900001", "L900002"]));
|
|
|
|
let pkt = await packetByKey(page, KEY_WELD_PART);
|
|
packetId = Number(pkt.id);
|
|
expect(pkt.status).toBe("draft");
|
|
// Either still waiting for its drawing, or the chained drawing job already attached it
|
|
const flags = pkt.flags as string[];
|
|
expect(flags.includes("drawing_needed") || pkt.drawing_state === "attached").toBe(true);
|
|
|
|
// The drawing job runs right after the feed (chained)
|
|
await expect
|
|
.poll(async () => (await getJson(page.request, `/api/packets/${packetId}`)).drawing_state, {
|
|
timeout: POLL_MS,
|
|
intervals: [1_000, 2_000],
|
|
})
|
|
.toBe("attached");
|
|
await page.goto(`/packets/${packetId}`);
|
|
// The Drawing tab shows the pages the feed attached from M-Files (the fixture vault)
|
|
const full = await getJson(page.request, `/api/packets/${packetId}`);
|
|
expect(((full.pages ?? []) as Json[]).length).toBeGreaterThan(0);
|
|
await expect(page.getByRole("tab", { name: /^Drawing/ }).or(page.getByRole("button", { name: /^Drawing/ })).first()).toBeVisible();
|
|
|
|
// Send it on, unless the feed auto-released it already
|
|
pkt = await getJson(page.request, `/api/packets/${packetId}`);
|
|
if (pkt.status === "draft") {
|
|
if (pkt.heat_map === null || pkt.heat_map === undefined) {
|
|
const r = await page.request.patch(`/api/packets/${packetId}`, {
|
|
headers: writeHeaders(page),
|
|
data: { set: { heat_map: true }, base: {} },
|
|
});
|
|
expect(r.status(), await r.text()).toBe(200);
|
|
pkt = await getJson(page.request, `/api/packets/${packetId}`);
|
|
}
|
|
const r = await transition(page, pkt, "send_to_warehouse");
|
|
if (r.status() !== 200) {
|
|
// the worker may have auto-released it between the two reads
|
|
pkt = await getJson(page.request, `/api/packets/${packetId}`);
|
|
expect(pkt.status, await r.text()).toBe("warehouse");
|
|
}
|
|
}
|
|
pkt = await getJson(page.request, `/api/packets/${packetId}`);
|
|
expect(pkt.status).toBe("warehouse");
|
|
});
|
|
|
|
test("tflag and tflagsearch: the Flagged column, the flag texts and the Flag filter", async ({ browser }) => {
|
|
const clara = await actor(browser, "clerk.clara");
|
|
const page = clara.page;
|
|
// The chained drawing run flags the three drafts
|
|
await expect
|
|
.poll(async () => (await packetByKey(page, KEY_TWO_OPS)).flags, { timeout: POLL_MS, intervals: [1_000, 2_000] })
|
|
.toContain("rev_mismatch");
|
|
const rev = await packetByKey(page, KEY_TWO_OPS);
|
|
const nr = await packetByKey(page, KEY_DRAFTING);
|
|
const nf = await packetByKey(page, KEY_NOT_FOUND);
|
|
expect(nr.flags).toContain("not_released");
|
|
expect(nf.flags).toContain("not_found");
|
|
expect(rev.flag).toMatch(/^Rev mismatch/);
|
|
expect(nf.flag).toBe("Drawing not in M-Files - check the drawing number in VISUAL");
|
|
|
|
await page.goto("/clerical");
|
|
await pickProject(page, "L900");
|
|
const flagged = column(page, "Flagged");
|
|
await expect(cardOf(flagged, rev.id)).toContainText(String(rev.flag));
|
|
await expect(cardOf(flagged, nr.id)).toContainText(String(nr.flag));
|
|
await expect(cardOf(flagged, nf.id)).toContainText("Drawing not in M-Files");
|
|
// The clean WELD-PART packet is not flagged
|
|
await expect(cardOf(flagged, packetId)).toHaveCount(0);
|
|
|
|
// The Flag filter narrows the board to one reason
|
|
const main = page.locator("main");
|
|
await filtersButton(page).click();
|
|
await fltSelect(page, "Flag").selectOption({ label: "Rev mismatch with M-Files" });
|
|
await expect(cardOf(main, rev.id)).toBeVisible();
|
|
await expect(cardOf(main, nf.id)).toHaveCount(0);
|
|
await expectShowing(page, 1);
|
|
await fltSelect(page, "Flag").selectOption({ label: "Drawing not found in M-Files" });
|
|
await expect(cardOf(main, nf.id)).toBeVisible();
|
|
await expect(cardOf(main, rev.id)).toHaveCount(0);
|
|
await fltSelect(page, "Flag").selectOption({ label: "Not flagged" });
|
|
await expect(cardOf(main, nf.id)).toHaveCount(0);
|
|
await expect(cardOf(main, rev.id)).toHaveCount(0);
|
|
await closeAll(clara);
|
|
});
|
|
|
|
test("trevchk2: a Clerical supervisor approves the M-Files rev with a reason and a signature", async ({ browser }) => {
|
|
const clara = await actor(browser, "clerk.clara");
|
|
const pkt = await packetByKey(clara.page, KEY_TWO_OPS);
|
|
// The staged M-Files copy is there before anyone can approve it
|
|
await expect
|
|
.poll(
|
|
async () => String((await getJson(clara.page.request, `/api/packets/${pkt.id}`)).visual?.staged_rev?.rev ?? ""),
|
|
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
|
|
)
|
|
.toBe("3");
|
|
await clara.page.goto(`/packets/${pkt.id}`);
|
|
const box = clara.page.getByRole("region", { name: "From VISUAL" });
|
|
await expect(box.getByText("A Clerical supervisor can approve rev 3 for this packet.")).toBeVisible();
|
|
await expect(box.getByRole("button", { name: /^Approve rev/ })).toHaveCount(0);
|
|
await closeAll(clara);
|
|
|
|
const leo = await actor(browser, "clerk.leo");
|
|
const page = leo.page;
|
|
await page.goto(`/packets/${pkt.id}`);
|
|
await page
|
|
.getByRole("region", { name: "From VISUAL" })
|
|
.getByRole("button", { name: "Approve rev 3 for this packet" })
|
|
.click();
|
|
const sheet = page.getByRole("dialog", { name: "Approve rev 3 for L900-0201" });
|
|
await expect(sheet).toBeVisible();
|
|
await sheet.getByLabel("Reason").fill("Engineering confirmed rev 3 is right");
|
|
await sheet.getByRole("button", { name: "Approve and attach" }).click();
|
|
await signWithPassword(page, "clerk.leo");
|
|
await expect(page.getByText("Rev 3 approved by hand").first()).toBeVisible();
|
|
const after = await getJson(page.request, `/api/packets/${pkt.id}`);
|
|
expect(after.flags).not.toContain("rev_mismatch");
|
|
expect(after.drawing_state).toBe("attached");
|
|
expect(String(after.visual.rev_ok.rev)).toBe("3");
|
|
expect(String(after.visual.rev_ok.vis_rev)).toBe("2");
|
|
expect(((after.pages ?? []) as Json[]).some((pg) => pg.type === "drawing" && String(pg.rev) === "3" && !pg.void)).toBe(
|
|
true,
|
|
);
|
|
await closeAll(leo);
|
|
});
|
|
|
|
test("thand: Clerical uploads the drawing and the BOM by hand on a flagged draft; the warehouse cannot", async ({
|
|
browser,
|
|
}) => {
|
|
const wade = await actor(browser, "wh.wade");
|
|
const nf = await packetByKey(wade.page, KEY_NOT_FOUND);
|
|
await wade.page.goto(`/packets/${nf.id}`);
|
|
await expect(wade.page.getByTestId("status-pill")).toBeVisible();
|
|
await expect(wade.page.getByRole("button", { name: "Upload drawing" })).toHaveCount(0);
|
|
await expect(wade.page.getByRole("button", { name: "Upload BOM" })).toHaveCount(0);
|
|
await closeAll(wade);
|
|
|
|
const clara = await actor(browser, "clerk.clara");
|
|
const page = clara.page;
|
|
await page.goto(`/packets/${nf.id}`);
|
|
await expect(page.getByText("Flagged - Clerical can upload the drawing or the BOM by hand.")).toBeVisible();
|
|
|
|
// The drawing: a reason is needed, then the signature
|
|
await page.getByRole("button", { name: "Upload drawing" }).click();
|
|
let sheet = page.getByRole("dialog", { name: "Upload the drawing" });
|
|
await sheet.getByLabel("File (PDF, up to 25 MB)").setInputFiles({
|
|
name: "L900-0701 hand.pdf",
|
|
mimeType: "application/pdf",
|
|
buffer: onePagePdf(`hand drawing ${Date.now()}`),
|
|
});
|
|
await sheet.getByRole("button", { name: "Upload and sign" }).click();
|
|
await expect(sheet.getByRole("alert")).toHaveText("Say why it is uploaded by hand.");
|
|
await sheet.getByLabel("Why by hand (kept on the packet)").fill("Drafting sent the PDF by email");
|
|
await sheet.getByRole("button", { name: "Upload and sign" }).click();
|
|
await signWithPassword(page, "clerk.clara");
|
|
await expect(page.getByText("Drawing attached. Check it, then send it on.")).toBeVisible();
|
|
const handLine = page.getByText(/^Drawing uploaded by hand: L900-0701 hand\.pdf/);
|
|
await expect(handLine).toBeVisible();
|
|
await expect(handLine).toContainText("Drafting sent the PDF by email");
|
|
let p = await getJson(page.request, `/api/packets/${nf.id}`);
|
|
expect(p.drawing_state).toBe("hand");
|
|
expect(p.flags).toContain("hand_drawing");
|
|
expect(p.flags).not.toContain("not_found");
|
|
expect(p.visual.manual_dwg.reason).toBe("Drafting sent the PDF by email");
|
|
|
|
// The BOM, as CSV
|
|
await page.getByRole("button", { name: "Upload BOM" }).click();
|
|
sheet = page.getByRole("dialog", { name: "Upload the BOM" });
|
|
await sheet.getByLabel("File (XLSX or CSV, up to 25 MB)").setInputFiles({
|
|
name: "hand_bom.csv",
|
|
mimeType: "text/csv",
|
|
buffer: Buffer.from("Item,Qty,Part ID,Description\n1,6,PIPE-8-SCH40,8in pipe\n2,2,FLG-8-150,8in flange\n", "utf-8"),
|
|
});
|
|
await sheet.getByLabel("Why by hand (kept on the packet)").fill("No materials in VISUAL yet");
|
|
await sheet.getByRole("button", { name: "Upload and sign" }).click();
|
|
await signWithPassword(page, "clerk.clara");
|
|
await expect(page.getByText("BOM uploaded. Check it, then send it on.")).toBeVisible();
|
|
p = await getJson(page.request, `/api/packets/${nf.id}`);
|
|
expect(((p.bom ?? []) as Json[]).map((b) => b.part_id)).toEqual(["PIPE-8-SCH40", "FLG-8-150"]);
|
|
expect(p.flags).toContain("hand_bom");
|
|
expect(p.flag).toBe("Drawing and BOM uploaded by hand - check it and send it on");
|
|
await expect(page.getByRole("button", { name: "Use VISUAL's BOM again" })).toBeVisible();
|
|
await closeAll(clara);
|
|
});
|
|
|
|
test("tmat: the work order's materials from VISUAL are the WELD-PART packet's BOM", async ({ browser }) => {
|
|
const clara = await actor(browser, "clerk.clara");
|
|
const page = clara.page;
|
|
const p = await getJson(page.request, `/api/packets/${packetId}`);
|
|
const parts = ((p.bom ?? []) as Json[]).map((b) => String(b.part_id));
|
|
// seq 20's lines: the MAIN warehouse line, the WIP sub-assembly and the line with a blank drawing. The line that
|
|
// names another drawing stays off, and seq 10's lines belong to the LAYOUT packet.
|
|
expect(parts).toEqual(expect.arrayContaining(["PIPE-6-SCH40", "L900-01-SUB", "WOL-1-6"]));
|
|
expect(parts).not.toContain("ELB-6-90");
|
|
expect(parts).not.toContain("FLG-6-150");
|
|
await page.goto(`/packets/${packetId}`);
|
|
await packetTab(page, /^Materials/).click();
|
|
const panel = page.getByRole("tabpanel");
|
|
await expect(panel.getByText("PIPE-6-SCH40").first()).toBeVisible();
|
|
await expect(panel.getByText("L900-01-SUB").first()).toBeVisible();
|
|
await expect(panel.getByText("ELB-6-90")).toHaveCount(0);
|
|
await closeAll(clara);
|
|
});
|
|
|
|
test("wh.wade releases it to the shop", async ({ page }) => {
|
|
await signInPassword(page, "wh.wade");
|
|
let pkt = await getJson(page.request, `/api/packets/${packetId}`);
|
|
// Release needs every material line pulled or short, with a heat on every pulled line: pull each one
|
|
// with a heat, as the warehouse would
|
|
for (const line of (pkt.bom ?? []) as Json[]) {
|
|
if (line.pulled || line.na) continue;
|
|
const pr = await page.request.patch(`/api/packets/${packetId}/bom/${line.item}`, {
|
|
headers: { ...writeHeaders(page), "If-Match": String(line.row_version) },
|
|
data: { pulled: true, heat: `HT-${String(line.item)}` },
|
|
});
|
|
expect(pr.status(), await pr.text()).toBe(200);
|
|
}
|
|
pkt = await getJson(page.request, `/api/packets/${packetId}`);
|
|
const r = await transition(page, pkt, "wh_release");
|
|
expect(r.status(), await r.text()).toBe(200);
|
|
expect((await r.json()).status).toBe("ready");
|
|
});
|
|
|
|
test("VISUAL moves the drawing to rev 2: a change order, staged, approved", async ({ page }) => {
|
|
await signInPassword(page, "clerk.clara");
|
|
await expect(page).toHaveURL(/\/clerical/);
|
|
writeFileSync(join(FIXTURE_DIR, "state"), "2");
|
|
const before = await lastRunId(page);
|
|
await pressRunNow(page);
|
|
const run = await waitForRun(page, before);
|
|
expect(run.counts.cos_opened).toBeGreaterThan(0);
|
|
|
|
const cos = await getJson(page.request, `/api/packets/${packetId}/change-orders`);
|
|
const list = cos as unknown as Json[];
|
|
const co = list.find((c) => c.review === "pending");
|
|
expect(co, "a pending change order").toBeTruthy();
|
|
|
|
// The Changes tab on the packet and the Clerical home both show it
|
|
await page.goto(`/packets/${packetId}`);
|
|
await page.getByRole("tab", { name: /changes/i }).or(page.getByRole("button", { name: /^changes/i })).first().click();
|
|
await expect(page.getByText(/waiting on clerical/i).first()).toBeVisible();
|
|
await page.goto("/clerical");
|
|
await expect(page.getByText(/changes to approve/i).first()).toBeVisible();
|
|
await expect(page.getByText("L900001").first()).toBeVisible();
|
|
|
|
// The chained drawing run stages the rev 2 PDF
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const all = await getJson(page.request, `/api/packets/${packetId}/change-orders`);
|
|
const c = (all as unknown as Json[]).find((x) => x.co_no === co!.co_no);
|
|
return c?.staged ? "staged" : "waiting";
|
|
},
|
|
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
|
|
)
|
|
.toBe("staged");
|
|
|
|
const clara = dcUser("clerk.clara");
|
|
const r = await page.request.post(`/api/packets/${packetId}/change-orders/${co!.co_no}/approve`, {
|
|
headers: writeHeaders(page),
|
|
data: { sign: { factor: "password", secret: clara.password } },
|
|
});
|
|
expect(r.status(), await r.text()).toBe(200);
|
|
|
|
const pkt = await getJson(page.request, `/api/packets/${packetId}`);
|
|
expect(pkt.rev).toBe("2");
|
|
// The old rev 1 pages are kept and voided; the rev 2 pages carry the change order
|
|
const pages = (pkt.pages ?? []) as Json[];
|
|
expect(pages.some((pg) => pg.void)).toBe(true);
|
|
expect(pages.some((pg) => !pg.void && pg.rev === "2")).toBe(true);
|
|
await page.goto(`/packets/${packetId}`);
|
|
await expect(page.getByText(`${pkt.drawing} R2`, { exact: true }).first()).toBeVisible();
|
|
// The Drawing tab lists the voided rev 1 page beside the new rev 2 page
|
|
await expect(page.getByRole("button", { name: /\bvoid\b/i }).first()).toBeVisible();
|
|
});
|
|
});
|