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]>
This commit is contained in:
@@ -99,4 +99,36 @@ test.describe.serial("admin stamp check", () => {
|
||||
await expect(results).toContainText("Job traveler");
|
||||
await closeAll(ann);
|
||||
});
|
||||
|
||||
test("C9: Q.C. checks a stamp on the Stamp check screen; Warehouse is refused", async ({ browser }) => {
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const page = quinn.page;
|
||||
// the API outside /admin answers Q.C.
|
||||
const ok = await api(quinn, "post", "/api/stamp-verify", { text: LINE });
|
||||
expect(ok.valid).toBe(true);
|
||||
expect(ok.print.packet_id).toBe(P.id);
|
||||
|
||||
// the top-bar link opens the screen
|
||||
await page.goto("/qc");
|
||||
await page.getByRole("banner").getByRole("link", { name: "Stamp check", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/stamp-check$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Stamp check" })).toBeVisible();
|
||||
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Check a stamp line" }) });
|
||||
await form.getByLabel("Stamp line or code", { exact: true }).fill(LINE);
|
||||
await form.getByRole("button", { name: "Check stamp", exact: true }).click();
|
||||
const result = form.getByRole("status", { name: "Stamp result" });
|
||||
await expect(result).toContainText("Genuine.");
|
||||
await expect(result).toContainText("Job traveler");
|
||||
|
||||
// Clerical may check too; Warehouse may not, and its screen says so
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
expect((await api(clara, "post", "/api/stamp-verify", { text: LINE })).valid).toBe(true);
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const refused = await call(wade, "post", "/api/stamp-verify", { text: LINE });
|
||||
expect(refused.status).toBe(403);
|
||||
await expect(wade.page.getByRole("banner").getByRole("link", { name: "Stamp check", exact: true })).toHaveCount(0);
|
||||
await wade.page.goto("/stamp-check");
|
||||
await expect(wade.page.getByTestId("not-for-role")).toContainText("The Stamp check is not for your role.");
|
||||
await closeAll(quinn, clara, wade);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { actor, api, closeAll } from "./shophelpers";
|
||||
import { call } from "./adminhelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
// Change C8 on the CI stack: checklists per department. multi.mia (Clerical and 06 Code Bldg 2 supervisor) opens
|
||||
// Checklists from the top bar, sees only her own department, adds an item and saves. sup.wes (03 supervisor) can
|
||||
// read 06 but not change it, and Q.C. and a lead cannot change any. At the end mia sets 06 back to the default list
|
||||
// so the other scenarios keep the lists they expect.
|
||||
|
||||
const RUN = Date.now().toString(36).toLowerCase().slice(-5);
|
||||
const KEY = `e2e_${RUN}`;
|
||||
const TEXT = `Nameplate checked ${RUN}`;
|
||||
|
||||
async function resetTo06Default(a: Parameters<typeof api>[0]): Promise<void> {
|
||||
const cur = await api(a, "get", "/api/checklists/06");
|
||||
if (cur.custom) await api(a, "put", "/api/checklists/06", { items: null, base: cur.version });
|
||||
}
|
||||
|
||||
test.describe.serial("department checklists", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test("C8: a supervisor edits their own department's checklist; others cannot", async ({ browser }) => {
|
||||
const mia = await actor(browser, "multi.mia");
|
||||
await resetTo06Default(mia);
|
||||
const list = (await api(mia, "get", "/api/checklists")) as unknown as Json[];
|
||||
const editable = list.filter((d) => d.editable).map((d) => String(d.dept_code));
|
||||
expect(editable).toEqual(["06"]);
|
||||
|
||||
// the top-bar link; one department, so no picker
|
||||
const page = mia.page;
|
||||
await page.goto("/clerical");
|
||||
await page.getByRole("banner").getByRole("link", { name: "Checklists", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/checklists$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Checklists" })).toBeVisible();
|
||||
await expect(page.getByLabel("Department", { exact: true })).toHaveCount(0);
|
||||
const editor = page.locator("section").filter({ has: page.getByRole("heading", { name: /^06 / }) });
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor).toContainText(/Uses the default/);
|
||||
const keys = editor.getByLabel(/^Item \d+ key$/);
|
||||
const n = await keys.count();
|
||||
expect(n).toBeGreaterThan(0);
|
||||
|
||||
// add an item at the end and save
|
||||
await editor.getByRole("button", { name: "Add item", exact: true }).click();
|
||||
await expect(keys).toHaveCount(n + 1);
|
||||
await editor.getByLabel(`Item ${n + 1} group`, { exact: true }).fill("Final");
|
||||
await editor.getByLabel(`Item ${n + 1} key`, { exact: true }).fill(KEY);
|
||||
await editor.getByLabel(`Item ${n + 1} text`, { exact: true }).fill(TEXT);
|
||||
await editor.getByRole("button", { name: "Save checklist", exact: true }).click();
|
||||
await expect(editor.getByText(/^Checklist saved\./)).toBeVisible();
|
||||
await expect(editor).toContainText("Own list");
|
||||
const saved = await api(mia, "get", "/api/checklists/06");
|
||||
expect(saved.custom).toBe(true);
|
||||
expect(saved.editable).toBe(true);
|
||||
expect((saved.items as Json[]).map((i) => i.key)).toContain(KEY);
|
||||
expect((saved.items as Json[]).length).toBe(n + 1);
|
||||
|
||||
// sup.wes reads 06 but may not change it; his own list is 03
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const as03 = await api(wes, "get", "/api/checklists/06");
|
||||
expect(as03.editable).toBe(false);
|
||||
expect((as03.items as Json[]).map((i) => i.key)).toContain(KEY);
|
||||
const refused = await call(wes, "put", "/api/checklists/06", { items: [], base: saved.version });
|
||||
expect(refused.status).toBe(403);
|
||||
await wes.page.goto("/checklists");
|
||||
await expect(wes.page.locator("section").filter({ has: wes.page.getByRole("heading", { name: /^03 / }) })).toBeVisible();
|
||||
await expect(wes.page.getByRole("heading", { name: /^06 / })).toHaveCount(0);
|
||||
|
||||
// Q.C. and a lead: no edit, no screen
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
expect((await call(quinn, "put", "/api/checklists/03", { items: [], base: 0 })).status).toBe(403);
|
||||
const lou = await actor(browser, "lead.lou");
|
||||
expect((await api(lou, "get", "/api/checklists/03")).editable).toBe(false);
|
||||
await lou.page.goto("/checklists");
|
||||
await expect(lou.page.getByTestId("not-for-role")).toContainText("The Checklists screen is not for your role.");
|
||||
|
||||
// a stale save is refused with the current list
|
||||
const stale = await call(mia, "put", "/api/checklists/06", { items: saved.items, base: Number(saved.version) - 1 });
|
||||
expect(stale.status).toBe(409);
|
||||
|
||||
// back to the default so the other scenarios keep their lists
|
||||
await resetTo06Default(mia);
|
||||
expect((await api(mia, "get", "/api/checklists/06")).custom).toBe(false);
|
||||
await closeAll(mia, wes, quinn, lou);
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
atCompleteApi,
|
||||
cardOf,
|
||||
clickAndCatch,
|
||||
clickAndPrint,
|
||||
column,
|
||||
expectPdf,
|
||||
fetchOk,
|
||||
itpOf,
|
||||
makeCloseProject,
|
||||
openClericalHome,
|
||||
openPacket,
|
||||
@@ -206,18 +208,20 @@ test.describe.serial("close", () => {
|
||||
await sheet.getByRole("button", { name: L.cancel, exact: true }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
|
||||
// a row opens the print in a new tab and closes the sheet; the server logs it
|
||||
// a row closes the sheet and prints on the same page (C4): the stamped PDF is fetched and the print dialog
|
||||
// opens, no new tab; the server logs it
|
||||
const before = await printCount(clara, id, "traveler");
|
||||
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
|
||||
sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
|
||||
await expect(sheet).toBeVisible();
|
||||
await clickAndCatch(
|
||||
clara.ctx,
|
||||
await clickAndPrint(
|
||||
page,
|
||||
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
|
||||
`/api/packets/${id}/print/traveler`,
|
||||
);
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect(page).toHaveURL(new RegExp(`/packets/${id}$`));
|
||||
expect(clara.ctx.pages().length).toBe(1);
|
||||
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 1);
|
||||
|
||||
// the same print through the API: a PDF, and one more log row
|
||||
@@ -244,6 +248,33 @@ test.describe.serial("close", () => {
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("C3: an open ITP on the unit blocks filing until Q.C. signs it off", async ({ browser }) => {
|
||||
const id = pk("r1").id;
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const unitId = Number(proj.unit.id);
|
||||
const started = await api(quinn, "post", `/api/units/${unitId}/itp`, {});
|
||||
expect(started.status).not.toBe("complete");
|
||||
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
const file = box.getByRole("button", { name: L.fileIt, exact: true });
|
||||
await expect(file).toBeDisabled();
|
||||
await expect(box.getByText(L.itpOpen)).toBeVisible();
|
||||
expect((await packet(clara, id)).blocked.file).toContain(L.itpOpen);
|
||||
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
|
||||
expect(await statusOf(clara, id)).toBe("complete");
|
||||
|
||||
// signed off: File is enabled again
|
||||
await signedOffItpApi(quinn, unitId);
|
||||
await page.reload();
|
||||
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
|
||||
await expect(box.getByText(L.itpOpen)).toHaveCount(0);
|
||||
await closeAll(quinn, clara);
|
||||
});
|
||||
|
||||
test("R1: filed in the data book with the PIN; the seal holds and the filed data book downloads", async ({
|
||||
browser,
|
||||
}) => {
|
||||
@@ -326,12 +357,14 @@ test.describe.serial("close", () => {
|
||||
await closeAll(ann, clara);
|
||||
});
|
||||
|
||||
test("ITP: signed off through the API, its PDF is filed in the unit folder and linked on the ITP page", async ({
|
||||
test("ITP: signed off, its PDF is filed in the unit folder and linked on the ITP page", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const unitId = Number(proj.unit.id);
|
||||
const itp = await signedOffItpApi(quinn, unitId);
|
||||
// signed off in the C3 scenario above
|
||||
const itp = (await itpOf(quinn, unitId)) as Json;
|
||||
expect(itp.status).toBe("complete");
|
||||
const no = String(itp.no);
|
||||
expect(itp.signoff.asset_id).toBeTruthy();
|
||||
|
||||
@@ -356,6 +389,13 @@ test.describe.serial("close", () => {
|
||||
.first();
|
||||
const req = await clickAndCatch(quinn.ctx, quinn.page, pdfBtn, `/api/itps/${itp.id}/pdf`);
|
||||
await expectPdf(quinn, req.url());
|
||||
// Print goes to the print dialog on the same page (C4)
|
||||
await clickAndPrint(
|
||||
quinn.page,
|
||||
quinn.page.getByRole("button", { name: "Print", exact: true }),
|
||||
`/api/itps/${itp.id}/print`,
|
||||
);
|
||||
await expect(quinn.page).toHaveURL(new RegExp(`/qc/units/${unitId}/itp$`));
|
||||
const signed = quinn.page.getByRole("link", { name: L.signedPdf, exact: true });
|
||||
await expect(signed).toBeVisible();
|
||||
await expect(signed).toHaveAttribute("href", new RegExp(`/_blob/${itp.signoff.asset_id}`));
|
||||
|
||||
@@ -27,6 +27,8 @@ export const L = {
|
||||
sealUnknown: "Seal unknown",
|
||||
reopenedLine: /^Reopened by .+: .+\. The earlier seal is kept as broken\./,
|
||||
blocked: "Close the job in VISUAL first.",
|
||||
itpOpen: "Complete the unit's ITP first. Q.C. signs it off on the ITP page.", // C3
|
||||
shortsOpen: "Every short must be received and approved by the supervisor first.", // C7
|
||||
visualLine: "visual-line", // testid of the VISUAL observed line
|
||||
sealState: "seal-state", // testid of the seal line
|
||||
reopened: "reopened", // testid of the reopen line
|
||||
@@ -34,7 +36,6 @@ export const L = {
|
||||
jobClosed: "Job closed in VISUAL",
|
||||
moveBack: "Not closed in VISUAL, move back",
|
||||
fileIt: "File in the data book",
|
||||
fileAnyway: "File anyway",
|
||||
dataBookPdf: "Data book (PDF)",
|
||||
download: "Download",
|
||||
csv: "Traceability (CSV)",
|
||||
@@ -42,7 +43,6 @@ export const L = {
|
||||
reopenIt: "Reopen (IT)",
|
||||
// sheets
|
||||
printSheet: "Print",
|
||||
openWosSheet: "File with open work orders?",
|
||||
reopenSheet: "Reopen a filed packet",
|
||||
// print rows (each a button whose name starts with the row title)
|
||||
printRows: ["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Data book"],
|
||||
@@ -202,6 +202,40 @@ export async function clickAndCatch(ctx: BrowserContext, page: Page, target: Loc
|
||||
return { url: () => fixed, response };
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a print control and check it prints on the same page (change C4): the page fetches the stamped PDF (the URL
|
||||
* contains `part`), draws it and calls window.print, which is stubbed here. No tab is opened. Returns the PDF URL.
|
||||
*/
|
||||
export async function clickAndPrint(page: Page, target: Locator, part: string): Promise<string> {
|
||||
await expect(target).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__printed: number;
|
||||
__opened: string[];
|
||||
print: () => void;
|
||||
open: (u?: string | URL) => Window | null;
|
||||
};
|
||||
w.__printed = 0;
|
||||
w.__opened = [];
|
||||
w.print = () => {
|
||||
w.__printed += 1;
|
||||
};
|
||||
w.open = (u?: string | URL) => {
|
||||
w.__opened.push(String(u ?? ""));
|
||||
return null;
|
||||
};
|
||||
});
|
||||
const fetched = page.waitForResponse((r) => r.url().includes(part), { timeout: 30_000 });
|
||||
await target.click();
|
||||
const res = await fetched;
|
||||
expect(res.status(), `print fetch ${res.url()}`).toBe(200);
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as unknown as { __printed: number }).__printed), { timeout: 30_000 })
|
||||
.toBe(1);
|
||||
expect(await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).toEqual([]);
|
||||
return res.url();
|
||||
}
|
||||
|
||||
/** GET a URL through the actor's API context and check the status. */
|
||||
export async function fetchOk(a: Actor, url: string): Promise<APIResponse> {
|
||||
const r = await a.page.request.get(url);
|
||||
|
||||
@@ -180,4 +180,24 @@ test.describe.serial("project management", () => {
|
||||
await expect(pmTab(page, L.pm.tabs.hours)).toHaveAttribute("aria-selected", "true"); // ps.pmtab
|
||||
await closeAll(paula);
|
||||
});
|
||||
test("C10: the PM screen and /api/pm are for Project Management and Admin only", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
for (const a of [clara, wes]) {
|
||||
for (const url of ["/api/pm/overview", "/api/pm/hours", "/api/pm/export.csv", "/api/pm/report.pdf"]) {
|
||||
const r = await a.page.request.get(url);
|
||||
expect(r.status(), `${a.sam} ${url}`).toBe(403);
|
||||
}
|
||||
await a.page.goto(L.homes.pm);
|
||||
await expect(a.page.getByTestId("not-for-role")).toContainText(
|
||||
"The Project Management screen is not for your role.",
|
||||
);
|
||||
await expect(a.page.getByRole("heading", { level: 1, name: L.pm.head })).toHaveCount(0);
|
||||
}
|
||||
// Admin still reads it
|
||||
const ann = await actor(browser, "admin.ann");
|
||||
const r = await ann.page.request.get("/api/pm/overview");
|
||||
expect(r.status()).toBe(200);
|
||||
await closeAll(clara, wes, ann);
|
||||
});
|
||||
});
|
||||
|
||||
+122
-3
@@ -7,6 +7,8 @@ import {
|
||||
closeAll,
|
||||
packet,
|
||||
packetTab,
|
||||
assignApi,
|
||||
finishStageApi,
|
||||
pullAndRelease,
|
||||
setPin,
|
||||
sign,
|
||||
@@ -43,6 +45,8 @@ import {
|
||||
type QcProject,
|
||||
} from "./qchelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
import { L as CL, finishInspectionApi } from "./closehelpers";
|
||||
import { pullWithHeat } from "./pmhelpers";
|
||||
|
||||
// Phase 6 Q.C. scenarios on the CI stack, modelled on the old tests/ps titp, tqcf and tpdocs, plus the accept and
|
||||
// reject flows of the old qcTab. clerk.clara makes a project with two units; the crew (wh.wade, sup.wes,
|
||||
@@ -60,6 +64,8 @@ const T = {
|
||||
reject: `QC reject spool ${RUN}`,
|
||||
wp: `ITP weld spool ${RUN}`,
|
||||
fa: `ITP final spool ${RUN}`,
|
||||
ncr: `QC ncr spool ${RUN}`,
|
||||
short: `QC short spool ${RUN}`,
|
||||
};
|
||||
|
||||
let proj: QcProject;
|
||||
@@ -104,6 +110,20 @@ test.describe.serial("qc", () => {
|
||||
const rel = await stepApi(clara, fa.id, "release_to_shop", { dept: "11" });
|
||||
expect(rel.status).toBe("ready");
|
||||
P.fa = fa;
|
||||
// C2: a packet to reject without an NCR number
|
||||
P.ncr = await atQcApi(crew, proj.units[0], T.ncr, "Stall 4", "Bay 2");
|
||||
// C7: a packet with item 2 short; wh.wade receives it before the shop works it
|
||||
const sh = await makeQcDraft(clara, proj.units[0], T.short);
|
||||
await pullWithHeat(wade, sh.id, `HS${RUN}-`, [2]);
|
||||
const rec = await api(wade, "post", `/api/packets/${sh.id}/bom/2/short/receipts`, { qty: 2, heat: `HS${RUN}-R2` });
|
||||
expect(rec.shorts_unapproved).toBe(1);
|
||||
await assignApi(wes, sh.id, "Stall 4", andyId);
|
||||
await stepApi(andy, sh.id, "start");
|
||||
await finishStageApi(andy, sh.id, 2);
|
||||
await stepApi(andy, sh.id, "submit", { sign: sign("weld.andy") });
|
||||
const shq = await stepApi(wes, sh.id, "send_to_qc", { book: true, spool: true, location: "Bay 2", sign: sign("sup.wes") });
|
||||
expect(shq.status).toBe("qc");
|
||||
P.short = sh;
|
||||
await setPin(quinn, PIN);
|
||||
await closeAll(clara, wade, wes, andy, quinn);
|
||||
});
|
||||
@@ -184,7 +204,7 @@ test.describe.serial("qc", () => {
|
||||
await closeAll(quinn);
|
||||
});
|
||||
|
||||
test("reject: one weld rejected, an NCR number only, the password; the supervisor sends it again", async ({
|
||||
test("reject: one weld rejected, a reason and an NCR number, the password; the supervisor sends it again", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("reject").id;
|
||||
@@ -206,7 +226,7 @@ test.describe.serial("qc", () => {
|
||||
})
|
||||
.toContain("reject:undercut");
|
||||
|
||||
// the reject sheet: enabled on an NCR number alone
|
||||
// the reject sheet: an NCR number alone is not enough, the reason needs 8 characters (C2)
|
||||
const bar = qcBar(page);
|
||||
await bar.getByRole("button", { name: L.reject, exact: true }).click();
|
||||
const sheet = page.getByRole("dialog", { name: L.rejectSheet, exact: true });
|
||||
@@ -214,6 +234,12 @@ test.describe.serial("qc", () => {
|
||||
const submitReject = sheet.getByRole("button", { name: L.rejectSubmit });
|
||||
await expect(submitReject).toBeDisabled();
|
||||
await sheet.getByLabel(L.ncrNumber, { exact: true }).fill(NCR);
|
||||
await expect(submitReject).toBeDisabled();
|
||||
const reason = sheet.getByLabel(L.rejectReason, { exact: true });
|
||||
await reason.fill("Short");
|
||||
await expect(sheet.getByText(L.reasonShort)).toBeVisible();
|
||||
await expect(submitReject).toBeDisabled();
|
||||
await reason.fill("Undercut on W1, grind and reweld");
|
||||
await expect(submitReject).toBeEnabled();
|
||||
await submitReject.click();
|
||||
await signWithPassword(page, "qc.quinn");
|
||||
@@ -530,12 +556,105 @@ test.describe.serial("qc", () => {
|
||||
await closeAll(quinn, wes, ann);
|
||||
});
|
||||
|
||||
test("C2: a reject without an NCR number needs the number at the accept", async ({ browser }) => {
|
||||
const id = pk("ncr").id;
|
||||
const NCR2 = "NCR-2026-0042";
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
// rejected with a detailed reason and no NCR number; sup.wes sends it again; a fresh inspection is complete
|
||||
const rej = await stepApi(quinn, id, "qc_reject", { reason: "Porosity on W2, grind out and reweld", sign: sign("qc.quinn") });
|
||||
expect(rej.status).toBe("review");
|
||||
expect(rej.ncr_required).toBe(true);
|
||||
await stepApi(wes, id, "send_to_qc", { book: true, spool: true, location: "Bay 3", sign: sign("sup.wes") });
|
||||
await finishInspectionApi(quinn, id);
|
||||
expect((await packet(quinn, id)).ncr_required).toBe(true);
|
||||
// the API refuses the accept without the number (422)
|
||||
const r = await quinn.page.request.post(`/api/packets/${id}/transitions`, {
|
||||
headers: { Origin: new URL(quinn.page.url()).origin, "X-Requested-With": "PacketStation" },
|
||||
data: { action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", sign: sign("qc.quinn") },
|
||||
});
|
||||
expect(r.status()).toBe(422);
|
||||
expect(((await r.json()) as Json).detail).toBe(L.ncrFirst);
|
||||
|
||||
// the Q.C. bar asks for it; Accept waits for a well-formed number
|
||||
const page = quinn.page;
|
||||
await open(page, id);
|
||||
const bar = qcBar(page);
|
||||
await expect(bar.getByTestId("ncr-required")).toBeVisible();
|
||||
const acceptBtn = bar.getByRole("button", { name: L.accept, exact: true });
|
||||
const field = bar.getByLabel(L.ncrOfReject, { exact: true });
|
||||
await expect(acceptBtn).toBeDisabled();
|
||||
await field.fill("NCR-12");
|
||||
await expect(acceptBtn).toBeDisabled();
|
||||
await field.fill(NCR2);
|
||||
await expect(acceptBtn).toBeEnabled();
|
||||
await bar.getByLabel(L.route, { exact: true }).selectOption({ label: L.routeDataBook });
|
||||
await acceptBtn.click();
|
||||
await signWithPin(page, PIN);
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect.poll(() => statusOf(quinn, id)).toBe("complete");
|
||||
const after = await packet(quinn, id);
|
||||
expect(after.qc_last.ncr).toBe(NCR2);
|
||||
expect(after.ncr_required).toBe(false);
|
||||
await closeAll(quinn, wes);
|
||||
});
|
||||
|
||||
test("C7: a received short needs the supervisor's approval before Q.C. accepts", async ({ browser }) => {
|
||||
const id = pk("short").id;
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
await finishInspectionApi(quinn, id);
|
||||
let p = await packet(quinn, id);
|
||||
expect(p.shorts_unapproved).toBe(1);
|
||||
expect(p.blocked.qc_accept).toContain(CL.shortsOpen);
|
||||
await stepApi(quinn, id, "qc_accept", { routed_to: "databook", sign: sign("qc.quinn") }, 403);
|
||||
const receipt = Number(((p.shorts as Json[])[0].receipts as Json[])[0].id);
|
||||
const approveUrl = `/api/packets/${id}/shorts/receipts/${receipt}/approve`;
|
||||
|
||||
// the Q.C. bar says why Accept is off
|
||||
await open(quinn.page, id);
|
||||
const bar = qcBar(quinn.page);
|
||||
await expect(bar.getByText(CL.shortsOpen)).toBeVisible();
|
||||
await expect(bar.getByRole("button", { name: L.accept, exact: true })).toBeDisabled();
|
||||
|
||||
// Q.C. and a lead may not approve; the department supervisor does
|
||||
await api(quinn, "post", approveUrl, { sign: sign("qc.quinn") }, { status: 403 });
|
||||
const lou = await actor(browser, "lead.lou");
|
||||
await api(lou, "post", approveUrl, { sign: sign("lead.lou") }, { status: 403 });
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
await open(wes.page, id, L.tabMaterials);
|
||||
const receipts = wes.page.getByRole("group", { name: "Receipts for item 2" });
|
||||
await expect(receipts).toContainText(`heat HS${RUN}-R2`);
|
||||
await expect(receipts).toContainText(L.waitingApproval);
|
||||
await receipts.getByRole("button", { name: "Approve", exact: true }).click();
|
||||
await signWithPassword(wes.page, "sup.wes");
|
||||
await expect(signSheet(wes.page)).toBeHidden();
|
||||
await expect(receipts).toContainText("Approved");
|
||||
await expect(receipts).not.toContainText(L.waitingApproval);
|
||||
p = await packet(wes, id);
|
||||
expect(p.shorts_unapproved).toBe(0);
|
||||
// a second approval of the same receipt is refused
|
||||
await api(wes, "post", approveUrl, { sign: sign("sup.wes") }, { status: 409 });
|
||||
|
||||
// now Q.C. accepts
|
||||
await quinn.page.reload();
|
||||
const acceptBtn = qcBar(quinn.page).getByRole("button", { name: L.accept, exact: true });
|
||||
await expect(acceptBtn).toBeEnabled();
|
||||
await acceptBtn.click();
|
||||
await signWithPin(quinn.page, PIN);
|
||||
await expect(signSheet(quinn.page)).toBeHidden();
|
||||
await expect.poll(() => statusOf(quinn, id)).toBe("complete");
|
||||
await closeAll(quinn, lou, wes);
|
||||
});
|
||||
|
||||
test("api: the signature and the step body used here still answer as the contract says", async ({ browser }) => {
|
||||
// A guard for the helpers: a reject without a reason or an NCR is refused before any signature is asked.
|
||||
// A guard for the helpers: a reject without a reason is refused before any signature is asked, and an NCR
|
||||
// number does not stand in for the reason (C2).
|
||||
const quinn: Actor = await actor(browser, "qc.quinn");
|
||||
const p = await packet(quinn, pk("reject").id);
|
||||
expect(p.status).toBe("qc");
|
||||
await stepApi(quinn, p.id, "qc_reject", { sign: sign("qc.quinn") }, 400);
|
||||
await stepApi(quinn, p.id, "qc_reject", { ncr: NCR, sign: sign("qc.quinn") }, 400);
|
||||
await stepApi(quinn, p.id, "qc_reject", { reason: "Bad", ncr: NCR, sign: sign("qc.quinn") }, 400);
|
||||
await closeAll(quinn);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,11 @@ export const L = {
|
||||
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",
|
||||
|
||||
@@ -121,12 +121,16 @@ test.describe.serial("shop", () => {
|
||||
expect(assigned.welder.id).toBe(andyId);
|
||||
expect(assigned.welder.stamp).toBe("A7");
|
||||
|
||||
// weld.andy on the Stall screen
|
||||
// weld.andy on the Stall screen: no station picker (C11); his packets come from GET /api/stall/mine, each
|
||||
// with the station sup.wes picked
|
||||
const andy = await actor(browser, "weld.andy");
|
||||
const mineApi = await api(andy, "get", "/api/stall/mine");
|
||||
const mineCard = ((mineApi.mine ?? []) as Json[]).find((c) => Number(c.id) === id);
|
||||
expect(mineCard?.station_name).toBe("Stall 1");
|
||||
const stallMine = andy.page.waitForResponse((r) => r.url().includes("/api/stall/mine"));
|
||||
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") });
|
||||
expect((await stallMine).status()).toBe(200);
|
||||
await expect(andy.page.getByRole("combobox", { name: L.stallPicker })).toHaveCount(0);
|
||||
const myHead = andy.page.getByRole("heading", {
|
||||
name: new RegExp(`^${L.myPackets}`),
|
||||
});
|
||||
@@ -642,3 +646,16 @@ test.describe.serial("shop", () => {
|
||||
await closeAll(wes);
|
||||
});
|
||||
});
|
||||
|
||||
test("C11: a person with nothing assigned sees that the supervisor assigns packets", async ({ browser }) => {
|
||||
// both.bo (03 worker and supervisor) has no packet assigned to him in these scenarios
|
||||
const bo = await actor(browser, "both.bo");
|
||||
const mine = await api(bo, "get", "/api/stall/mine");
|
||||
expect(mine.mine).toEqual([]);
|
||||
expect(mine.submitted).toEqual([]);
|
||||
expect(String(mine.empty)).toContain("Your supervisor assigns packets to you.");
|
||||
await bo.page.goto("/stall");
|
||||
await expect(bo.page.getByText("Your supervisor assigns packets to you.").first()).toBeVisible();
|
||||
await expect(bo.page.getByRole("combobox", { name: L.stallPicker })).toHaveCount(0);
|
||||
await closeAll(bo);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user