Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target, Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as "report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates, search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and iPad viewports. Docs updated; PLAN.md questions 38 to 42. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -17,5 +17,13 @@ export default defineConfig({
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
// Phase 8: the PM, find bar and presence scenarios again at iPad landscape size, with touch.
|
||||
{
|
||||
name: "ipad",
|
||||
testMatch: /(pm|findbar|presence)\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"], viewport: { width: 1024, height: 768 }, isMobile: false, hasTouch: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { signInPassword } from "./helpers";
|
||||
import { actor, api, closeAll, stepApi } from "./shophelpers";
|
||||
import {
|
||||
L,
|
||||
cardAnimations,
|
||||
cardOf,
|
||||
chip,
|
||||
expectShowing,
|
||||
filtersButton,
|
||||
fltSelect,
|
||||
jobList,
|
||||
listIds,
|
||||
lsGet,
|
||||
makeDraft,
|
||||
makeProject,
|
||||
makeUnit,
|
||||
personId,
|
||||
pickProject,
|
||||
pullWithHeat,
|
||||
runSuffix,
|
||||
searchBox,
|
||||
viewButton,
|
||||
type Made,
|
||||
type Unit,
|
||||
} from "./pmhelpers";
|
||||
|
||||
// Phase 8 find bar, list view, KPI taps, Back, sheets and motion on the CI stack, modelled on the old tests/ps
|
||||
// tflt, tlist, tlay2, tnav, tpop and tmotion. clerk.clara makes a project with lots 052, 053 and 054 and four packets
|
||||
// through the API; wh.wade pulls them (F2 with its own heat, F3 with line 3 short) and sup.wes assigns F1 to
|
||||
// weld.andy at Stall 1 (started) and F2 to dup.dave at Stall 2. F4 goes to department 11. sup.wes then works the
|
||||
// Supervisor home with the project picked, so the counts are this run's packets only. A second project holds 30
|
||||
// bare drafts for the Back test on the Clerical list. Every UI label used here is in L (pmhelpers.ts).
|
||||
|
||||
let RUN = "";
|
||||
let PROJECT = "";
|
||||
let NAV = "";
|
||||
const F: Record<"f1" | "f2" | "f3" | "f4", Made> = {} as Record<"f1" | "f2" | "f3" | "f4", Made>;
|
||||
let andyId = "";
|
||||
|
||||
async function supervisorHome(page: Page): Promise<void> {
|
||||
await page.goto(L.homes.supervisor);
|
||||
await expect(searchBox(page)).toBeVisible();
|
||||
}
|
||||
|
||||
async function search(page: Page, q: string): Promise<void> {
|
||||
await searchBox(page).fill(q);
|
||||
}
|
||||
|
||||
test.describe.serial("find bar", () => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
test("clerk.clara, wh.wade and sup.wes make the find bar packets", async ({ browser }, info) => {
|
||||
RUN = runSuffix(info.project.name);
|
||||
PROJECT = `FB${RUN}`;
|
||||
NAV = `NV${RUN}`;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const andy = await actor(browser, "weld.andy");
|
||||
// dup.dave exists in the app only once he has signed in (the AD refresh runs later, in signin.spec.ts)
|
||||
const dave = await actor(browser, "dup.dave");
|
||||
const proj = await makeProject(clara, PROJECT, { name: "Find bar job", customer: "CI Gas" });
|
||||
const lots: Unit[] = [];
|
||||
for (const lot of ["052", "053", "054", "055"]) lots.push(await makeUnit(clara, Number(proj.id), lot));
|
||||
const [u52, u53, u54, u55] = lots as [Unit, Unit, Unit, Unit];
|
||||
F.f1 = await makeDraft(clara, u52, `FB one ${RUN}`, { send: "warehouse", wo: `5Z${RUN}` });
|
||||
F.f2 = await makeDraft(clara, u53, `FB two ${RUN}`, { send: "warehouse", customer: `Zephyr ${RUN}` });
|
||||
F.f3 = await makeDraft(clara, u54, `FB three ${RUN}`, { send: "warehouse" });
|
||||
F.f4 = await makeDraft(clara, u55, `FB four ${RUN}`, { send: "11" });
|
||||
await pullWithHeat(wade, F.f1.id, `HA${RUN}-`);
|
||||
await pullWithHeat(wade, F.f2.id, `HX${RUN}-`);
|
||||
await pullWithHeat(wade, F.f3.id, `HC${RUN}-`, [3]);
|
||||
andyId = await personId(wes, "weld.andy");
|
||||
const daveId = await personId(wes, "dup.dave");
|
||||
const stalls = (await api(wes, "get", "/api/shop/stations")) as unknown as Array<Record<string, unknown>>;
|
||||
const stall = (name: string): number =>
|
||||
Number(stalls.find((s) => s.dept_code === "03" && s.name === name)?.id ?? 0);
|
||||
await stepApi(wes, F.f1.id, "assign", { station_id: stall("Stall 1"), welder_id: andyId });
|
||||
await stepApi(andy, F.f1.id, "start");
|
||||
await stepApi(wes, F.f2.id, "assign", { station_id: stall("Stall 2"), welder_id: daveId });
|
||||
// 30 bare drafts for the Back test
|
||||
const nav = await makeProject(clara, NAV, { name: "Back test" });
|
||||
const nu = await makeUnit(clara, Number(nav.id), "001");
|
||||
for (let i = 0; i < 30; i++) await makeDraft(clara, nu, `Nav ${String(i).padStart(2, "0")} ${RUN}`);
|
||||
await closeAll(clara, wade, wes, andy, dave);
|
||||
});
|
||||
|
||||
test("tflt: search, Esc, status and station, clear, the shorts chip", async ({ browser }) => {
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const page = wes.page;
|
||||
await page.evaluate(() => window.localStorage.removeItem("ps.flt"));
|
||||
await supervisorHome(page);
|
||||
await pickProject(page, PROJECT);
|
||||
await expectShowing(page, 3); // F4 is in department 11
|
||||
|
||||
// a heat number, typed: the focus and the text stay
|
||||
await searchBox(page).click();
|
||||
await page.keyboard.type(`hx${RUN.toLowerCase()}`, { delay: 30 });
|
||||
await expectShowing(page, 1);
|
||||
await expect(cardOf(page, F.f2)).toBeVisible();
|
||||
await expect(searchBox(page)).toBeFocused();
|
||||
await expect(searchBox(page)).toHaveValue(`hx${RUN.toLowerCase()}`);
|
||||
// Esc clears and keeps the focus
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(searchBox(page)).toHaveValue("");
|
||||
await expect(searchBox(page)).toBeFocused();
|
||||
await expectShowing(page, 3);
|
||||
// a work order, a welder, a lot, a customer
|
||||
await search(page, `5Z${RUN}`);
|
||||
await expectShowing(page, 1);
|
||||
await expect(cardOf(page, F.f1)).toBeVisible();
|
||||
await search(page, "andy");
|
||||
await expectShowing(page, 1);
|
||||
await search(page, "lot 54");
|
||||
await expectShowing(page, 1);
|
||||
await expect(cardOf(page, F.f3)).toBeVisible();
|
||||
await search(page, `zephyr ${RUN}`);
|
||||
await expectShowing(page, 1);
|
||||
await expect(cardOf(page, F.f2)).toBeVisible();
|
||||
await search(page, "");
|
||||
await expectShowing(page, 3);
|
||||
|
||||
// Filters: status "At a station", then the station dropdown
|
||||
await filtersButton(page).click();
|
||||
await expect(filtersButton(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await fltSelect(page, L.find.status).selectOption({ label: L.find.statusAtStation });
|
||||
await expectShowing(page, 2);
|
||||
await fltSelect(page, L.find.station).selectOption({ label: "Stall 2" });
|
||||
await expectShowing(page, 1);
|
||||
await expect(cardOf(page, F.f2)).toBeVisible();
|
||||
// Clear filters
|
||||
await page.getByRole("button", { name: L.find.clear }).click();
|
||||
// Clear filters drops the project pick too, as the old build did; pick it again for the next step.
|
||||
await pickProject(page, PROJECT);
|
||||
await expectShowing(page, 3);
|
||||
// the shorts chip
|
||||
await chip(page, L.find.chips.short).click();
|
||||
await expect(chip(page, L.find.chips.short)).toHaveAttribute("aria-pressed", "true");
|
||||
await expectShowing(page, 1);
|
||||
await expect(cardOf(page, F.f3)).toBeVisible();
|
||||
await page.getByRole("button", { name: L.find.clear }).click();
|
||||
// Clear filters drops the project pick too, as the old build did; pick it again for the next step.
|
||||
await pickProject(page, PROJECT);
|
||||
await expectShowing(page, 3);
|
||||
await closeAll(wes);
|
||||
});
|
||||
|
||||
test("tflt and tlist: the list view, sort, Enter opens, saved filters, only the screen's packets", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const page = wes.page;
|
||||
await supervisorHome(page);
|
||||
await pickProject(page, PROJECT);
|
||||
await viewButton(page, "List").click();
|
||||
await expect(viewButton(page, "List")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(jobList(page)).toBeVisible();
|
||||
await expect.poll(async () => (await listIds(page)).length).toBe(3);
|
||||
// the 11 packet is in the project but not on the 03 supervisor's boards, so not in the list
|
||||
expect(await listIds(page)).not.toContain(F.f4.id);
|
||||
await page.getByLabel(L.find.sortBy).selectOption({ label: L.find.sortProject });
|
||||
await expect.poll(() => listIds(page)).toEqual([F.f1.id, F.f2.id, F.f3.id]);
|
||||
// the header and the KPIs stay in the list view
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: new RegExp(L.kpi.ready) }).first()).toBeVisible();
|
||||
// saved: the view and the sort, never the search text
|
||||
await search(page, "zzz-not-saved");
|
||||
await expect(page.getByText(L.find.empty)).toBeVisible();
|
||||
await search(page, "");
|
||||
const saved = JSON.parse((await lsGet(page, "ps.flt")) ?? "{}") as Record<string, unknown>;
|
||||
expect((saved.views as Record<string, string>).supervisor).toBe("list");
|
||||
expect(saved.sort).toBe("project");
|
||||
expect("q" in saved).toBe(false);
|
||||
// Enter opens a row
|
||||
const row = jobList(page).locator(`tbody tr[data-card-id="${F.f2.id}"]`);
|
||||
await row.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`/packets/${F.f2.id}$`));
|
||||
await page.goBack();
|
||||
await expect(jobList(page)).toBeVisible(); // the view came back from ps.flt
|
||||
await viewButton(page, "Board").click();
|
||||
await expect(cardOf(page, F.f1)).toBeVisible();
|
||||
await closeAll(wes);
|
||||
});
|
||||
|
||||
test("tlay2: a KPI tap filters the board and the KPIs stay; Filters opens and closes", async ({ browser }) => {
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const page = wes.page;
|
||||
await supervisorHome(page);
|
||||
await pickProject(page, PROJECT);
|
||||
await expectShowing(page, 3);
|
||||
const tile = page.getByRole("button", { name: new RegExp(L.kpi.atStations) }).first();
|
||||
// The tiles count up under motion, so read their labels (the final numbers) rather than the drawn text.
|
||||
const kpiText = async (): Promise<string> =>
|
||||
(await page.locator(".kpis").first().locator("[aria-label]").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label")))).join(" | ");
|
||||
// The numbers follow the project pick through their own request; wait for them before taking the snapshot.
|
||||
await expect(tile).toHaveAccessibleName(/At stations: 2$/);
|
||||
const before = await kpiText();
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-pressed", "true");
|
||||
await expectShowing(page, 2);
|
||||
expect(await kpiText()).toBe(before);
|
||||
await expect(fltSelect(page, L.find.status)).toBeVisible(); // the tap opened the filters
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-pressed", "false");
|
||||
await expectShowing(page, 3);
|
||||
// Filters opens and closes
|
||||
const btn = filtersButton(page);
|
||||
if ((await btn.getAttribute("aria-expanded")) === "true") await btn.click();
|
||||
await expect(fltSelect(page, L.find.dept)).toHaveCount(0);
|
||||
await btn.click();
|
||||
await expect(fltSelect(page, L.find.dept)).toBeVisible();
|
||||
await btn.click();
|
||||
await expect(fltSelect(page, L.find.dept)).toHaveCount(0);
|
||||
await closeAll(wes);
|
||||
});
|
||||
|
||||
test("tnav: Back from a packet lands on the home at the same scroll spot", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await page.setViewportSize({ width: 1280, height: 600 });
|
||||
await page.goto(L.homes.clerical);
|
||||
await expect(searchBox(page)).toBeVisible();
|
||||
await pickProject(page, NAV);
|
||||
await viewButton(page, "List").click();
|
||||
await expect.poll(async () => (await listIds(page)).length).toBeGreaterThanOrEqual(30);
|
||||
const target = await page.evaluate(() => Math.min(900, document.documentElement.scrollHeight - window.innerHeight));
|
||||
expect(target).toBeGreaterThan(300);
|
||||
await page.evaluate((y) => window.scrollTo(0, y), target);
|
||||
await expect.poll(() => page.evaluate(() => Math.round(window.scrollY))).toBe(target);
|
||||
// a row fully on screen
|
||||
const id = await page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("table.joblist tbody tr[data-card-id]")];
|
||||
const r = rows.find((e) => {
|
||||
const b = e.getBoundingClientRect();
|
||||
return b.top > 120 && b.bottom < window.innerHeight - 20;
|
||||
});
|
||||
return r ? Number(r.getAttribute("data-card-id")) : 0;
|
||||
});
|
||||
expect(id).toBeGreaterThan(0);
|
||||
await page.locator(`table.joblist tbody tr[data-card-id="${id}"]`).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/packets/${id}$`));
|
||||
await page.getByRole("button", { name: L.back }).or(page.getByRole("link", { name: L.back })).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`${L.homes.clerical}$`));
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Math.round(window.scrollY)), { timeout: 5_000 })
|
||||
.toBeGreaterThan(target - 40);
|
||||
expect(await page.evaluate(() => Math.round(window.scrollY))).toBeLessThan(target + 40);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("tpop: the New packet sheet keeps its margins on desktop and phone widths", async ({ browser }) => {
|
||||
for (const vp of [
|
||||
{ width: 1280, height: 800 },
|
||||
{ width: 390, height: 740 },
|
||||
]) {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await page.setViewportSize(vp);
|
||||
await page.goto(L.homes.clerical);
|
||||
await page.getByRole("button", { name: L.newPacket }).first().click();
|
||||
const sheet = page.getByRole("dialog").first();
|
||||
await expect(sheet).toBeVisible();
|
||||
await page.waitForTimeout(400); // the sheet's slide in
|
||||
const m = await sheet.evaluate((el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
top: Math.round(b.top),
|
||||
bottom: Math.round(window.innerHeight - b.bottom),
|
||||
left: Math.round(b.left),
|
||||
right: Math.round(window.innerWidth - b.right),
|
||||
tall: el.scrollHeight > el.clientHeight + 1,
|
||||
scrolls: cs.overflowY === "auto" || cs.overflowY === "scroll",
|
||||
};
|
||||
});
|
||||
expect(m.top, JSON.stringify(m)).toBeGreaterThanOrEqual(8);
|
||||
expect(m.left, JSON.stringify(m)).toBeGreaterThanOrEqual(8);
|
||||
expect(m.right, JSON.stringify(m)).toBeGreaterThanOrEqual(8);
|
||||
if (vp.width > 700) expect(m.bottom, JSON.stringify(m)).toBeGreaterThanOrEqual(8);
|
||||
else expect(m.bottom, JSON.stringify(m)).toBeGreaterThanOrEqual(0); // the phone sheet sits on the bottom edge
|
||||
if (m.tall) expect(m.scrolls, "a tall sheet scrolls inside").toBe(true);
|
||||
await closeAll(clara);
|
||||
}
|
||||
});
|
||||
|
||||
test("tmotion: the switch, reduced motion, and a card moved by someone else glides", async ({ browser }) => {
|
||||
// reduced motion always wins
|
||||
const calm = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const calmPage = await calm.newPage();
|
||||
await signInPassword(calmPage, "sup.wes");
|
||||
await calmPage.goto(L.homes.supervisor);
|
||||
await expect(searchBox(calmPage)).toBeVisible();
|
||||
expect(await calmPage.evaluate(() => document.body.classList.contains("motion"))).toBe(false);
|
||||
await calm.close();
|
||||
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const page = wes.page;
|
||||
await page.evaluate(() => window.localStorage.removeItem("ps.motion"));
|
||||
await supervisorHome(page);
|
||||
await expect.poll(() => page.evaluate(() => document.body.classList.contains("motion"))).toBe(true);
|
||||
const btn = page.getByRole("banner").getByRole("button", { name: new RegExp(`^(${L.motionOn}|${L.motionOff})$`) });
|
||||
await expect(btn).toHaveText(L.motionOn);
|
||||
await btn.click();
|
||||
await expect(btn).toHaveText(L.motionOff);
|
||||
await expect(btn).toHaveAttribute("aria-pressed", "false");
|
||||
expect(await page.evaluate(() => document.body.classList.contains("motion"))).toBe(false);
|
||||
expect(await lsGet(page, "ps.motion")).toBe("off");
|
||||
await btn.click();
|
||||
await expect(btn).toHaveText(L.motionOn);
|
||||
expect(await page.evaluate(() => document.body.classList.contains("motion"))).toBe(true);
|
||||
expect(await lsGet(page, "ps.motion")).not.toBe("off");
|
||||
|
||||
// at rest: no card animates on a plain refresh
|
||||
await pickProject(page, PROJECT);
|
||||
await expect(cardOf(page, F.f3)).toBeVisible();
|
||||
await page.waitForTimeout(2_000);
|
||||
expect(await cardAnimations(page)).toBe(0);
|
||||
// lead.lou assigns F3 through the API: the card moves to Stall 3 on wes's board and glides
|
||||
const lou = await actor(browser, "lead.lou");
|
||||
const stalls = (await api(lou, "get", "/api/shop/stations")) as unknown as Array<Record<string, unknown>>;
|
||||
const stall3 = Number(stalls.find((s) => s.dept_code === "03" && s.name === "Stall 3")?.id ?? 0);
|
||||
await stepApi(lou, F.f3.id, "assign", { station_id: stall3, welder_id: andyId });
|
||||
await expect.poll(() => cardAnimations(page), { timeout: 10_000, intervals: [50] }).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(2_000);
|
||||
expect(await cardAnimations(page)).toBe(0);
|
||||
await closeAll(wes, lou);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { actor, api, closeAll, packet, stepApi } from "./shophelpers";
|
||||
import {
|
||||
L,
|
||||
catchOpen,
|
||||
downloadText,
|
||||
isoDay,
|
||||
makeDraft,
|
||||
makeProject,
|
||||
makeUnit,
|
||||
personId,
|
||||
pickProject,
|
||||
pmDataRows,
|
||||
pmTab,
|
||||
pmTable,
|
||||
pullWithHeat,
|
||||
runSuffix,
|
||||
unpickProject,
|
||||
type Made,
|
||||
type Unit,
|
||||
} from "./pmhelpers";
|
||||
|
||||
// Phase 8 Project Management on the CI stack, modelled on the old tests/ps tpm. clerk.clara makes a project with
|
||||
// two units (one due yesterday, one in four days) and three packets through the API: K1 put on hold by wh.wade,
|
||||
// K2 assigned to weld.andy and started (in progress), K3 Ready to assign. pm.paula signs in and lands on /pm: the
|
||||
// KPI tiles and the plain-words summary; the projects tab opens the project to its units and a unit drills to Find
|
||||
// jobs; the aging, hours and quality tabs have rows; the scope follows the find bar's project pick; Excel (CSV)
|
||||
// — all downloads a CSV whose first line names the report; PDF report answers a PDF; a packet shows View only and
|
||||
// no enabled write control; the API refuses her a write. Every UI label used here is in L (pmhelpers.ts).
|
||||
|
||||
let RUN = "";
|
||||
let PROJECT = "";
|
||||
let units: Unit[] = [];
|
||||
const K: Record<"k1" | "k2" | "k3", Made> = {} as Record<"k1" | "k2" | "k3", Made>;
|
||||
|
||||
test.describe.serial("project management", () => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
test("clerk.clara, wh.wade and weld.andy make the PM packets", async ({ browser }, info) => {
|
||||
RUN = runSuffix(info.project.name);
|
||||
PROJECT = `PM${RUN}`;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const andy = await actor(browser, "weld.andy");
|
||||
const proj = await makeProject(clara, PROJECT, { name: "PM pump skids", customer: "CI Gas", due: isoDay(20) });
|
||||
units = [
|
||||
await makeUnit(clara, Number(proj.id), "001", isoDay(-1)),
|
||||
await makeUnit(clara, Number(proj.id), "002", isoDay(4)),
|
||||
];
|
||||
const [u1, u2] = units as [Unit, Unit];
|
||||
K.k1 = await makeDraft(clara, u1, `PM held ${RUN}`, { send: "warehouse" });
|
||||
await api(wade, "post", `/api/packets/${K.k1.id}/wh`, {
|
||||
stage: "hold",
|
||||
reason: "Waiting on purchasing / PO",
|
||||
note: "PO 4471",
|
||||
expect_stage: "incoming",
|
||||
});
|
||||
K.k2 = await makeDraft(clara, u1, `PM running ${RUN}`, { send: "warehouse" });
|
||||
await pullWithHeat(wade, K.k2.id, `HP${RUN}-`);
|
||||
const andyId = await personId(wes, "weld.andy");
|
||||
const stalls = (await api(wes, "get", "/api/shop/stations")) as unknown as Array<Record<string, unknown>>;
|
||||
const stall2 = stalls.find((s) => s.dept_code === "03" && s.name === "Stall 2");
|
||||
expect(stall2).toBeTruthy();
|
||||
await stepApi(wes, K.k2.id, "assign", { station_id: Number(stall2?.id), welder_id: andyId });
|
||||
await stepApi(andy, K.k2.id, "start");
|
||||
K.k3 = await makeDraft(clara, u2, `PM ready ${RUN}`, { send: "03" });
|
||||
expect((await packet(wes, K.k2.id)).status).toBe("in_progress");
|
||||
await closeAll(clara, wade, wes, andy);
|
||||
});
|
||||
|
||||
test("tpm: overview, projects, drill down, tabs, scope, exports, view only", async ({ browser }) => {
|
||||
const paula = await actor(browser, "pm.paula");
|
||||
const page = paula.page;
|
||||
|
||||
// 1. lands on the PM screen: KPIs and the summary
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(new RegExp(`${L.homes.pm}$`));
|
||||
await expect(page.getByText(L.pm.eyebrow, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { level: 1, name: L.pm.head })).toBeVisible();
|
||||
for (const label of L.pm.kpis) {
|
||||
await expect(page.getByRole("button", { name: label }).first()).toBeVisible();
|
||||
}
|
||||
const sum = page.locator(".pmsum").or(page.getByTestId("pm-summary")).first();
|
||||
await expect(sum.getByRole("heading", { name: L.pm.plainWords })).toBeVisible();
|
||||
await expect(sum.getByRole("listitem").first()).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: L.pm.pipeline, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("img", { name: L.pm.bars })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: L.pm.attention })).toBeVisible();
|
||||
await expect(pmTab(page, L.pm.tabs.overview)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByText(L.pm.scope).first()).toBeVisible();
|
||||
|
||||
// 2. the projects tab: open the project to its units
|
||||
await pmTab(page, L.pm.tabs.projects).click();
|
||||
const projects = pmTable(page, L.pm.titles.projects);
|
||||
await expect(projects).toBeVisible();
|
||||
const projRow = projects.getByRole("row").filter({ hasText: PROJECT }).first();
|
||||
await expect(projRow).toBeVisible();
|
||||
const unit1 = projects.getByRole("row").filter({ hasText: `${PROJECT}001` });
|
||||
if (!(await unit1.count())) await projRow.click();
|
||||
await expect(unit1.first()).toBeVisible();
|
||||
await expect(projects.getByRole("row").filter({ hasText: `${PROJECT}002` }).first()).toBeVisible();
|
||||
await expect(unit1.first()).toContainText(L.pm.health.late);
|
||||
|
||||
// 3. a unit drills to Find jobs, scoped to that unit
|
||||
await unit1.first().click();
|
||||
await expect(pmTab(page, L.pm.tabs.find)).toHaveAttribute("aria-selected", "true");
|
||||
const list = page.getByRole("table", { name: L.find.listHead, exact: true });
|
||||
await expect(list.getByRole("row").filter({ hasText: K.k1.title }).first()).toBeVisible();
|
||||
await expect(list.getByRole("row").filter({ hasText: K.k2.title }).first()).toBeVisible();
|
||||
await expect(list.getByRole("row").filter({ hasText: K.k3.title })).toHaveCount(0);
|
||||
await expect(page.getByTestId("pm-scope").or(page.locator(".pmscope")).first()).toContainText(
|
||||
`Unit ${PROJECT}001`,
|
||||
);
|
||||
|
||||
// 4. aging, hours and quality have rows
|
||||
await unpickProject(page);
|
||||
for (const t of [L.pm.tabs.aging, L.pm.tabs.hours, L.pm.tabs.quality]) {
|
||||
await pmTab(page, t).click();
|
||||
await expect(pmTab(page, t)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator("section.pmt").first()).toBeVisible();
|
||||
await expect.poll(() => pmDataRows(page).count(), { message: t }).toBeGreaterThan(0);
|
||||
}
|
||||
await expect(pmTable(page, L.pm.titles.qualityHolds).getByRole("row").filter({ hasText: PROJECT })).not.toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// 5. the scope follows the find bar
|
||||
await pickProject(page, PROJECT);
|
||||
await pmTab(page, L.pm.tabs.overview).click();
|
||||
const scope = page.getByTestId("pm-scope").or(page.locator(".pmscope")).first();
|
||||
await expect(scope).toContainText(`Project ${PROJECT}`);
|
||||
await expect(scope).toContainText("view only");
|
||||
await expect(page.getByTestId("pm-kpi-holds").or(page.getByRole("button", { name: /Warehouse holds/ })).first()).toContainText("1");
|
||||
|
||||
// 6. exports: the CSV of every table downloads; the PDF answers a PDF
|
||||
const [dl] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: L.pm.csvAll, exact: true }).click(),
|
||||
]);
|
||||
expect(dl.suggestedFilename()).toMatch(/^PacketStation_All_reports_\d{4}-\d{2}-\d{2}\.csv$/);
|
||||
const text = await downloadText(dl);
|
||||
expect(text.split(/\r\n/)[0]).toMatch(L.pm.csvFirst);
|
||||
expect(text).toContain(`Project ${PROJECT}`);
|
||||
const url = await catchOpen(page, page.getByRole("button", { name: L.pm.pdf, exact: true }));
|
||||
expect(url).toContain("/api/pm/report.pdf");
|
||||
const r = await page.request.get(url);
|
||||
expect(r.status()).toBe(200);
|
||||
expect(r.headers()["content-type"]).toContain("application/pdf");
|
||||
expect((await r.body()).subarray(0, 4).toString("latin1")).toBe("%PDF");
|
||||
|
||||
// 7. read only: View only and no enabled write control; the API refuses a write
|
||||
await page.goto(`/packets/${K.k2.id}`);
|
||||
await expect(page.getByText(L.pm.viewOnly).first()).toBeVisible();
|
||||
const writes = page
|
||||
.getByRole("button", {
|
||||
name: /^(Pause|Resume|Start work|Submit to supervisor|Send to station|Send to Q\.C\.|Mark a weld|Return to station|Pull back to queue)$/,
|
||||
})
|
||||
.and(page.locator(":enabled"));
|
||||
await expect(writes).toHaveCount(0);
|
||||
await expect(page.locator("main input:enabled[type=checkbox]")).toHaveCount(0);
|
||||
await stepApi(paula, K.k2.id, "submit", {}, 403);
|
||||
await api(paula, "post", `/api/pm/overview`, {}, { status: 405 });
|
||||
await closeAll(paula);
|
||||
});
|
||||
|
||||
test("tpm: the period changes the history figures", async ({ browser }) => {
|
||||
const paula = await actor(browser, "pm.paula");
|
||||
const page = paula.page;
|
||||
await page.goto(L.homes.pm);
|
||||
const period = page.getByLabel(L.pm.period).or(page.getByTitle("Period for history-based figures")).first();
|
||||
await period.selectOption({ label: L.pm.periods["365"] });
|
||||
await expect(page.getByRole("button", { name: /^Q\.C\. rejects · last 12 months/ }).first()).toBeVisible();
|
||||
expect(await page.evaluate(() => window.localStorage.getItem("ps.pmper"))).toBe("365");
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: /^Filed · last 12 months/ }).first()).toBeVisible();
|
||||
await period.selectOption({ label: L.pm.periods["90"] });
|
||||
await pmTab(page, L.pm.tabs.hours).click();
|
||||
await page.reload();
|
||||
await expect(pmTab(page, L.pm.tabs.hours)).toHaveAttribute("aria-selected", "true"); // ps.pmtab
|
||||
await closeAll(paula);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, type Download, type Locator, type Page } from "@playwright/test";
|
||||
import { dcUser } from "./helpers";
|
||||
import { api, sign, stepApi, type Actor } from "./shophelpers";
|
||||
import { getJson, onePagePdf, writeHeaders, type Json } from "./whhelpers";
|
||||
|
||||
// Helpers for the phase 8 scenarios (Project Management, the find bar, presence, motion, Back and the sheets):
|
||||
// projects, units and packets made through the API, the find bar and PM locators, and the UI labels the specs
|
||||
// depend on (L). The labels are the texts of the phase 8 design contract ("Labels for the Playwright specs" and the
|
||||
// vocabulary); the main session adjusts them here in one place.
|
||||
|
||||
export const L = {
|
||||
// Project Management
|
||||
pm: {
|
||||
eyebrow: "Project management · Status and reporting",
|
||||
head: "Where every job stands",
|
||||
tabs: {
|
||||
overview: "Overview",
|
||||
projects: "Projects & units",
|
||||
aging: "Bottlenecks & aging",
|
||||
hours: "Hours vs target",
|
||||
quality: "Quality & issues",
|
||||
find: "Find jobs",
|
||||
},
|
||||
pdf: "PDF report",
|
||||
csvAll: "Excel (CSV) — all",
|
||||
csv: "Excel (CSV)",
|
||||
details: "Details",
|
||||
kpis: [
|
||||
"Open packets",
|
||||
"Late units",
|
||||
"Units at risk",
|
||||
"Warehouse holds",
|
||||
"Open change orders",
|
||||
"Open shorts",
|
||||
/Q\.C\. rejects · /,
|
||||
"Packets over target hours",
|
||||
/Filed · /,
|
||||
"Average lead time, created → filed",
|
||||
] as (string | RegExp)[],
|
||||
plainWords: "In plain words",
|
||||
pipeline: "Pipeline",
|
||||
bars: "Open packets by step",
|
||||
attention: "Units late or at risk",
|
||||
scope: "Scope:",
|
||||
nothing: "Nothing in scope.",
|
||||
allOnTrack: "Every unit with packets is on track.",
|
||||
period: "Period",
|
||||
periods: { "30": "Last 30 days", "90": "Last 90 days", "365": "Last 12 months", all: "All time" },
|
||||
health: { ok: "On track", risk: "At risk", late: "Late", done: "Complete", empty: "No packets yet" },
|
||||
viewOnly: "View only.",
|
||||
titles: {
|
||||
projects: "Projects and units",
|
||||
agingSteps: "Time in the current step (open packets)",
|
||||
agingOpen: "Every open packet, longest waiting first",
|
||||
hoursDept: "By department",
|
||||
hoursOver: "Packets over target",
|
||||
qualityHolds: "Warehouse holds",
|
||||
qualityShorts: "Open shorts",
|
||||
},
|
||||
csvFirst: /^PESCO Packet Station — /,
|
||||
},
|
||||
// the find bar
|
||||
find: {
|
||||
bar: "findbar", // data-testid of the bar (a region named "Find jobs")
|
||||
search: "Find a job",
|
||||
project: "Project", // the project search box of PickUnit
|
||||
projectsFound: "Projects found",
|
||||
change: "Change",
|
||||
unit: "Unit",
|
||||
filters: "Filters",
|
||||
board: "Board",
|
||||
list: "List",
|
||||
dept: "Department",
|
||||
status: "Status",
|
||||
station: "Station",
|
||||
worker: "Worker",
|
||||
flag: "Flag",
|
||||
statusAtStation: "At a station (assigned / working)",
|
||||
chips: {
|
||||
co: "Change order open",
|
||||
short: "Shorts open",
|
||||
overdue: "Project overdue",
|
||||
week: "Due within 7 days",
|
||||
mine: "Mine",
|
||||
},
|
||||
showing: "Showing",
|
||||
count: "flt-count", // data-testid of "Showing n packets"
|
||||
clear: /^Clear filters/,
|
||||
listHead: "Jobs",
|
||||
sortBy: "Sort by",
|
||||
sortProject: "Project / lot",
|
||||
empty: "No packets match these filters.",
|
||||
},
|
||||
// KPI tiles on the Supervisor home
|
||||
kpi: {
|
||||
ready: "Ready to assign",
|
||||
atStations: "At stations",
|
||||
review: "Waiting on review",
|
||||
},
|
||||
homes: {
|
||||
supervisor: "/supervisor",
|
||||
clerical: "/clerical",
|
||||
pm: "/pm",
|
||||
},
|
||||
newPacket: "New packet",
|
||||
back: /Back$/,
|
||||
presence: "presence", // data-testid of the callout (role status)
|
||||
alsoHere: "Also in this packet now:",
|
||||
roles: { supervisor: "Supervisor", stall: "Weld Stall", pm: "Project Mgmt" },
|
||||
motionOn: "Motion on",
|
||||
motionOff: "Motion off",
|
||||
} as const;
|
||||
|
||||
/** A run suffix that differs per Playwright project, so the chromium and ipad runs never share a number. */
|
||||
export function runSuffix(projectName: string): string {
|
||||
// Letters only, so no digit in it can match a lot or a count the specs search for ("lot 54").
|
||||
const base = Date.now()
|
||||
.toString(36)
|
||||
.toUpperCase()
|
||||
.slice(-5)
|
||||
.replace(/\d/g, (d) => "QRSTUVWXYZ".charAt(Number(d)));
|
||||
return projectName === "chromium" ? base : `${base}${projectName.slice(0, 1).toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** Today plus `days`, as YYYY-MM-DD (UTC calendar; the specs only need a day or more of margin). */
|
||||
export function isoDay(days: number): string {
|
||||
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
id: number;
|
||||
number: string;
|
||||
lot: string;
|
||||
}
|
||||
|
||||
export interface Made {
|
||||
id: number;
|
||||
title: string;
|
||||
unitNumber: string;
|
||||
}
|
||||
|
||||
/** As Clerical: a project (made once per number) with the given fields. */
|
||||
export async function makeProject(clara: Actor, number: string, fields: Json = {}): Promise<Json> {
|
||||
const projects = (await getJson(clara.page.request, "/api/projects?status=all")) as unknown as Json[];
|
||||
const found = projects.find((p) => p.number === number);
|
||||
if (found) return found;
|
||||
return api(clara, "post", "/api/projects", { number, name: `CI ${number}`, ...fields }, { status: 201 });
|
||||
}
|
||||
|
||||
/** As Clerical: one unit with this lot (and due date) in the project. */
|
||||
export async function makeUnit(clara: Actor, projectId: number, lot: string, due?: string): Promise<Unit> {
|
||||
const units = (await api(
|
||||
clara,
|
||||
"post",
|
||||
`/api/projects/${projectId}/units`,
|
||||
{ lot, ...(due ? { due } : {}) },
|
||||
{ status: 201 },
|
||||
)) as unknown as Json[];
|
||||
const u = units[0] as Json;
|
||||
return { id: Number(u.id), number: String(u.number), lot: String(u.lot ?? lot) };
|
||||
}
|
||||
|
||||
export interface DraftOpts {
|
||||
dept?: string;
|
||||
customer?: string;
|
||||
wo?: string;
|
||||
drawing?: string;
|
||||
bom?: Json[];
|
||||
/** "warehouse" sends it to the warehouse; a department code releases it there; absent leaves a bare draft. */
|
||||
send?: string;
|
||||
}
|
||||
|
||||
const BOM3: Json[] = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/**
|
||||
* As clerk.clara: a hand draft on `unit` with a work order (target 2 h) and three BOM lines. With `send`, a one-page
|
||||
* drawing is attached with her password and the heat map answered, then it is sent on. A bare draft is one request.
|
||||
*/
|
||||
export async function makeDraft(clara: Actor, unit: Unit, title: string, o: DraftOpts = {}): Promise<Made> {
|
||||
const draft = await api(
|
||||
clara,
|
||||
"post",
|
||||
"/api/packets",
|
||||
{
|
||||
unit_id: unit.id,
|
||||
dept_code: o.dept ?? "03",
|
||||
op: "WELD-PART",
|
||||
title,
|
||||
customer: o.customer ?? "CI Customer",
|
||||
drawing: o.drawing ?? `D-${unit.number}`,
|
||||
rev: "A",
|
||||
wps: "WPS-11",
|
||||
work_orders: [
|
||||
{
|
||||
wo_base: (o.wo ?? `W${unit.number}`).slice(0, 20),
|
||||
lot: "001",
|
||||
split: "0",
|
||||
sub: "0",
|
||||
qty: 1,
|
||||
target_hrs: 2,
|
||||
},
|
||||
],
|
||||
bom: o.bom ?? BOM3,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
const id = Number(draft.id);
|
||||
if (o.send) {
|
||||
const up = await clara.page.request.post("/api/assets", {
|
||||
headers: writeHeaders(clara.page),
|
||||
multipart: {
|
||||
file: { name: `${unit.number}-${id}.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/${id}/pages`, {
|
||||
asset_id: asset.id,
|
||||
type: "drawing",
|
||||
sign: sign("clerk.clara"),
|
||||
});
|
||||
await api(clara, "patch", `/api/packets/${id}`, { set: { heat_map: true }, base: {} });
|
||||
if (o.send === "warehouse") {
|
||||
const sent = await stepApi(clara, id, "send_to_warehouse");
|
||||
expect(sent.status).toBe("warehouse");
|
||||
} else {
|
||||
const sent = await stepApi(clara, id, "release_to_shop", { dept: o.send });
|
||||
expect(sent.status).toBe("ready");
|
||||
}
|
||||
}
|
||||
return { id, title, unitNumber: unit.number };
|
||||
}
|
||||
|
||||
/**
|
||||
* As wh.wade: every BOM line pulled with `heat` (line n gets `${heat}${n}`), except the items in `short`, which are
|
||||
* recorded short (On order); then released. The packet is Ready to assign in 03.
|
||||
*/
|
||||
export async function pullWithHeat(wade: Actor, id: number, heat: string, short: number[] = []): Promise<Json> {
|
||||
let p = await getJson(wade.page.request, `/api/packets/${id}`);
|
||||
for (const line of p.bom as Json[]) {
|
||||
const item = Number(line.item);
|
||||
if (short.includes(item)) {
|
||||
p = await api(wade, "post", `/api/packets/${id}/bom/${item}/short`, {
|
||||
qty: Number(line.qty ?? 1),
|
||||
reason: "On order",
|
||||
expect_open: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
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: `${heat}${item}` },
|
||||
{ headers: { "If-Match": String(cur.row_version) } },
|
||||
);
|
||||
}
|
||||
const out = await stepApi(wade, id, "wh_release");
|
||||
expect(out.status).toBe("ready");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The user id of a 03 person by display name (the assign sheet's people list). */
|
||||
export async function personId(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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the find bar
|
||||
|
||||
export function findBar(page: Page): Locator {
|
||||
return page.getByTestId(L.find.bar);
|
||||
}
|
||||
|
||||
export function searchBox(page: Page): Locator {
|
||||
return findBar(page).getByRole("searchbox", { name: L.find.search });
|
||||
}
|
||||
|
||||
/** Pick a project in the find bar (PickUnit: search, then the hit). Unpicks the current one first. */
|
||||
export async function pickProject(page: Page, number: string): Promise<void> {
|
||||
const bar = findBar(page);
|
||||
const change = bar.getByRole("button", { name: L.find.change, exact: true });
|
||||
if (await change.count()) await change.first().click();
|
||||
await bar.getByRole("searchbox", { name: L.find.project }).fill(number);
|
||||
await bar
|
||||
.getByRole("group", { name: L.find.projectsFound })
|
||||
.getByRole("button", { name: new RegExp(`^${number}\\b`) })
|
||||
.first()
|
||||
.click();
|
||||
await expect(bar.getByText(number, { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** Back to every project. */
|
||||
export async function unpickProject(page: Page): Promise<void> {
|
||||
const change = findBar(page).getByRole("button", { name: L.find.change, exact: true });
|
||||
if (await change.count()) await change.first().click();
|
||||
}
|
||||
|
||||
/** The n of "Showing n packets". */
|
||||
export async function showing(page: Page): Promise<number> {
|
||||
const t = (await page.getByTestId(L.find.count).innerText()).replace(/\s+/g, " ");
|
||||
const m = /Showing (\d+)/.exec(t);
|
||||
if (!m) throw new Error(`no count in "${t}"`);
|
||||
return Number(m[1]);
|
||||
}
|
||||
|
||||
/** Wait until the bar shows `n` packets. */
|
||||
export async function expectShowing(page: Page, n: number): Promise<void> {
|
||||
await expect.poll(() => showing(page), { timeout: 10_000 }).toBe(n);
|
||||
}
|
||||
|
||||
/** A select of the find bar's second row by its label. */
|
||||
export function fltSelect(page: Page, label: string): Locator {
|
||||
return findBar(page).getByLabel(label, { exact: true });
|
||||
}
|
||||
|
||||
export function filtersButton(page: Page): Locator {
|
||||
return findBar(page).getByRole("button", { name: new RegExp(`^${L.find.filters}`) });
|
||||
}
|
||||
|
||||
export function chip(page: Page, label: string): Locator {
|
||||
return findBar(page).getByRole("button", { name: label, exact: true });
|
||||
}
|
||||
|
||||
export function viewButton(page: Page, which: "Board" | "List"): Locator {
|
||||
return findBar(page).getByRole("button", { name: which, exact: true });
|
||||
}
|
||||
|
||||
export function jobList(page: Page): Locator {
|
||||
return page.getByRole("table", { name: L.find.listHead, exact: true });
|
||||
}
|
||||
|
||||
/** The packet ids of the list view's rows, in order (each row carries data-card-id). */
|
||||
export async function listIds(page: Page): Promise<number[]> {
|
||||
const rows = jobList(page).locator("tbody tr[data-card-id]");
|
||||
return (await rows.evaluateAll((els) => els.map((e) => Number(e.getAttribute("data-card-id"))))) as number[];
|
||||
}
|
||||
|
||||
/** A card of a packet anywhere on the board: by data-card-id, else by its title or unit number. */
|
||||
export function cardOf(page: Page, p: Made): Locator {
|
||||
return page
|
||||
.locator(`[data-card-id="${p.id}"]`)
|
||||
.or(page.locator(".card").filter({ hasText: p.title }))
|
||||
.or(page.locator(".card").filter({ hasText: p.unitNumber }))
|
||||
.first();
|
||||
}
|
||||
|
||||
export async function lsGet(page: Page, key: string): Promise<string | null> {
|
||||
return page.evaluate((k) => window.localStorage.getItem(k), key);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PM
|
||||
|
||||
export function pmTab(page: Page, name: string): Locator {
|
||||
return page.getByRole("tablist").getByRole("tab", { name, exact: true });
|
||||
}
|
||||
|
||||
export function pmTable(page: Page, title: string): Locator {
|
||||
return page.getByRole("table", { name: title, exact: true });
|
||||
}
|
||||
|
||||
/** Body rows of every PM table on the page that carry data (not the "Nothing in scope." row). */
|
||||
export function pmDataRows(page: Page): Locator {
|
||||
return page.locator("table.pmtable tbody tr").filter({ hasNotText: L.pm.nothing });
|
||||
}
|
||||
|
||||
/** Read a finished download as text, without the BOM. */
|
||||
export async function downloadText(dl: Download): Promise<string> {
|
||||
const path = await dl.path();
|
||||
if (!path) throw new Error("download has no path");
|
||||
const text = readFileSync(path, "utf-8");
|
||||
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
}
|
||||
|
||||
/** Click and catch what it opens in a new tab (window.open is stubbed), as a URL. */
|
||||
export async function catchOpen(page: Page, target: Locator): Promise<string> {
|
||||
await expect(target).toBeVisible();
|
||||
const href = await target.getAttribute("href");
|
||||
if (href) return new URL(href, page.url()).toString();
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __opened: string[]; open: (u?: string | URL) => Window | null };
|
||||
w.__opened = [];
|
||||
w.open = (u?: string | URL) => {
|
||||
w.__opened.push(String(u ?? ""));
|
||||
return null;
|
||||
};
|
||||
});
|
||||
await target.click();
|
||||
await expect
|
||||
.poll(async () => (await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).length)
|
||||
.toBeGreaterThan(0);
|
||||
const opened = await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);
|
||||
return new URL(opened[0] ?? "", page.url()).toString();
|
||||
}
|
||||
|
||||
/** How many Web Animations run on board cards right now (the FLIP glide, the fade in, the glow). */
|
||||
export async function cardAnimations(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
document.getAnimations().filter((a) => {
|
||||
const t = (a.effect as KeyframeEffect | null)?.target as Element | null | undefined;
|
||||
return !!t && !!t.closest && !!t.closest("[data-card-id]");
|
||||
}).length,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { dcUser } from "./helpers";
|
||||
import { actor, api, closeAll } from "./shophelpers";
|
||||
import { L, makeDraft, makeProject, makeUnit, pullWithHeat, runSuffix, type Made } from "./pmhelpers";
|
||||
|
||||
// Phase 8 presence on the CI stack, modelled on the old sync.js presence and the "Also here" line. clerk.clara and
|
||||
// wh.wade make one packet Ready to assign in 03. sup.wes and weld.andy open it in two browser contexts: each sees
|
||||
// "Also in this packet now:" with the other's name and role. weld.andy closes the page: the callout on sup.wes's
|
||||
// page clears within 10 s. weld.andy opens it again, then sup.wes locks the session through the API: weld.andy's
|
||||
// callout drops sup.wes. Every UI label used here is in L (pmhelpers.ts).
|
||||
|
||||
let RUN = "";
|
||||
let P: Made;
|
||||
|
||||
function callout(page: Page) {
|
||||
return page.getByTestId(L.presence);
|
||||
}
|
||||
|
||||
async function openPacket(page: Page, id: number): Promise<void> {
|
||||
await page.goto(`/packets/${id}`);
|
||||
await expect(page.getByRole("heading").first()).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe.serial("presence", () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test("clerk.clara and wh.wade make the packet", async ({ browser }, info) => {
|
||||
RUN = runSuffix(info.project.name);
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const proj = await makeProject(clara, `PR${RUN}`, { name: "Presence job" });
|
||||
const unit = await makeUnit(clara, Number(proj.id), "001");
|
||||
P = await makeDraft(clara, unit, `Presence ${RUN}`, { send: "warehouse" });
|
||||
await pullWithHeat(wade, P.id, `HP${RUN}-`);
|
||||
await closeAll(clara, wade);
|
||||
});
|
||||
|
||||
test("two people on one packet see each other; leaving and locking drop out", async ({ browser }) => {
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const andy = await actor(browser, "weld.andy");
|
||||
const wesName = dcUser("sup.wes").display_name;
|
||||
const andyName = dcUser("weld.andy").display_name;
|
||||
|
||||
// alone: nothing is drawn
|
||||
await openPacket(wes.page, P.id);
|
||||
await expect(callout(wes.page)).toHaveCount(0);
|
||||
|
||||
// together: each sees the other with the role
|
||||
await openPacket(andy.page, P.id);
|
||||
await expect(callout(wes.page)).toContainText(L.alsoHere, { timeout: 10_000 });
|
||||
await expect(callout(wes.page)).toContainText(`${andyName} (${L.roles.stall})`);
|
||||
await expect(callout(andy.page)).toContainText(L.alsoHere, { timeout: 10_000 });
|
||||
await expect(callout(andy.page)).toContainText(`${wesName} (${L.roles.supervisor})`);
|
||||
await expect(callout(wes.page)).toHaveAttribute("role", "status");
|
||||
await expect(callout(wes.page)).not.toContainText(wesName); // never yourself
|
||||
|
||||
// weld.andy closes the page: gone within 10 s
|
||||
await andy.page.close();
|
||||
await expect(callout(wes.page).filter({ hasText: andyName })).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// back again, then sup.wes locks: gone from weld.andy's callout
|
||||
andy.page = await andy.ctx.newPage();
|
||||
await openPacket(andy.page, P.id);
|
||||
await expect(callout(andy.page)).toContainText(wesName, { timeout: 10_000 });
|
||||
await api(wes, "post", "/api/auth/lock", undefined, { status: 204 });
|
||||
await expect(callout(andy.page).filter({ hasText: wesName })).toHaveCount(0, { timeout: 10_000 });
|
||||
await closeAll(wes, andy);
|
||||
});
|
||||
});
|
||||
@@ -192,7 +192,8 @@ test.describe.serial("warehouse", () => {
|
||||
await rs.getByLabel(L.heat, { exact: true }).fill("HT-E2E-R");
|
||||
await rs.getByRole("button", { name: L.receive, exact: true }).click();
|
||||
await expect(rs).toBeHidden();
|
||||
await expect(page.getByText(L.noShorts)).toBeVisible();
|
||||
// Other scenarios may leave shorts of their own on the table; this packet's row is gone.
|
||||
await expect(shortRow).toHaveCount(0);
|
||||
const after = await getJson(page.request, `/api/packets/${pullPkt.id}`);
|
||||
expect(after.open_shorts).toBe(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
"""Helpers for the phase 8 tests: one database per test module, the Project Management world, and the texts of the
|
||||
phase 8 contract (periods, PM steps, health, KPI labels, table titles and heads).
|
||||
|
||||
Built on shophelpers, qchelpers and closehelpers (imported, never edited). Nothing here reads VISUAL or M-Files.
|
||||
The labor hours are rows written straight into `visual_labor`, as the feed tests do.
|
||||
|
||||
The PM world (dates are calendar days in the site time zone, America/Denver):
|
||||
|
||||
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
|
||||
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 3.2 h, target 4.5 h),
|
||||
k_short (warehouse pulling, an open short with a past ETA)
|
||||
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
|
||||
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
|
||||
unit 004 "Skid D" due today + 30 -> risk k_co (ready, change order CO-1 open 3 days), k_pulled
|
||||
unit 005 "Skid E" due today + 30 -> done k_filed (filed, created 10 days ago), k_oldfiled (filed 120
|
||||
days ago, created 140 days ago, a synthetic history)
|
||||
unit 006 "Skid F" no due (project's) -> ok k_draft, k_ready, k_asg (assigned), k_complete
|
||||
unit 007 "Skid G" due today + 30 -> empty
|
||||
project L489 "BPX" (due today + 9), unit 052 with no packets -> empty
|
||||
project L333 closed, unit 001 with no packets: not in scope
|
||||
|
||||
History rows inserted with old timestamps (append-only tables, so inserted, never updated):
|
||||
k_draft: return_to_clerical 400 and 45 days ago (sent back)
|
||||
k_oldfiled: send_to_warehouse -139 d, wh_send_back -138 d (sent back), send_to_warehouse -137 d, wh_release -130 d,
|
||||
send_to_qc -126 d, qc_reject -125 d (sent back; a rejected qc_history row), send_to_qc -124 d,
|
||||
qc_accept -123 d (an accepted qc_history row), file -120 d.
|
||||
|
||||
So: open 12, late units 1, units at risk 3, holds 1, change orders 1, shorts 1, over target 1; by period
|
||||
rejects 30: 1, 90: 1, 365: 2, all: 2; filed 30: 1, 90: 1, 365: 2, all: 2;
|
||||
sent back 30: 1, 90: 2, 365: 4, all: 5; lead time 90: 10.0 d, 365: 15.0 d.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pkthelpers import step, tiny_pdf, upload
|
||||
from pshelpers import ORIGIN_HEADERS, PG_URL, app_conn, fake_visual, make_settings
|
||||
from shophelpers import (
|
||||
SIGN,
|
||||
Shop,
|
||||
draft,
|
||||
issue_co,
|
||||
ok,
|
||||
open_shop,
|
||||
pull_all,
|
||||
ready_skipped,
|
||||
run,
|
||||
set_running_since,
|
||||
start,
|
||||
to_station,
|
||||
to_warehouse,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
SITE_TZ = "America/Denver"
|
||||
|
||||
# ---------------------------------------------------------------- the contract's texts
|
||||
|
||||
PERIODS = {"30": "Last 30 days", "90": "Last 90 days", "365": "Last 12 months", "all": "All time"}
|
||||
|
||||
PM_STEPS = (
|
||||
("draft", "Ready to release (Clerical)"),
|
||||
("wh_incoming", "Warehouse: waiting"),
|
||||
("wh_pulling", "Warehouse: being pulled"),
|
||||
("wh_hold", "Warehouse: on hold"),
|
||||
("wh_pulled", "Warehouse: pulled, ready for production"),
|
||||
("ready", "Ready to assign"),
|
||||
("station", "At a station"),
|
||||
("review", "Supervisor review"),
|
||||
("qc", "At Q.C."),
|
||||
("complete", "Back for data book"),
|
||||
)
|
||||
STEP_LABEL = dict(PM_STEPS)
|
||||
|
||||
HEALTH = {"ok": "On track", "risk": "At risk", "late": "Late", "done": "Complete", "empty": "No packets yet"}
|
||||
|
||||
KPI_KEYS = ("open", "late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over", "filed", "lead")
|
||||
KPI_TABS = ("aging", "projects", "projects", "quality", "quality", "quality", "quality", "hours", "projects", "aging")
|
||||
KPI_ALERT = {"late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over"}
|
||||
|
||||
|
||||
def kpi_labels(period: str = "90") -> tuple[str, ...]:
|
||||
lower = PERIODS[period].lower()
|
||||
return (
|
||||
"Open packets",
|
||||
"Late units",
|
||||
"Units at risk",
|
||||
"Warehouse holds",
|
||||
"Open change orders",
|
||||
"Open shorts",
|
||||
f"Q.C. rejects · {lower}",
|
||||
"Packets over target hours",
|
||||
f"Filed · {lower}",
|
||||
"Average lead time, created → filed",
|
||||
)
|
||||
|
||||
|
||||
HOURS_TAIL = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
|
||||
HEADS: dict[str, list[str]] = {
|
||||
"projects": [
|
||||
"Project / unit",
|
||||
"Name",
|
||||
"Customer",
|
||||
"Due",
|
||||
"Left",
|
||||
"Units",
|
||||
"Packets",
|
||||
"Draft",
|
||||
"Warehouse",
|
||||
"Shop",
|
||||
"Q.C.",
|
||||
"Done",
|
||||
"% done",
|
||||
"Health",
|
||||
],
|
||||
"aging_steps": ["Step", "Packets", "Average", "Longest", "Oldest packet"],
|
||||
"aging_open": ["Job", "Unit", "Step", "With", "In this step", "Total age", "Unit due", "Flags"],
|
||||
"aging_history": ["Step", "Moves", "Average", "Longest"],
|
||||
"hours_dept": ["Department", *HOURS_TAIL],
|
||||
"hours_worker": ["Worker", *HOURS_TAIL],
|
||||
"hours_project": ["Project", *HOURS_TAIL],
|
||||
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
||||
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
|
||||
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
|
||||
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
|
||||
"quality_holds": ["Job", "Reason", "Note", "Put on hold by", "On hold for"],
|
||||
"quality_sentback": ["Job", "When", "Action", "By", "Reason"],
|
||||
"jobs": [
|
||||
"Job",
|
||||
"Project",
|
||||
"Unit",
|
||||
"Operation",
|
||||
"Title",
|
||||
"Department",
|
||||
"Step",
|
||||
"With",
|
||||
"Station",
|
||||
"Worker",
|
||||
"Drawing",
|
||||
"Rev",
|
||||
"Unit due",
|
||||
"Days in step",
|
||||
"Timer h",
|
||||
"Ticket h",
|
||||
"Target h",
|
||||
"Created",
|
||||
"Updated",
|
||||
"Change orders open",
|
||||
"Shorts open",
|
||||
],
|
||||
}
|
||||
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
|
||||
|
||||
|
||||
def titles(period: str = "90") -> dict[str, str]:
|
||||
p = PERIODS[period]
|
||||
return {
|
||||
"projects": "Projects and units",
|
||||
"aging_steps": "Time in the current step (open packets)",
|
||||
"aging_open": "Every open packet, longest waiting first",
|
||||
"aging_history": f"Average time per step, moves completed ({p})",
|
||||
"hours_dept": "By department",
|
||||
"hours_worker": "By welder / worker",
|
||||
"hours_project": "By project",
|
||||
"hours_over": "Packets over target",
|
||||
"quality_rejects": f"Q.C. rejects ({p})",
|
||||
"quality_shorts": "Open shorts",
|
||||
"quality_cos": "Open change orders",
|
||||
"quality_holds": "Warehouse holds",
|
||||
"quality_sentback": f"Sent back ({p})",
|
||||
"jobs": "All jobs in scope",
|
||||
}
|
||||
|
||||
|
||||
NOTES = {
|
||||
"projects": "Tap a project to show its units. Done = accepted by Q.C. or filed. At risk = due within 7 days and"
|
||||
" under 75% done, or a hold or open change order.",
|
||||
"aging_steps": "How long packets have been sitting where they are right now.",
|
||||
"aging_open": "Over 3 days in one step is flagged.",
|
||||
"aging_history": "From the step history recorded on every move.",
|
||||
"hours_over": "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the"
|
||||
" clock of record.",
|
||||
}
|
||||
|
||||
TAB_TABLES = {
|
||||
"aging": ("aging_steps", "aging_open", "aging_history"),
|
||||
"hours": ("hours_dept", "hours_worker", "hours_project", "hours_over"),
|
||||
"quality": ("quality_rejects", "quality_shorts", "quality_cos", "quality_holds", "quality_sentback"),
|
||||
}
|
||||
|
||||
REJECT_REASON = "Undercut on weld 1, regrind and reweld"
|
||||
REJECT_NCR = "NCR-2026-0031"
|
||||
HOLD_REASON = "Waiting on purchasing / PO"
|
||||
HOLD_NOTE = "PO 4471"
|
||||
FORMULA_TITLE = "=HYPERLINK(1)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- one database per module
|
||||
|
||||
|
||||
def today_site() -> date:
|
||||
return datetime.now(ZoneInfo(SITE_TZ)).date()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def module_settings(tmp: Path) -> Iterator[Settings]:
|
||||
"""The conftest `pg_settings` for a whole module: a fresh database with ps_migrate and ps_app, migrated to head,
|
||||
dropped afterwards. Lets a module build its world once."""
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
from packetstation import db, migrate
|
||||
|
||||
assert PG_URL
|
||||
info = conninfo_to_dict(PG_URL)
|
||||
mig_pw, app_pw = secrets.token_urlsafe(18), secrets.token_urlsafe(18)
|
||||
(tmp / "mig").write_text(mig_pw + "\n")
|
||||
(tmp / "app").write_text(app_pw + "\n")
|
||||
name = f"ps_test_{secrets.token_hex(4)}"
|
||||
s = make_settings(
|
||||
tmp,
|
||||
pg_host=info.get("host", "localhost"),
|
||||
pg_port=int(info.get("port", 5432)),
|
||||
pg_database=name,
|
||||
pg_migrate_password_file=tmp / "mig",
|
||||
pg_app_password_file=tmp / "app",
|
||||
pg_superuser_url=PG_URL,
|
||||
# A world takes minutes to build over a slow tunnel: no idle lock or idle sign-out while it does.
|
||||
session_lock_minutes=600,
|
||||
session_idle_minutes=720,
|
||||
)
|
||||
migrate.bootstrap_roles(s)
|
||||
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||
for user, pw in (("ps_migrate", mig_pw), ("ps_app", app_pw)):
|
||||
su.execute(sql.SQL("ALTER ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(user), sql.Literal(pw)))
|
||||
with db.connect_as("migrate", s, autocommit=True) as conn:
|
||||
migrate.run(conn, db.MIGRATIONS_DIR)
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
db.close_pool()
|
||||
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def module_shop(tmp: Path, fake_dir: FakeDirectory | None = None) -> Iterator[Shop]:
|
||||
"""The app on a module database with the fake directory and DEV VISUAL settings; every test user signed in."""
|
||||
from packetstation import guard
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(guard, "load_visual_settings", lambda: fake_visual(False))
|
||||
with module_settings(tmp) as s:
|
||||
gen = open_shop(s, fake_dir or FakeDirectory())
|
||||
shop = next(gen)
|
||||
try:
|
||||
yield shop
|
||||
finally:
|
||||
gen.close()
|
||||
|
||||
|
||||
def anon(shop: Shop) -> TestClient:
|
||||
"""A client with no session."""
|
||||
return TestClient(shop.main.app, headers=ORIGIN_HEADERS)
|
||||
|
||||
|
||||
def get_ok(c: TestClient, url: str, **params: Any) -> dict[str, Any]:
|
||||
r = c.get(url, params={k: v for k, v in params.items() if v is not None})
|
||||
assert r.status_code == 200, (url, params, r.text)
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- SQL setup
|
||||
|
||||
|
||||
def make_project(
|
||||
settings: Settings,
|
||||
number: str,
|
||||
name: str,
|
||||
*,
|
||||
customer: str | None = None,
|
||||
po: str | None = None,
|
||||
due: date | None = None,
|
||||
status: str = "open",
|
||||
) -> int:
|
||||
with app_conn(settings) as conn:
|
||||
row = conn.execute(
|
||||
"INSERT INTO projects (number, name, customer, po, due, status)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s::project_status) RETURNING id",
|
||||
(number, name, customer, po, due, status),
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def make_unit(settings: Settings, project_id: int, lot: str, name: str | None = None, due: date | None = None) -> int:
|
||||
with app_conn(settings) as conn:
|
||||
row = conn.execute(
|
||||
"INSERT INTO units (project_id, lot, name, due) VALUES (%s, %s, %s, %s) RETURNING id",
|
||||
(project_id, lot, name, due),
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def add_alias(settings: Settings, project_id: int, alias: str) -> None:
|
||||
run(
|
||||
settings,
|
||||
"INSERT INTO project_aliases (alias, project_id, kind) VALUES (%s, %s, 'import')",
|
||||
(alias.upper(), project_id),
|
||||
)
|
||||
|
||||
|
||||
def transition_at(
|
||||
settings: Settings,
|
||||
packet_id: int,
|
||||
days_ago: float,
|
||||
action: str,
|
||||
from_status: str | None,
|
||||
to_status: str | None,
|
||||
*,
|
||||
by: str = "clerk.clara",
|
||||
reason: str | None = None,
|
||||
dept: str = "03",
|
||||
) -> None:
|
||||
"""One packet_transitions row with an old timestamp (the table is append-only: insert, never update)."""
|
||||
run(
|
||||
settings,
|
||||
"INSERT INTO packet_transitions (packet_id, at, by, action, from_status, to_status, from_dept, to_dept,"
|
||||
" reason) VALUES (%s, now() - make_interval(secs => %s), (SELECT id FROM users WHERE sam_account = %s),"
|
||||
" %s, %s::packet_status, %s::packet_status, %s, %s, %s)",
|
||||
(packet_id, days_ago * 86400, by, action, from_status, to_status, dept, dept, reason),
|
||||
)
|
||||
|
||||
|
||||
def qc_history_at(settings: Settings, packet_id: int, days_ago: float, result: str, note: str | None = None) -> None:
|
||||
run(
|
||||
settings,
|
||||
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
|
||||
" VALUES (%s, now() - make_interval(secs => %s), %s, 'Quinn QC', %s, '03', '{}')",
|
||||
(packet_id, days_ago * 86400, result, note),
|
||||
)
|
||||
|
||||
|
||||
def labor_rows(settings: Settings, packet_id: int, rows: list[tuple[str, int, str]]) -> None:
|
||||
"""Make a packet a VISUAL packet and give its work orders VISUAL ticket hours: rows of (wo_base, seq, hours).
|
||||
Each work order gets the seq of its row; visual_labor gets one row per entry, as the feed writes them."""
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (packet_id,))
|
||||
for base, seq, hours in rows:
|
||||
conn.execute(
|
||||
"UPDATE packet_work_orders SET seq = %s WHERE packet_id = %s AND wo_base = %s", (seq, packet_id, base)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)"
|
||||
" SELECT wo_base, lot, split, sub, seq, %s, 2, current_date FROM packet_work_orders"
|
||||
" WHERE packet_id = %s AND wo_base = %s"
|
||||
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET hours = EXCLUDED.hours",
|
||||
(hours, packet_id, base),
|
||||
)
|
||||
|
||||
|
||||
def quick_draft(
|
||||
shop: Shop, unit_id: int, title: str, *, n: int, send: str | None = None, **over: Any
|
||||
) -> dict[str, Any]:
|
||||
"""A hand draft by clerk.clara with neutral values (drawing D-61nn, work order Wnn XY, no BOM) unless
|
||||
overridden. `send` "warehouse" sends it to the warehouse; a department code releases it there (ready). Either
|
||||
attaches a one-page drawing and answers the heat map first. A bare draft costs one request."""
|
||||
body: dict[str, Any] = {
|
||||
"unit_id": unit_id,
|
||||
"dept_code": "03",
|
||||
"op": "WELD-PART",
|
||||
"title": title,
|
||||
"customer": "Plains Midstream",
|
||||
"drawing": f"D-61{n:02d}",
|
||||
"rev": "A",
|
||||
"wps": "WPS-11",
|
||||
"work_orders": [
|
||||
{"wo_base": f"W{n:02d}XY", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1},
|
||||
],
|
||||
"bom": [],
|
||||
}
|
||||
body.update(over)
|
||||
c = shop("clerk.clara")
|
||||
r = c.post("/api/packets", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
pkt: dict[str, Any] = r.json()
|
||||
if send:
|
||||
assert c.patch(f"/api/packets/{pkt['id']}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
|
||||
a = upload(c, tiny_pdf(1))
|
||||
assert a.status_code == 201, a.text
|
||||
pkt = ok(
|
||||
c.post(
|
||||
f"/api/packets/{pkt['id']}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN}
|
||||
)
|
||||
)
|
||||
if send == "warehouse":
|
||||
pkt = ok(step(c, pkt, "send_to_warehouse"))
|
||||
assert pkt["status"] == "warehouse", pkt["status"]
|
||||
else:
|
||||
pkt = ok(step(c, pkt, "release_to_shop", dept=send))
|
||||
assert pkt["status"] == "ready", pkt["status"]
|
||||
return pkt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the PM world
|
||||
|
||||
|
||||
@dataclass
|
||||
class PmWorld:
|
||||
shop: Shop
|
||||
today: date
|
||||
projects: dict[str, int] = field(default_factory=dict) # "L670", "L489", "L333"
|
||||
units: dict[int, int] = field(default_factory=dict) # 1..7 -> L670 units; 52 -> L489 052
|
||||
k: dict[str, int] = field(default_factory=dict) # packet ids by key
|
||||
|
||||
@property
|
||||
def settings(self) -> Settings:
|
||||
return self.shop.settings
|
||||
|
||||
def unit_number(self, n: int) -> str:
|
||||
return f"L670{n:03d}"
|
||||
|
||||
|
||||
OPEN_KEYS = (
|
||||
"k_run",
|
||||
"k_short",
|
||||
"k_rev",
|
||||
"k_qc",
|
||||
"k_hold",
|
||||
"k_inc",
|
||||
"k_co",
|
||||
"k_pulled",
|
||||
"k_draft",
|
||||
"k_ready",
|
||||
"k_asg",
|
||||
"k_complete",
|
||||
)
|
||||
FILED_KEYS = ("k_filed", "k_oldfiled")
|
||||
|
||||
# The PM step of each open packet, and the expected pipeline counts.
|
||||
STEP_OF = {
|
||||
"k_draft": "draft",
|
||||
"k_inc": "wh_incoming",
|
||||
"k_short": "wh_pulling",
|
||||
"k_hold": "wh_hold",
|
||||
"k_pulled": "wh_pulled",
|
||||
"k_ready": "ready",
|
||||
"k_co": "ready",
|
||||
"k_asg": "station",
|
||||
"k_run": "station",
|
||||
"k_rev": "review",
|
||||
"k_qc": "qc",
|
||||
"k_complete": "complete",
|
||||
}
|
||||
PIPELINE_COUNTS = {key: sum(1 for v in STEP_OF.values() if v == key) for key, _ in PM_STEPS}
|
||||
|
||||
UNIT_HEALTH = {1: "late", 2: "risk", 3: "risk", 4: "risk", 5: "done", 6: "ok", 7: "empty"}
|
||||
|
||||
# By period: Q.C. rejects, filed, sent back, lead time.
|
||||
BY_PERIOD = {
|
||||
"30": {"rejects": 1, "filed": 1, "sentback": 1},
|
||||
"90": {"rejects": 1, "filed": 1, "sentback": 2, "lead": "10.0 d"},
|
||||
"365": {"rejects": 2, "filed": 2, "sentback": 4, "lead": "15.0 d"},
|
||||
"all": {"rejects": 2, "filed": 2, "sentback": 5},
|
||||
}
|
||||
|
||||
|
||||
def pm_world(shop: Shop) -> PmWorld:
|
||||
"""Build the PM world described at the top of this file. Takes a few minutes over a slow tunnel."""
|
||||
from closehelpers import at_complete, filed
|
||||
from qchelpers import at_qc, reject
|
||||
|
||||
s = shop.settings
|
||||
today = today_site()
|
||||
w = PmWorld(shop, today)
|
||||
pa = make_project(s, "L670", "Pump skids", customer="Acme Gas", po="PO-9", due=today + timedelta(days=20))
|
||||
add_alias(s, pa, "OLD670")
|
||||
w.projects["L670"] = pa
|
||||
dues = {
|
||||
1: today - timedelta(days=1),
|
||||
2: today + timedelta(days=4),
|
||||
3: today + timedelta(days=30),
|
||||
4: today + timedelta(days=30),
|
||||
5: today + timedelta(days=30),
|
||||
6: None,
|
||||
7: today + timedelta(days=30),
|
||||
}
|
||||
for n, due in dues.items():
|
||||
w.units[n] = make_unit(s, pa, f"{n:03d}", f"Skid {'ABCDEFG'[n - 1]}", due)
|
||||
pb = make_project(s, "L489", "BPX", customer="BPX", due=today + timedelta(days=9))
|
||||
w.projects["L489"] = pb
|
||||
w.units[52] = make_unit(s, pb, "052", "48in separator")
|
||||
pc = make_project(s, "L333", "Old closed job", status="closed")
|
||||
w.projects["L333"] = pc
|
||||
make_unit(s, pc, "001", "Closed unit")
|
||||
u = w.units
|
||||
wade = shop("wh.wade")
|
||||
|
||||
# unit 001, late: a running packet over target with ticket hours, a short with a past ETA
|
||||
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
|
||||
w.k["k_run"] = run_pkt["id"]
|
||||
run(
|
||||
s,
|
||||
"UPDATE packet_stages SET clock_elapsed_ms = %s WHERE packet_id = %s AND closed_at IS NULL",
|
||||
(4 * 3600 * 1000, run_pkt["id"]),
|
||||
)
|
||||
set_running_since(s, run_pkt["id"], 2 * 3600)
|
||||
labor_rows(s, run_pkt["id"], [("L670001", 10, "1.5"), ("L670002", 20, "1.7")])
|
||||
|
||||
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
|
||||
yesterday = (today - timedelta(days=1)).isoformat()
|
||||
short = ok(
|
||||
wade.post(
|
||||
f"/api/packets/{short['id']}/bom/1/short",
|
||||
json={"qty": 1, "reason": "On order", "ref": "PO-77", "eta": yesterday, "expect_open": False},
|
||||
)
|
||||
)
|
||||
w.k["k_short"] = short["id"]
|
||||
|
||||
# unit 002, at risk by the due rule: a packet back in review after a Q.C. reject, a packet at Q.C.
|
||||
rev = at_qc(shop, u[2], title="Rejected spool")
|
||||
rev = ok(reject(shop, rev, REJECT_REASON, ncr=REJECT_NCR))
|
||||
assert rev["status"] == "review", rev["status"]
|
||||
w.k["k_rev"] = rev["id"]
|
||||
w.k["k_qc"] = at_qc(shop, u[2], title="Inspected spool")["id"]
|
||||
|
||||
# unit 003, at risk by a hold: a warehouse hold five days old, a packet waiting
|
||||
hold = to_warehouse(shop, draft(shop, u[3], title="Held spool"))
|
||||
ok(
|
||||
wade.post(
|
||||
f"/api/packets/{hold['id']}/wh",
|
||||
json={"stage": "hold", "reason": HOLD_REASON, "note": HOLD_NOTE, "expect_stage": "incoming"},
|
||||
)
|
||||
)
|
||||
run(
|
||||
s,
|
||||
"UPDATE packet_wh SET at = now() - interval '5 days' WHERE packet_id = %s AND closed_at IS NULL",
|
||||
(hold["id"],),
|
||||
)
|
||||
w.k["k_hold"] = hold["id"]
|
||||
w.k["k_inc"] = to_warehouse(shop, draft(shop, u[3], title="Waiting spool"))["id"]
|
||||
|
||||
# unit 004, at risk by a change order: CO-1 open three days, a packet pulled
|
||||
co = issue_co(shop, ready_skipped(shop, u[4], title="Changed spool"), ["03"])
|
||||
run(s, "UPDATE change_orders SET issued_at = now() - interval '3 days' WHERE packet_id = %s", (co["id"],))
|
||||
w.k["k_co"] = co["id"]
|
||||
pulled = pull_all(shop, to_warehouse(shop, draft(shop, u[4], title="Pulled spool")))
|
||||
ok(wade.post(f"/api/packets/{pulled['id']}/wh", json={"stage": "pulled", "expect_stage": "pulling"}))
|
||||
w.k["k_pulled"] = pulled["id"]
|
||||
|
||||
# unit 005, done: one filed now (created ten days ago), one filed 120 days ago with a synthetic history
|
||||
fp = filed(shop, u[5])
|
||||
run(s, "UPDATE packets SET created_at = now() - interval '10 days' WHERE id = %s", (fp["id"],))
|
||||
w.k["k_filed"] = fp["id"]
|
||||
old = draft(shop, u[5], title="Old filed spool")
|
||||
oid = old["id"]
|
||||
run(
|
||||
s,
|
||||
"UPDATE packets SET status = 'filed', status_at = now() - interval '120 days',"
|
||||
" created_at = now() - interval '140 days' WHERE id = %s",
|
||||
(oid,),
|
||||
)
|
||||
for days, action, frm, to, reason in (
|
||||
(139, "send_to_warehouse", "draft", "warehouse", None),
|
||||
(138, "wh_send_back", "warehouse", "draft", "Wrong BOM"),
|
||||
(137, "send_to_warehouse", "draft", "warehouse", None),
|
||||
(130, "wh_release", "warehouse", "ready", None),
|
||||
(126, "send_to_qc", "review", "qc", None),
|
||||
(125, "qc_reject", "qc", "review", "Porosity at W2"),
|
||||
(124, "send_to_qc", "review", "qc", None),
|
||||
(123, "qc_accept", "qc", "complete", None),
|
||||
(120, "file", "complete", "filed", None),
|
||||
):
|
||||
transition_at(s, oid, days, action, frm, to, reason=reason)
|
||||
qc_history_at(s, oid, 125, "rejected", "Porosity at W2")
|
||||
qc_history_at(s, oid, 123, "accepted")
|
||||
w.k["k_oldfiled"] = oid
|
||||
|
||||
# unit 006, on track: a draft (sent back twice long ago), ready, assigned, complete
|
||||
dr = draft(shop, u[6], title="Draft spool")
|
||||
w.k["k_draft"] = dr["id"]
|
||||
transition_at(s, dr["id"], 400, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Wrong department")
|
||||
transition_at(s, dr["id"], 45, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Missing drawing")
|
||||
w.k["k_ready"] = ready_skipped(shop, u[6], title="Ready spool")["id"]
|
||||
w.k["k_asg"] = to_station(shop, ready_skipped(shop, u[6], title="Assigned spool"), station="Stall 2")["id"]
|
||||
w.k["k_complete"] = at_complete(shop, u[6])["id"]
|
||||
return w
|
||||
|
||||
|
||||
def by_key(rows: list[dict[str, Any]], key: str = "key") -> dict[str, dict[str, Any]]:
|
||||
return {str(r[key]): r for r in rows}
|
||||
|
||||
|
||||
def table(answer: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
found = [t for t in answer["tables"] if t["key"] == key]
|
||||
assert found, (key, [t["key"] for t in answer["tables"]])
|
||||
out: dict[str, Any] = found[0]
|
||||
return out
|
||||
|
||||
|
||||
def rows_by_pid(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
return {int(r["pid"]): r for r in t["rows"] if r.get("pid") is not None}
|
||||
|
||||
|
||||
def unit_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
return {int(r["unit_id"]): r for r in t["rows"] if r.get("lvl") == 1 and r.get("unit_id") is not None}
|
||||
|
||||
|
||||
def project_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
return {int(r["project_id"]): r for r in t["rows"] if r.get("lvl") == 0 and r.get("project_id") is not None}
|
||||
@@ -14,6 +14,11 @@ def _sub(hub: events.EventHub, name: str, screen: str | None = None) -> events.S
|
||||
return hub.subscribe(session_id=uuid.uuid4(), user_id=uuid.uuid4(), display_name=name, screen=screen)
|
||||
|
||||
|
||||
def _bare(data: dict) -> dict:
|
||||
"""A presence payload without the `since` times (they are the subscribe instants)."""
|
||||
return {"users": [{k: v for k, v in u.items() if k != "since"} for u in data["users"]]}
|
||||
|
||||
|
||||
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
|
||||
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
|
||||
out = []
|
||||
@@ -29,12 +34,13 @@ def test_fan_out_presence_and_session_targeting() -> None:
|
||||
b = _sub(hub, "Bo", "stall")
|
||||
got_a = await _drain(a.queue)
|
||||
assert [n for n, _ in got_a] == ["presence", "presence"]
|
||||
assert got_a[-1][1] == {
|
||||
assert _bare(got_a[-1][1]) == {
|
||||
"users": [
|
||||
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None},
|
||||
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall", "packet_id": None},
|
||||
{"user_id": a.user_id, "display_name": "Ann", "role": "", "screen": "admin", "packet_id": None},
|
||||
{"user_id": b.user_id, "display_name": "Bo", "role": "", "screen": "stall", "packet_id": None},
|
||||
]
|
||||
}
|
||||
assert all(u["since"] for u in got_a[-1][1]["users"])
|
||||
await _drain(b.queue)
|
||||
|
||||
hub._deliver(json.dumps({"event": "changed", "collection": "packets", "id": 7}))
|
||||
@@ -46,14 +52,18 @@ def test_fan_out_presence_and_session_targeting() -> None:
|
||||
("changed", {"collection": "projects"}),
|
||||
]
|
||||
|
||||
# A revoked session drops out of presence at once; the event itself goes to that session only.
|
||||
hub._deliver(json.dumps({"event": "session", "session_id": b.session_id, "action": "revoked"}))
|
||||
assert await _drain(a.queue) == []
|
||||
assert await _drain(b.queue) == [("session", {"session_id": b.session_id, "action": "revoked"})]
|
||||
users = [{"user_id": a.user_id, "display_name": "Ann", "role": "", "screen": "admin", "packet_id": None}]
|
||||
got = await _drain(a.queue)
|
||||
assert [(n, _bare(d)) for n, d in got] == [("presence", {"users": users})]
|
||||
got_b = await _drain(b.queue)
|
||||
assert got_b[0] == ("session", {"session_id": b.session_id, "action": "revoked"})
|
||||
assert [n for n, _ in got_b] == ["session", "presence"]
|
||||
|
||||
hub.unsubscribe(b)
|
||||
got = await _drain(a.queue)
|
||||
users = [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None}]
|
||||
assert got == [("presence", {"users": users})]
|
||||
assert [(n, _bare(d)) for n, d in got] == [("presence", {"users": users})]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Phase 8: the find bar filters applied by the server: status words, department, station, worker, the quick flags
|
||||
(Mine per screen), the flag kinds, the sorts and the facets, on `GET /api/packets` and on the boards.
|
||||
|
||||
The world is built once per module. Most statuses past `ready` are set by SQL: the filters read the packet row only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pmhelpers import get_ok, make_project, make_unit, module_shop, quick_draft, today_site
|
||||
from pshelpers import needs_pg
|
||||
from psycopg.types.json import Jsonb
|
||||
from shophelpers import Shop, issue_co, ok, run, step
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
DRAFTS = ("d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9")
|
||||
STATUS_ORDER = ("draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterWorld:
|
||||
shop: Shop
|
||||
k: dict[str, int] = field(default_factory=dict)
|
||||
projects: dict[str, int] = field(default_factory=dict)
|
||||
units: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def ids(self, *keys: str) -> set[int]:
|
||||
return {self.k[x] for x in keys}
|
||||
|
||||
@property
|
||||
def every(self) -> set[int]:
|
||||
return set(self.k.values())
|
||||
|
||||
def but(self, *keys: str) -> set[int]:
|
||||
return self.every - self.ids(*keys)
|
||||
|
||||
|
||||
def title(key: str) -> str:
|
||||
return f"ZX{key} spool"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[FilterWorld]:
|
||||
with module_shop(tmp_path_factory.mktemp("filters")) as shop:
|
||||
s = shop.settings
|
||||
today = today_site()
|
||||
w = FilterWorld(shop)
|
||||
fa = make_project(s, "L700", "Filter project", customer="Acme", due=today + timedelta(days=60))
|
||||
fb = make_project(s, "L60", "Second project")
|
||||
w.projects = {"fa": fa, "fb": fb}
|
||||
w.units = {
|
||||
"f1": make_unit(s, fa, "001", due=today - timedelta(days=1)), # overdue
|
||||
"f2": make_unit(s, fa, "002", due=today + timedelta(days=3)), # due within 7 days
|
||||
"f3": make_unit(s, fa, "003", due=today + timedelta(days=30)),
|
||||
"g1": make_unit(s, fb, "001"), # no due date anywhere
|
||||
}
|
||||
|
||||
def mk(key: str, unit: str, send: str | None = None, **over: Any) -> dict[str, Any]:
|
||||
pkt = quick_draft(shop, w.units[unit], title(key), n=len(w.k) + 1, send=send, **over)
|
||||
w.k[key] = int(pkt["id"])
|
||||
return pkt
|
||||
|
||||
# drafts: one hand draft, VISUAL drafts with each flag kind, an unconfirmed rev and a clean one. The rev
|
||||
# confirmation lives in visual->>'rev_confirmed' (the drawing job writes it); revOk is the old name.
|
||||
mk("d1", "f3")
|
||||
visual = {
|
||||
"d2": (["rev_mismatch"], "rev_mismatch", {}),
|
||||
"d3": (["not_found"], "not_found", {}),
|
||||
"d4": ([], "attached", {}),
|
||||
"d5": (["lookup_failed"], "lookup_failed", {}),
|
||||
"d6": (["drawing_needed"], "needed", {}),
|
||||
"d7": (["not_released"], "not_released", {}),
|
||||
"d8": (["hand_drawing"], "hand", {"revOk": True, "rev_confirmed": True}),
|
||||
"d9": ([], "attached", {"revOk": True, "rev_confirmed": True}),
|
||||
}
|
||||
for key, (flags, state, vis) in visual.items():
|
||||
mk(key, "f3")
|
||||
run(
|
||||
s,
|
||||
"UPDATE packets SET source_kind = 'visual', flags = %s, drawing_state = %s::drawing_state,"
|
||||
" visual = %s WHERE id = %s",
|
||||
(flags, state, Jsonb(vis), w.k[key]),
|
||||
)
|
||||
# the warehouse: one waiting (overdue unit), one with an open short (unit due this week)
|
||||
mk("w1", "f1", send="warehouse")
|
||||
w2 = mk("w2", "f2", send="warehouse", bom=[{"item": 1, "qty": 2, "part_id": "PIPE-4", "description": "Pipe"}])
|
||||
ok(
|
||||
shop("wh.wade").post(
|
||||
f"/api/packets/{w2['id']}/bom/1/short", json={"qty": 1, "reason": "On order", "expect_open": False}
|
||||
)
|
||||
)
|
||||
# the shop: ready with an open change order, two at stations, one ready in 06 on the second project
|
||||
issue_co(shop, mk("r1", "f3", send="03"), ["03"])
|
||||
a1 = mk("a1", "f1", send="03")
|
||||
ok(step(shop("sup.wes"), a1, "assign", station_id=shop.stall("03", "Stall 1"), welder_id=shop.uid("weld.andy")))
|
||||
a2 = mk("a2", "f3", send="03")
|
||||
a2 = ok(
|
||||
step(shop("sup.wes"), a2, "assign", station_id=shop.stall("03", "Stall 2"), welder_id=shop.uid("dup.dave"))
|
||||
)
|
||||
ok(step(shop("dup.dave"), a2, "start"))
|
||||
mk("s6", "g1", send="06")
|
||||
# later statuses by SQL
|
||||
for key, unit, status, routed in (
|
||||
("rv1", "f2", "review", None),
|
||||
("q1", "f3", "qc", None),
|
||||
("c1", "f3", "complete", "databook"),
|
||||
("c2", "f3", "complete", "shipping"),
|
||||
("fl1", "f3", "filed", "databook"),
|
||||
):
|
||||
mk(key, unit, send="03")
|
||||
run(
|
||||
s,
|
||||
"UPDATE packets SET status = %s::packet_status, routed_to = %s::route_kind, status_at = now()"
|
||||
" WHERE id = %s",
|
||||
(status, routed, w.k[key]),
|
||||
)
|
||||
yield w
|
||||
|
||||
|
||||
def cards(w: FilterWorld, sam: str = "admin.ann", **params: Any) -> list[dict[str, Any]]:
|
||||
out = get_ok(w.shop(sam), "/api/packets", **params)
|
||||
got: list[dict[str, Any]] = out["packets"]
|
||||
return got
|
||||
|
||||
|
||||
def ids(w: FilterWorld, sam: str = "admin.ann", **params: Any) -> set[int]:
|
||||
got = [int(c["id"]) for c in cards(w, sam, **params)]
|
||||
assert len(got) == len(set(got)), got
|
||||
return set(got)
|
||||
|
||||
|
||||
# ================================================================ status, department, station, worker
|
||||
|
||||
|
||||
STATUS_WORDS = {
|
||||
"draft": DRAFTS,
|
||||
"warehouse": ("w1", "w2"),
|
||||
"ready": ("r1", "s6"),
|
||||
"shop": ("r1", "s6", "a1", "a2", "rv1", "q1"),
|
||||
"station": ("a1", "a2"),
|
||||
"review": ("rv1",),
|
||||
"qc": ("q1",),
|
||||
"complete": ("c1", "c2"),
|
||||
"filed": ("fl1",),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("word", [*STATUS_WORDS, "open"])
|
||||
def test_status_words(world: FilterWorld, word: str) -> None:
|
||||
want = world.but("fl1") if word == "open" else world.ids(*STATUS_WORDS[word])
|
||||
assert ids(world, status=word) == want
|
||||
|
||||
|
||||
def test_raw_status_list_still_works(world: FilterWorld) -> None:
|
||||
assert ids(world, status="ready,review") == world.ids("r1", "s6", "rv1")
|
||||
assert ids(world, status="assigned") == world.ids("a1")
|
||||
assert ids(world, status="in_progress") == world.ids("a2")
|
||||
|
||||
|
||||
def test_dept_station_worker(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
assert ids(world, dept="06") == world.ids("s6")
|
||||
assert ids(world, dept="03") == world.but("s6")
|
||||
assert ids(world, station_id=shop.stall("03", "Stall 1")) == world.ids("a1")
|
||||
assert ids(world, station_id=shop.stall("03", "Stall 2")) == world.ids("a2")
|
||||
assert ids(world, welder_id=shop.uid("weld.andy")) == world.ids("a1")
|
||||
assert ids(world, welder_id=shop.uid("dup.dave")) == world.ids("a2")
|
||||
assert ids(world, status="station", welder_id=shop.uid("dup.dave")) == world.ids("a2")
|
||||
assert ids(world, status="draft", welder_id=shop.uid("dup.dave")) == set()
|
||||
|
||||
|
||||
# ================================================================ quick flags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flags", "keys"),
|
||||
[
|
||||
("co", ("r1",)),
|
||||
("short", ("w2",)),
|
||||
("overdue", ("w1", "a1")),
|
||||
("week", ("w1", "a1", "w2", "rv1")),
|
||||
("short,week", ("w2",)),
|
||||
("co,short", ()),
|
||||
],
|
||||
)
|
||||
def test_quick_flags(world: FilterWorld, flags: str, keys: tuple[str, ...]) -> None:
|
||||
assert ids(world, flags=flags) == world.ids(*keys)
|
||||
|
||||
|
||||
def test_mine_per_screen(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
clerical = world.ids(*DRAFTS, "c1")
|
||||
assert ids(world, "clerk.clara", flags="mine") == clerical # the first screen of the roles
|
||||
assert ids(world, "clerk.clara", flags="mine", mine_screen="clerical") == clerical
|
||||
assert ids(world, "wh.wade", flags="mine", mine_screen="warehouse") == world.ids("w1", "w2")
|
||||
assert ids(world, "sup.wes", flags="mine", mine_screen="supervisor") == world.but("s6")
|
||||
assert ids(world, "multi.mia", flags="mine", mine_screen="supervisor") == world.ids("s6")
|
||||
assert ids(world, "multi.mia", flags="mine") == clerical # Clerical comes first in the screen order
|
||||
stall1 = shop.stall("03", "Stall 1")
|
||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall1) == world.ids("a1")
|
||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == set() # no station picked
|
||||
assert ids(world, "qc.quinn", flags="mine", mine_screen="qc") == world.ids("q1")
|
||||
assert ids(world, "pm.paula", flags="mine") == world.every
|
||||
assert ids(world, "admin.ann", flags="mine", mine_screen="admin") == world.every
|
||||
# mine with another flag narrows further
|
||||
assert ids(world, "wh.wade", flags="mine,short", mine_screen="warehouse") == world.ids("w2")
|
||||
|
||||
|
||||
def test_mine_never_widens(world: FilterWorld) -> None:
|
||||
"""A screen the caller does not have gives nothing more than the caller's own screens would."""
|
||||
got = ids(world, "clerk.clara", flags="mine", mine_screen="qc")
|
||||
assert got in (set(), world.ids("q1"), world.ids(*DRAFTS, "c1"))
|
||||
|
||||
|
||||
# ================================================================ flag kinds
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flag", "keys"),
|
||||
[
|
||||
("any", ("d1", "d2", "d3", "d5", "d6", "d7", "d8")),
|
||||
("1", ("d1", "d2", "d3", "d5", "d6", "d7", "d8")),
|
||||
("rev", ("d2",)),
|
||||
("rev_mismatch", ("d2",)),
|
||||
("unconf", ("d4",)),
|
||||
("hand", ("d1", "d8")),
|
||||
("hand_upload", ("d8",)),
|
||||
("none", ("d7",)),
|
||||
("vault", ("d3",)),
|
||||
("read", ("d5",)),
|
||||
("wait", ("d6",)),
|
||||
],
|
||||
)
|
||||
def test_flag_kinds(world: FilterWorld, flag: str, keys: tuple[str, ...]) -> None:
|
||||
assert ids(world, flag=flag) == world.ids(*keys)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["no", "0"])
|
||||
def test_not_flagged(world: FilterWorld, flag: str) -> None:
|
||||
assert ids(world, flag=flag) == world.but("d1", "d2", "d3", "d5", "d6", "d7", "d8")
|
||||
|
||||
|
||||
# ================================================================ sorts
|
||||
|
||||
|
||||
def test_sort_updated_is_the_default(world: FilterWorld) -> None:
|
||||
for got in (cards(world), cards(world, sort="updated")):
|
||||
upd = [c["updated_at"] for c in got]
|
||||
assert upd == sorted(upd, reverse=True)
|
||||
assert [c["id"] for c in cards(world)] == [c["id"] for c in cards(world, sort="updated")]
|
||||
|
||||
|
||||
def test_sort_project(world: FilterWorld) -> None:
|
||||
got = cards(world, sort="project")
|
||||
keys = [(c["project"]["number"].encode(), c["unit"]["number"].encode(), c["op"] or "") for c in got]
|
||||
assert keys == sorted(keys) # C collation: byte order
|
||||
assert got[0]["project"]["number"] == "L60" # "L60" sorts before "L700"
|
||||
|
||||
|
||||
def test_sort_due(world: FilterWorld) -> None:
|
||||
got = cards(world, sort="due")
|
||||
due = [c["due_effective"] for c in got]
|
||||
assert due == sorted(due, key=lambda d: (d is None, d or ""))
|
||||
assert got[-1]["id"] == world.k["s6"] # no due date: last
|
||||
assert {got[0]["id"], got[1]["id"]} == world.ids("w1", "a1") # the overdue unit first
|
||||
|
||||
|
||||
def test_sort_status(world: FilterWorld) -> None:
|
||||
order = [STATUS_ORDER.index(c["status"]) for c in cards(world, sort="status")]
|
||||
assert order == sorted(order)
|
||||
|
||||
|
||||
def test_sort_dept(world: FilterWorld) -> None:
|
||||
got = cards(world, sort="dept")
|
||||
depts = [c["dept"]["code"] for c in got]
|
||||
assert depts == sorted(depts)
|
||||
in03 = [c["updated_at"] for c in got if c["dept"]["code"] == "03"]
|
||||
assert in03 == sorted(in03, reverse=True)
|
||||
|
||||
|
||||
def test_sort_unknown_is_refused_or_default(world: FilterWorld) -> None:
|
||||
r = world.shop("admin.ann").get("/api/packets", params={"sort": "drop table"})
|
||||
assert r.status_code in (200, 400, 422), r.text
|
||||
if r.status_code == 200:
|
||||
assert [c["id"] for c in r.json()["packets"]] == [c["id"] for c in cards(world)]
|
||||
|
||||
|
||||
# ================================================================ facets
|
||||
|
||||
|
||||
def test_facets_follow_the_project_and_unit_only(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
plain = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"])
|
||||
assert "facets" not in plain
|
||||
out = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"], facets=1)
|
||||
f = out["facets"]
|
||||
assert [(x["name"], x["dept_code"]) for x in f["stations"]] == [("Stall 1", "03"), ("Stall 2", "03")]
|
||||
assert {x["id"] for x in f["stations"]} == {shop.stall("03", "Stall 1"), shop.stall("03", "Stall 2")}
|
||||
assert [x["display_name"] for x in f["workers"]] == ["Andy Welder", "Dave Dup"]
|
||||
assert {x["id"] for x in f["workers"]} == {shop.uid("weld.andy"), shop.uid("dup.dave")}
|
||||
# other filters do not narrow the facets
|
||||
narrowed = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"], status="draft", facets=1)
|
||||
assert narrowed["packets"] and all(c["status"] == "draft" for c in narrowed["packets"])
|
||||
assert narrowed["facets"] == f
|
||||
unit = get_ok(shop("admin.ann"), "/api/packets", unit_id=world.units["f1"], facets=1)["facets"]
|
||||
assert [x["name"] for x in unit["stations"]] == ["Stall 1"]
|
||||
assert [x["display_name"] for x in unit["workers"]] == ["Andy Welder"]
|
||||
other = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fb"], facets=1)["facets"]
|
||||
assert other == {"stations": [], "workers": []}
|
||||
|
||||
|
||||
# ================================================================ the boards
|
||||
|
||||
|
||||
def board_ids(b: dict[str, Any]) -> set[int]:
|
||||
out = {int(c["id"]) for col in b["columns"].values() for c in col}
|
||||
for d in b.get("depts", []):
|
||||
for st in d["stations"]:
|
||||
out |= {int(c["id"]) for c in st["packets"]}
|
||||
return out
|
||||
|
||||
|
||||
def steady(k: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: v for key, v in k.items() if key != "elapsed_ms"}
|
||||
|
||||
|
||||
def test_shop_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
wes = shop("sup.wes")
|
||||
base = get_ok(wes, "/api/shop/board")
|
||||
assert "facets" in base and set(base["facets"]) == {"stations", "workers"}
|
||||
assert {"Stall 1", "Stall 2"} <= {x["name"] for x in base["facets"]["stations"]}
|
||||
assert world.ids("r1", "a1", "a2", "rv1", "q1") <= board_ids(base)
|
||||
cases: list[tuple[dict[str, Any], set[int]]] = [
|
||||
({"station_id": shop.stall("03", "Stall 1")}, world.ids("a1")),
|
||||
({"welder_id": shop.uid("dup.dave")}, world.ids("a2")),
|
||||
({"q": "ZXa2"}, world.ids("a2")),
|
||||
({"status": "review"}, world.ids("rv1")),
|
||||
({"status": "station"}, world.ids("a1", "a2")),
|
||||
({"flags": "co"}, world.ids("r1")),
|
||||
({"flags": "week"}, world.ids("w1", "a1", "w2", "rv1")), # the warehouse column holds w1 and w2
|
||||
({"q": "nothing-matches-this"}, set()),
|
||||
]
|
||||
for params, want in cases:
|
||||
b = get_ok(wes, "/api/shop/board", **params)
|
||||
assert board_ids(b) == want, params
|
||||
# the KPIs follow the project and unit only (elapsed_ms is a running clock, so it moves between calls)
|
||||
assert steady(b["kpis"]) == steady(base["kpis"]), params
|
||||
assert "facets" in b
|
||||
|
||||
|
||||
def test_stall_board_filters(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
andy = shop("weld.andy")
|
||||
stall1 = shop.stall("03", "Stall 1")
|
||||
base = get_ok(andy, "/api/shop/stall", station_id=stall1)
|
||||
assert {c["id"] for c in base["mine"]} == world.ids("a1") and "facets" in base
|
||||
assert get_ok(andy, "/api/shop/stall", station_id=stall1, q="ZXa1")["mine"][0]["id"] == world.k["a1"]
|
||||
none = get_ok(andy, "/api/shop/stall", station_id=stall1, q="nothing-matches-this")
|
||||
assert none["mine"] == [] and none["submitted"] == []
|
||||
assert get_ok(andy, "/api/shop/stall", station_id=stall1, status="review")["mine"] == []
|
||||
|
||||
|
||||
def test_warehouse_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||
wade = world.shop("wh.wade")
|
||||
base = get_ok(wade, "/api/warehouse/board")
|
||||
assert world.ids("w1", "w2") <= board_ids(base) and "facets" in base
|
||||
for params, want in (
|
||||
({"flags": "short"}, world.ids("w2")),
|
||||
({"q": "ZXw1"}, world.ids("w1")),
|
||||
({"flags": "overdue"}, world.ids("w1")),
|
||||
({"flags": "mine"}, world.ids("w1", "w2")),
|
||||
):
|
||||
b = get_ok(wade, "/api/warehouse/board", **params)
|
||||
assert board_ids(b) & world.ids("w1", "w2") == want, params
|
||||
assert b["kpis"] == base["kpis"], params
|
||||
|
||||
|
||||
def test_qc_home_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||
quinn = world.shop("qc.quinn")
|
||||
base = get_ok(quinn, "/api/qc/home")
|
||||
assert [c["id"] for c in base["columns"]["ready"]] == [world.k["q1"]] and "facets" in base
|
||||
found = get_ok(quinn, "/api/qc/home", q="ZXq1")
|
||||
assert [c["id"] for c in found["columns"]["ready"]] == [world.k["q1"]]
|
||||
none = get_ok(quinn, "/api/qc/home", q="nothing-matches-this")
|
||||
assert none["columns"]["ready"] == []
|
||||
assert none["kpis"] == base["kpis"] and found["kpis"] == base["kpis"]
|
||||
other = get_ok(quinn, "/api/qc/home", project_id=world.projects["fb"])
|
||||
assert other["columns"]["ready"] == []
|
||||
|
||||
|
||||
def test_filters_never_widen_access(world: FilterWorld) -> None:
|
||||
"""A board still refuses who it refused; a filter cannot open another department's board."""
|
||||
r = world.shop("multi.mia").get("/api/shop/board", params={"dept": "03", "status": "open"})
|
||||
assert r.status_code == 403, r.text
|
||||
r = world.shop("weld.andy").get("/api/shop/board", params={"flags": "mine"})
|
||||
assert r.status_code == 403, r.text
|
||||
@@ -8,9 +8,13 @@ stamp per mode. Nothing here reads VISUAL or M-Files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -31,7 +35,8 @@ from qchelpers import g
|
||||
from shophelpers import Shop, open_shop, run
|
||||
|
||||
from packetstation.pdf import databook, stamps
|
||||
from packetstation.pdf.style import clean
|
||||
from packetstation.pdf.doc import PAD_H, Doc
|
||||
from packetstation.pdf.style import L, clean, fmt_clock_hhmm, fmt_clock_hms, round_half_up
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
@@ -40,6 +45,7 @@ NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
||||
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
|
||||
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
|
||||
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
|
||||
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -61,6 +67,17 @@ def build(shop: Shop, pid: int, mode: str = "preview", **kw: Any) -> Any:
|
||||
)
|
||||
|
||||
|
||||
def dump(name: str, built: Any, pkt: dict[str, Any]) -> None:
|
||||
"""With DATABOOK_DUMP_DIR set to a folder, keep the book and the packet read there (for the side by side
|
||||
comparison with the old build's data book). Off by default."""
|
||||
out = DUMP_DIR
|
||||
if not out:
|
||||
return
|
||||
Path(out).mkdir(parents=True, exist_ok=True)
|
||||
(Path(out) / f"{name}.pdf").write_bytes(built.bytes)
|
||||
(Path(out) / f"{name}.json").write_text(json.dumps({"packet": pkt, "index": built.index}, default=str, indent=1))
|
||||
|
||||
|
||||
def index(built: Any) -> list[dict[str, Any]]:
|
||||
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
|
||||
return [{k: g(r, k) for k in keys} for r in built.index]
|
||||
@@ -74,6 +91,7 @@ def test_data_book_preview(shop: Shop) -> None:
|
||||
pkt = rich_complete(shop)
|
||||
pid = pkt["id"]
|
||||
built = build(shop, pid)
|
||||
dump("preview", built, pkt)
|
||||
data: bytes = built.bytes
|
||||
assert data[:5] == b"%PDF-"
|
||||
reader = pdf_reader(data)
|
||||
@@ -141,6 +159,17 @@ def test_data_book_preview(shop: Shop) -> None:
|
||||
assert heat in trace, heat
|
||||
assert "open items" in trace.lower() and "item 3" in trace
|
||||
|
||||
# phase 8 comparison with the old build's book (each line one fix)
|
||||
assert '(9" x 11")' in index_text # a letter sheet is 8.5 in wide: Math.round gives 9
|
||||
assert re.search(r"\b\d\d:\d\d WELD TIME", flat(texts[0])), flat(texts[0]) # HH:MM as the old cover
|
||||
weld_page = next(r for r in rows if str(r["doc"]).startswith("Weld log"))["page"]
|
||||
assert re.search(r"Time recorded \d\d:\d\d:\d\d \(target 4\.5 h\)", flat(texts[int(weld_page) - 1]))
|
||||
qc_page = next(r for r in rows if r["section"] == 4)["page"]
|
||||
qc_lines = texts[int(qc_page) - 1].splitlines()
|
||||
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
|
||||
hist_page = next(r for r in rows if r["section"] == 7)["page"]
|
||||
assert "CO-1 " in flat(texts[int(hist_page) - 1]) and "documents changed" in flat(texts[int(hist_page) - 1])
|
||||
|
||||
# metadata
|
||||
meta = reader.metadata
|
||||
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
|
||||
@@ -199,3 +228,49 @@ def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
|
||||
s5 = [r for r in rows if r["section"] == 5]
|
||||
assert s5[0]["doc"].startswith("ITP"), s5
|
||||
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 8: against the old build's book
|
||||
|
||||
|
||||
def test_co_summary_of_a_manual_change_without_sheets_reads_documents_changed() -> None:
|
||||
co: dict[str, Any] = {"origin": "manual", "review": "approved", "reason": "Nozzle moved", "items": []}
|
||||
assert databook.co_summary(co) == "documents changed" # the reason has its own column, as in coSummary()
|
||||
co["items"] = [{"old_dwg": "D-5001", "old_rev": "A", "new_rev": "B"}]
|
||||
assert databook.co_summary(co) == "D-5001 Rev A -> Rev B"
|
||||
|
||||
|
||||
def test_clock_and_sheet_size_forms_of_the_old_build() -> None:
|
||||
assert fmt_clock_hms(48_112) == "00:00:48" and fmt_clock_hms(3_725_000) == "01:02:05"
|
||||
assert fmt_clock_hhmm(48_112) == "00:00" and fmt_clock_hhmm(37_800_000) == "10:30"
|
||||
assert fmt_clock_hhmm(None) == "00:00"
|
||||
assert round_half_up(612 / 72) == 9 and round_half_up(792 / 72) == 11 and round_half_up(2448 / 72) == 34
|
||||
|
||||
|
||||
def _rows_height(rows: list[list[Any]], cols: dict[int, dict[str, Any]]) -> float:
|
||||
d = Doc(("T", "S"))
|
||||
return d.table(None, rows, 200.0, cols) - 200.0
|
||||
|
||||
|
||||
def test_table_cells_wrap_where_autotable_did() -> None:
|
||||
one_line = 8.3 * 1.15 + 2 * 4.2
|
||||
# 79.4 pt of text in an 86 pt column: one line in the old book (autotable's 1 pt slack, jsPDF's widths)
|
||||
assert abs(_rows_height([["10/18/2026 11:32 AM"]], {0: {"w": 86}}) - one_line) < 0.01
|
||||
# clearly too long still wraps
|
||||
assert _rows_height([["10/18/2026 11:32 AM and more"]], {0: {"w": 86}}) > one_line * 1.5
|
||||
|
||||
|
||||
def test_table_heads_sit_left_over_centred_columns() -> None:
|
||||
from pypdf import PdfReader
|
||||
|
||||
d = Doc(("T", "S"))
|
||||
d.table(["ITEM", "QTY", "TEXT"], [["1", "2", "x"]], 200.0, {0: {"w": 30, "align": "center"}, 1: {"w": 30}})
|
||||
xs: dict[str, float] = {}
|
||||
|
||||
def visit(text: str, cm: Any, tm: Any, font: Any, size: Any) -> None:
|
||||
if text.strip() in ("ITEM", "QTY"):
|
||||
xs[text.strip()] = float(tm[4])
|
||||
|
||||
PdfReader(io.BytesIO(d.finish())).pages[0].extract_text(visitor_text=visit)
|
||||
assert xs["ITEM"] == pytest.approx(L + PAD_H, abs=0.5), xs # autotable column styles style the body only
|
||||
assert xs["QTY"] == pytest.approx(L + 30 + PAD_H, abs=0.5), xs
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Phase 8 server smoke: one small world through every new read (the find bar on the list and the boards, the PM
|
||||
reports, the exports) so the server agent's shapes are exercised end to end. The detailed checks live in
|
||||
test_search.py, test_filters.py, test_pm_api.py, test_pm_export.py and test_presence.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import app_conn, needs_pg
|
||||
from pypdf import PdfReader
|
||||
from shophelpers import Shop, draft, make_unit, ok, open_shop, ready_packet, run, set_running_since, start, to_station
|
||||
from shophelpers import to_warehouse as send_to_wh
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
||||
settings = shop.settings
|
||||
project_id, u1 = make_unit(settings, "L670", "052")
|
||||
with app_conn(settings) as conn:
|
||||
u2 = conn.execute(
|
||||
"INSERT INTO units (project_id, lot, name, status, due) VALUES (%s, '053', 'Unit two', 'open',"
|
||||
" current_date - 2) RETURNING id",
|
||||
(project_id,),
|
||||
).fetchone()[0]
|
||||
conn.execute("UPDATE projects SET due = current_date + 30 WHERE id = %s", (project_id,))
|
||||
|
||||
p_draft = draft(shop, u1, title="Inlet nozzle =SUM(A1)")
|
||||
p_wh = send_to_wh(shop, draft(shop, u1, drawing="D-7000"))
|
||||
ok(
|
||||
shop("wh.wade").post(
|
||||
f"/api/packets/{p_wh['id']}/wh",
|
||||
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
|
||||
)
|
||||
)
|
||||
p_st = start(shop, to_station(shop, ready_packet(shop, u2)))
|
||||
set_running_since(settings, p_st["id"], 6 * 3600)
|
||||
with app_conn(settings) as conn:
|
||||
line = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 2", (p_wh["id"],)).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, ref, eta)"
|
||||
" VALUES (%s, %s, 2, 2, 'Not in stock', 'PO-881', current_date - 1)",
|
||||
(p_wh["id"], line),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
|
||||
" VALUES (%s, now() - interval '3 days', 'rejected', 'Quinn QC', 'NCR-12 · undercut', '03', '{}')",
|
||||
(p_st["id"],),
|
||||
)
|
||||
co = conn.execute(
|
||||
"INSERT INTO change_orders (packet_id, co_no, origin, review, ecn, reason, issued_at)"
|
||||
" VALUES (%s, 1, 'manual', 'approved', 'ECN-77', 'Rev C', now() - interval '4 days') RETURNING id",
|
||||
(p_st["id"],),
|
||||
).fetchone()[0]
|
||||
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (co,))
|
||||
run(settings, "UPDATE packets SET wps = 'WPS-99' WHERE id = %s", (p_draft["id"],))
|
||||
|
||||
sup = shop("sup.wes")
|
||||
# The find bar on the list
|
||||
got = ok(sup.get("/api/packets", params={"q": "lot 52", "project_id": project_id, "facets": 1}))
|
||||
ids = {c["id"] for c in got["packets"]}
|
||||
assert p_draft["id"] in ids and p_st["id"] not in ids, ids
|
||||
assert len(got["facets"]["workers"]) == 1 and len(got["facets"]["stations"]) == 1
|
||||
assert ok(sup.get("/api/packets", params={"q": "HT-20"}))["packets"][0]["id"] == p_st["id"]
|
||||
assert [c["id"] for c in ok(sup.get("/api/packets", params={"q": "ECN-77"}))["packets"]] == [p_st["id"]]
|
||||
assert [c["id"] for c in ok(sup.get("/api/packets", params={"status": "station"}))["packets"]] == [p_st["id"]]
|
||||
shorts = ok(sup.get("/api/packets", params={"flags": "short"}))["packets"]
|
||||
assert [c["id"] for c in shorts] == [p_wh["id"]]
|
||||
mine = ok(sup.get("/api/packets", params={"flags": "mine", "mine_screen": "supervisor"}))["packets"]
|
||||
assert {c["id"] for c in mine} == {p_draft["id"], p_wh["id"], p_st["id"]}
|
||||
over = ok(sup.get("/api/packets", params={"flags": "overdue"}))["packets"]
|
||||
assert [c["id"] for c in over] == [p_st["id"]]
|
||||
for sort in ("updated", "project", "due", "status", "dept"):
|
||||
assert len(ok(sup.get("/api/packets", params={"sort": sort, "project_id": project_id}))["packets"]) == 3
|
||||
hand = ok(sup.get("/api/packets", params={"flag": "hand"}))["packets"]
|
||||
assert {c["id"] for c in hand} == {p_draft["id"]}
|
||||
|
||||
# The boards keep their KPIs on the project scope
|
||||
b = ok(sup.get("/api/shop/board", params={"q": "nothing-matches-this"}))
|
||||
assert b["kpis"]["at_stations"] == 1 and not b["depts"][0]["stations"][0]["packets"]
|
||||
assert "facets" in b
|
||||
wh = ok(shop("wh.wade").get("/api/warehouse/board", params={"flags": "short"}))
|
||||
assert [c["id"] for c in wh["columns"]["hold"]] == [p_wh["id"]] and "facets" in wh
|
||||
qc = ok(shop("qc.quinn").get("/api/qc/home", params={"q": "zzz"}))
|
||||
assert qc["kpis"]["waiting"] == 0 and "facets" in qc
|
||||
stall = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": shop.stall(), "flags": "mine"}))
|
||||
assert [c["id"] for c in stall["mine"]] == [p_st["id"]]
|
||||
|
||||
# Project Management
|
||||
pmc = shop("pm.paula")
|
||||
ov = ok(pmc.get("/api/pm/overview", params={"period": "30"}))
|
||||
k = {x["key"]: x["n"] for x in ov["kpis"]}
|
||||
assert k["open"] == 3 and k["holds"] == 1 and k["shorts"] == 1 and k["cos"] == 1 and k["rejects"] == 1, k
|
||||
assert k["late_units"] == 1, k
|
||||
assert ov["scope"]["label"] == "All projects" and ov["summary"][0].startswith("3 open packets")
|
||||
assert sum(s["count"] for s in ov["pipeline"]) == 3
|
||||
assert [(r["cells"][0], r["health"]) for r in ov["attention"]["rows"]] == [("L670052", "risk"), ("L670053", "late")]
|
||||
pr = ok(pmc.get("/api/pm/projects", params={"expand": "all"}))
|
||||
assert [r["lvl"] for r in pr["tables"][0]["rows"]][:3] == [0, 1, 1]
|
||||
ag = ok(pmc.get("/api/pm/aging"))
|
||||
assert [t["key"] for t in ag["tables"]] == ["aging_steps", "aging_open", "aging_history"]
|
||||
hr = ok(pmc.get("/api/pm/hours"))
|
||||
over_rows = hr["tables"][3]["rows"]
|
||||
assert [r["pid"] for r in over_rows] == [p_st["id"]], hr
|
||||
ql = ok(pmc.get("/api/pm/quality"))
|
||||
assert [len(t["rows"]) for t in ql["tables"]][:4] == [1, 1, 1, 1], ql
|
||||
fd = ok(pmc.get("/api/pm/find", params={"sort": "project", "project_id": project_id}))
|
||||
assert fd["scope"]["label"] == "Project L670" and len(fd["packets"]) == 3
|
||||
|
||||
r = pmc.get("/api/pm/export.csv", params={"table": "all"})
|
||||
assert r.status_code == 200, r.text
|
||||
text = r.content.decode("utf-8")
|
||||
assert text.startswith("\ufeffPESCO Packet Station — All reports\r\n")
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
assert pmc.get("/api/pm/export.csv", params={"table": "nope"}).status_code == 404
|
||||
r = pmc.get("/api/pm/report.pdf")
|
||||
assert r.status_code == 200 and r.content.startswith(b"%PDF")
|
||||
|
||||
reader = PdfReader(io.BytesIO(r.content))
|
||||
assert len(reader.pages) > 5
|
||||
assert "PRODUCTION STATUS REPORT" in reader.pages[0].extract_text()
|
||||
assert all("Controlled copy" in pg.extract_text() for pg in reader.pages)
|
||||
with app_conn(settings) as conn:
|
||||
kinds = [x[0] for x in conn.execute("SELECT kind FROM print_log ORDER BY id")]
|
||||
acts = [x[0] for x in conn.execute("SELECT action FROM audit_events WHERE action = 'report exported'")]
|
||||
assert kinds == ["report-csv", "report-pdf"] and len(acts) == 2
|
||||
assert shop("clerk.clara").get("/api/pm/overview").status_code == 200
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Phase 8: the Project Management read endpoints against the PM world (pmhelpers.py).
|
||||
|
||||
The world is built once for the module; every test only reads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pmhelpers import (
|
||||
ATTENTION_HEAD,
|
||||
BY_PERIOD,
|
||||
FILED_KEYS,
|
||||
HEADS,
|
||||
HEALTH,
|
||||
HOLD_NOTE,
|
||||
HOLD_REASON,
|
||||
KPI_ALERT,
|
||||
KPI_KEYS,
|
||||
KPI_TABS,
|
||||
NOTES,
|
||||
OPEN_KEYS,
|
||||
PERIODS,
|
||||
PIPELINE_COUNTS,
|
||||
PM_STEPS,
|
||||
REJECT_NCR,
|
||||
REJECT_REASON,
|
||||
STEP_LABEL,
|
||||
STEP_OF,
|
||||
TAB_TABLES,
|
||||
UNIT_HEALTH,
|
||||
PmWorld,
|
||||
anon,
|
||||
by_key,
|
||||
get_ok,
|
||||
kpi_labels,
|
||||
module_shop,
|
||||
pm_world,
|
||||
project_rows,
|
||||
table,
|
||||
titles,
|
||||
unit_rows,
|
||||
)
|
||||
from pshelpers import needs_pg
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
ENDPOINTS = ("overview", "projects", "aging", "hours", "quality", "find")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
|
||||
with module_shop(tmp_path_factory.mktemp("pm_api")) as shop:
|
||||
yield pm_world(shop)
|
||||
|
||||
|
||||
def pm(w: PmWorld, what: str, sam: str = "pm.paula", **params: Any) -> dict[str, Any]:
|
||||
return get_ok(w.shop(sam), f"/api/pm/{what}", **params)
|
||||
|
||||
|
||||
def kpis(answer: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
return by_key(answer["kpis"])
|
||||
|
||||
|
||||
def ids(w: PmWorld, *keys: str) -> set[int]:
|
||||
return {w.k[k] for k in keys}
|
||||
|
||||
|
||||
# ================================================================ overview
|
||||
|
||||
|
||||
def test_overview_kpis(world: PmWorld) -> None:
|
||||
out = pm(world, "overview")
|
||||
assert [k["key"] for k in out["kpis"]] == list(KPI_KEYS)
|
||||
assert [k["label"] for k in out["kpis"]] == list(kpi_labels("90"))
|
||||
assert [k["tab"] for k in out["kpis"]] == list(KPI_TABS)
|
||||
assert {k["key"] for k in out["kpis"] if k["alert"]} == KPI_ALERT
|
||||
n = {k: v["n"] for k, v in kpis(out).items()}
|
||||
assert n == {
|
||||
"open": len(OPEN_KEYS),
|
||||
"late_units": 1,
|
||||
"risk_units": 3,
|
||||
"holds": 1,
|
||||
"cos": 1,
|
||||
"shorts": 1,
|
||||
"rejects": 1,
|
||||
"over": 1,
|
||||
"filed": 1,
|
||||
"lead": "10.0 d",
|
||||
}
|
||||
|
||||
|
||||
def test_overview_pipeline(world: PmWorld) -> None:
|
||||
out = pm(world, "overview")
|
||||
pipe = out["pipeline"]
|
||||
assert [(p["key"], p["label"]) for p in pipe] == list(PM_STEPS)
|
||||
assert {p["key"]: p["count"] for p in pipe} == PIPELINE_COUNTS
|
||||
slow = [p["key"] for p in pipe if p["slowest"]]
|
||||
assert slow == ["wh_hold"] # held five days; every other step is minutes old
|
||||
hold = by_key(pipe)["wh_hold"]
|
||||
assert hold["avg_s"] >= 5 * 86400 - 60 and hold["max_s"] >= hold["avg_s"]
|
||||
assert hold["oldest"]["pid"] == world.k["k_hold"] and hold["oldest"]["job"]
|
||||
empty = [p for p in pipe if p["count"] == 0]
|
||||
assert all(p["oldest"] is None for p in empty)
|
||||
|
||||
|
||||
def test_overview_summary(world: PmWorld) -> None:
|
||||
out = pm(world, "overview")
|
||||
lines = out["summary"]
|
||||
assert lines[0] == f"{len(OPEN_KEYS)} open packets in scope (All projects)."
|
||||
assert lines[1].startswith("Late: ") and world.unit_number(1) in lines[1]
|
||||
most = [x for x in lines if x.startswith("Most packets are at ")]
|
||||
assert most and most[0] in (
|
||||
f'Most packets are at "{STEP_LABEL["ready"]}" (2).',
|
||||
f'Most packets are at "{STEP_LABEL["station"]}" (2).',
|
||||
)
|
||||
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
|
||||
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
|
||||
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
|
||||
assert "1 packet is over target hours." in lines
|
||||
|
||||
|
||||
def test_overview_attention(world: PmWorld) -> None:
|
||||
att = pm(world, "overview")["attention"]
|
||||
assert att["title"] == "Units late or at risk" and att["head"] == ATTENTION_HEAD
|
||||
got = {int(r["unit_id"]): r["health"] for r in att["rows"]}
|
||||
assert got == {world.units[n]: h for n, h in UNIT_HEALTH.items() if h in ("late", "risk")}
|
||||
for r in att["rows"]:
|
||||
assert r["cells"][-1] == HEALTH[r["health"]]
|
||||
late = next(r for r in att["rows"] if r["health"] == "late")
|
||||
assert late["cells"][0] == world.unit_number(1)
|
||||
assert late["cells"][3] == "1 d over"
|
||||
|
||||
|
||||
# ================================================================ health and projects
|
||||
|
||||
|
||||
def test_health_per_unit_and_project(world: PmWorld) -> None:
|
||||
out = pm(world, "projects", expand="all")
|
||||
t = table(out, "projects")
|
||||
assert t["title"] == titles()["projects"] and t["head"] == HEADS["projects"]
|
||||
assert t["note"] == NOTES["projects"]
|
||||
units = unit_rows(t)
|
||||
for n, health in UNIT_HEALTH.items():
|
||||
row = units[world.units[n]]
|
||||
assert row["health"] == health, (n, row)
|
||||
assert row["cells"][-1] == HEALTH[health]
|
||||
assert row["project_id"] == world.projects["L670"]
|
||||
assert units[world.units[5]]["cells"][12].startswith("100")
|
||||
assert units[world.units[7]]["cells"][6] == "0"
|
||||
projects = project_rows(t)
|
||||
assert projects[world.projects["L670"]]["health"] == "late" # as healthy as its worst unit
|
||||
assert projects[world.projects["L670"]]["cells"][-1] == "Late"
|
||||
assert projects[world.projects["L489"]]["health"] == "empty"
|
||||
assert world.projects["L333"] not in projects # closed, no packet in scope
|
||||
assert units[world.units[52]]["health"] == "empty"
|
||||
# the order: each project row, then its units
|
||||
lvl = [(r["lvl"], r["project_id"]) for r in t["rows"]]
|
||||
first = lvl.index((0, world.projects["L670"]))
|
||||
assert all(x == (1, world.projects["L670"]) for x in lvl[first + 1 : first + 8])
|
||||
|
||||
|
||||
def test_drill_down(world: PmWorld) -> None:
|
||||
pa, pb = world.projects["L670"], world.projects["L489"]
|
||||
closed = pm(world, "projects")
|
||||
t = table(closed, "projects")
|
||||
assert {r["lvl"] for r in t["rows"]} == {0}
|
||||
assert set(project_rows(t)) == {pa, pb}
|
||||
one = table(pm(world, "projects", expand=str(pb)), "projects")
|
||||
assert set(unit_rows(one)) == {world.units[52]}
|
||||
both = table(pm(world, "projects", expand=f"{pa},{pb}"), "projects")
|
||||
assert set(unit_rows(both)) == set(world.units.values())
|
||||
picked = table(pm(world, "projects", project_id=pa), "projects")
|
||||
assert set(project_rows(picked)) == {pa}
|
||||
assert set(unit_rows(picked)) == {world.units[n] for n in range(1, 8)}
|
||||
# any filter other than the project: only units with a packet in scope
|
||||
at_qc = table(pm(world, "projects", status="qc", expand="all"), "projects")
|
||||
assert set(unit_rows(at_qc)) == {world.units[2]}
|
||||
assert pa in project_rows(at_qc)
|
||||
|
||||
|
||||
# ================================================================ aging, hours, quality
|
||||
|
||||
|
||||
def test_aging(world: PmWorld) -> None:
|
||||
out = pm(world, "aging")
|
||||
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["aging"])
|
||||
assert [p["key"] for p in out["pipeline"]] == [k for k, _ in PM_STEPS]
|
||||
for key in TAB_TABLES["aging"]:
|
||||
t = table(out, key)
|
||||
assert t["title"] == titles()[key] and t["head"] == HEADS[key] and t["note"] == NOTES[key]
|
||||
steps = {r["cells"][0]: r for r in table(out, "aging_steps")["rows"]}
|
||||
assert steps[STEP_LABEL["wh_hold"]]["cells"][1] == "1"
|
||||
assert steps[STEP_LABEL["ready"]]["cells"][1] == "2"
|
||||
opened = table(out, "aging_open")
|
||||
assert {int(r["pid"]) for r in opened["rows"]} == ids(world, *OPEN_KEYS)
|
||||
assert opened["rows"][0]["pid"] == world.k["k_hold"] and opened["rows"][0]["flag"] is True
|
||||
assert opened["rows"][0]["cells"][2] == STEP_LABEL["wh_hold"]
|
||||
fresh_rows = [r for r in opened["rows"] if r["pid"] in ids(world, "k_ready", "k_asg", "k_qc")]
|
||||
assert fresh_rows and not any(r["flag"] for r in fresh_rows)
|
||||
steps_of = {int(r["pid"]): r["cells"][2] for r in opened["rows"]}
|
||||
for key in OPEN_KEYS:
|
||||
assert steps_of[world.k[key]] == STEP_LABEL[STEP_OF[key]], key
|
||||
assert table(out, "aging_history")["rows"], "the moves made by the world"
|
||||
|
||||
|
||||
def test_hours(world: PmWorld) -> None:
|
||||
out = pm(world, "hours")
|
||||
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["hours"])
|
||||
for key in TAB_TABLES["hours"]:
|
||||
t = table(out, key)
|
||||
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
|
||||
assert table(out, "hours_over")["note"] == NOTES["hours_over"]
|
||||
dept = table(out, "hours_dept")["rows"]
|
||||
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
|
||||
# counted: the running packet, and every packet past review with target hours
|
||||
assert dept[0]["cells"][1] == "6"
|
||||
assert dept[0]["cells"][3] == "3.2" # ticket hours: the running packet's VISUAL rows only
|
||||
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
|
||||
assert dept[0]["cells"][7] == "1"
|
||||
over = table(out, "hours_over")["rows"]
|
||||
assert [r["pid"] for r in over] == [world.k["k_run"]]
|
||||
cells = over[0]["cells"]
|
||||
# 4 h stored plus the running span (2 h when the world was built, more by now)
|
||||
timer = float(cells[3])
|
||||
assert 6.0 <= timer < 8.0, cells
|
||||
assert cells[4] == "3.2" and cells[5] == "4.5" and cells[6] == f"+{timer - 4.5:.1f} h"
|
||||
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
|
||||
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
|
||||
assert set(workers) == {"Andy Welder", "—"}
|
||||
assert workers["Andy Welder"]["cells"][1] == "5" and workers["—"]["cells"][1] == "1"
|
||||
assert workers["Andy Welder"]["cells"][7] == "1"
|
||||
projects = table(out, "hours_project")["rows"]
|
||||
assert [r["cells"][0] for r in projects] == ["L670 Pump skids"]
|
||||
variance = [r["cells"][5] for r in table(out, "hours_worker")["rows"]]
|
||||
assert all(v.startswith(("+", "-", "0")) for v in variance)
|
||||
|
||||
|
||||
def test_quality(world: PmWorld) -> None:
|
||||
out = pm(world, "quality")
|
||||
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["quality"])
|
||||
for key in TAB_TABLES["quality"]:
|
||||
t = table(out, key)
|
||||
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
|
||||
rej = table(out, "quality_rejects")["rows"]
|
||||
assert [r["pid"] for r in rej] == [world.k["k_rev"]]
|
||||
assert rej[0]["cells"][2] == "—" # the reject was made before an inspector name was entered
|
||||
assert REJECT_REASON in rej[0]["cells"][3] and REJECT_NCR in rej[0]["cells"][3]
|
||||
shorts = table(out, "quality_shorts")["rows"]
|
||||
assert [r["pid"] for r in shorts] == [world.k["k_short"]]
|
||||
assert shorts[0]["flag"] is True and shorts[0]["cells"][5].endswith(" (late)")
|
||||
assert shorts[0]["cells"][3] == "On order" and shorts[0]["cells"][4] == "PO-77"
|
||||
assert shorts[0]["cells"][1].startswith("1 ") and shorts[0]["cells"][7] == STEP_LABEL["wh_pulling"]
|
||||
cos = table(out, "quality_cos")["rows"]
|
||||
assert [r["pid"] for r in cos] == [world.k["k_co"]]
|
||||
assert cos[0]["cells"][1].startswith("CO-1") and cos[0]["flag"] is True # open three days
|
||||
assert cos[0]["cells"][4] == "3.0 d"
|
||||
holds = table(out, "quality_holds")["rows"]
|
||||
assert [r["pid"] for r in holds] == [world.k["k_hold"]] and holds[0]["flag"] is True
|
||||
assert holds[0]["cells"][1:4] == [HOLD_REASON, HOLD_NOTE, "Wade Warehouse"]
|
||||
assert holds[0]["cells"][4] == "5.0 d"
|
||||
back = table(out, "quality_sentback")["rows"]
|
||||
assert [r["pid"] for r in back] == [world.k["k_rev"], world.k["k_draft"]] # newest first
|
||||
assert back[1]["cells"][3] == "Wes Super" and back[1]["cells"][4] == "Missing drawing"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["30", "90", "365", "all"])
|
||||
def test_every_period(world: PmWorld, period: str) -> None:
|
||||
want = BY_PERIOD[period]
|
||||
ov = pm(world, "overview", period=period)
|
||||
sc = ov["scope"]["period"]
|
||||
assert sc["value"] == period and sc["label"] == PERIODS[period]
|
||||
assert (sc["start"] is None) == (period == "all")
|
||||
assert [k["label"] for k in ov["kpis"]] == list(kpi_labels(period))
|
||||
n = {k: v["n"] for k, v in kpis(ov).items()}
|
||||
assert n["rejects"] == want["rejects"] and n["filed"] == want["filed"]
|
||||
if "lead" in want:
|
||||
assert n["lead"] == want["lead"]
|
||||
q = pm(world, "quality", period=period)
|
||||
assert len(table(q, "quality_sentback")["rows"]) == want["sentback"]
|
||||
assert len(table(q, "quality_rejects")["rows"]) == want["rejects"]
|
||||
assert table(q, "quality_rejects")["title"] == titles(period)["quality_rejects"]
|
||||
# the open counts do not follow the period
|
||||
assert n["open"] == len(OPEN_KEYS) and n["holds"] == 1
|
||||
|
||||
|
||||
# ================================================================ find, scope, access
|
||||
|
||||
|
||||
def test_find_sorted(world: PmWorld) -> None:
|
||||
every = ids(world, *OPEN_KEYS, *FILED_KEYS)
|
||||
out = pm(world, "find", sort="project")
|
||||
got = [c["id"] for c in out["packets"]]
|
||||
assert set(got) == every and len(got) == len(every)
|
||||
units = [c["unit"]["number"] for c in out["packets"]]
|
||||
assert units == sorted(units)
|
||||
due = pm(world, "find", sort="due")["packets"]
|
||||
assert due[0]["unit"]["number"] == world.unit_number(1)
|
||||
late = [c["due_effective"] for c in due]
|
||||
assert late == sorted(late, key=lambda d: (d is None, d or ""))
|
||||
upd = [c["updated_at"] for c in pm(world, "find")["packets"]]
|
||||
assert upd == sorted(upd, reverse=True)
|
||||
scoped = pm(world, "find", unit_id=world.units[2])["packets"]
|
||||
assert {c["id"] for c in scoped} == ids(world, "k_rev", "k_qc")
|
||||
|
||||
|
||||
def test_scope_label(world: PmWorld) -> None:
|
||||
pa = world.projects["L670"]
|
||||
sc = pm(world, "overview")["scope"]
|
||||
assert sc["label"] == "All projects" and sc["project"] is None and sc["unit"] is None
|
||||
assert sc["count"] == len(pm(world, "find")["packets"])
|
||||
sc = pm(world, "overview", project_id=pa)["scope"]
|
||||
assert sc["label"] == "Project L670"
|
||||
assert sc["project"] == {"id": pa, "number": "L670", "name": "Pump skids"}
|
||||
sc = pm(world, "overview", project_id=pa, unit_id=world.units[1])["scope"]
|
||||
assert sc["label"].startswith("Unit L670001") and sc["unit"] == {"id": world.units[1], "number": "L670001"}
|
||||
assert sc["count"] == 2
|
||||
sc = pm(world, "overview", q="spool")["scope"]
|
||||
assert sc["label"] == 'All projects · search "spool"'
|
||||
sc = pm(world, "overview", project_id=pa, dept="03", q="held")["scope"]
|
||||
parts = sc["label"].split(" · ")
|
||||
assert parts[0] == "Project L670" and parts[-1] == 'search "held"' and len(parts) == 3
|
||||
assert sc["count"] == 1
|
||||
flags = pm(world, "overview", flags="short")["scope"]
|
||||
assert flags["label"].startswith("All projects · ") and flags["count"] == 1
|
||||
# mine is ignored on the PM screens
|
||||
assert pm(world, "overview", flags="mine")["scope"]["count"] == sc_count_all(world)
|
||||
|
||||
|
||||
def sc_count_all(world: PmWorld) -> int:
|
||||
return int(pm(world, "overview")["scope"]["count"])
|
||||
|
||||
|
||||
def test_scope_follows_the_project(world: PmWorld) -> None:
|
||||
pb = world.projects["L489"]
|
||||
out = pm(world, "overview", project_id=pb)
|
||||
n = {k: v["n"] for k, v in kpis(out).items()}
|
||||
assert n["open"] == 0 and n["holds"] == 0 and n["late_units"] == 0
|
||||
assert out["attention"]["rows"] == []
|
||||
q = pm(world, "quality", project_id=pb)
|
||||
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sam", ["pm.paula", "clerk.clara", "weld.andy"])
|
||||
def test_every_signed_in_role_reads(world: PmWorld, sam: str) -> None:
|
||||
for what in ENDPOINTS:
|
||||
r = world.shop(sam).get(f"/api/pm/{what}")
|
||||
assert r.status_code == 200, (sam, what, r.text)
|
||||
|
||||
|
||||
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
|
||||
c = anon(world.shop)
|
||||
for what in ENDPOINTS:
|
||||
r = c.get(f"/api/pm/{what}")
|
||||
assert r.status_code == 401, (what, r.text)
|
||||
for what in ENDPOINTS:
|
||||
r = world.shop("pm.paula").post(f"/api/pm/{what}", json={})
|
||||
assert r.status_code in (404, 405), (what, r.status_code)
|
||||
# the PM user cannot move a packet
|
||||
pkt = get_ok(world.shop("pm.paula"), f"/api/packets/{world.k['k_ready']}")
|
||||
r = world.shop("pm.paula").post(
|
||||
f"/api/packets/{pkt['id']}/transitions",
|
||||
json={"action": "assign", "expect": {"status": pkt["status"], "dept": pkt["dept"]["code"]}},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_bad_period_is_refused(world: PmWorld) -> None:
|
||||
r = world.shop("pm.paula").get("/api/pm/overview", params={"period": "7"})
|
||||
assert r.status_code in (400, 422), r.text
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Phase 8: the Project Management exports. The CSV per table and for every table (the header rows, the formula
|
||||
guard, the BOM and CRLF), the production status report PDF (pages, cover, the controlled copy stamp on every page),
|
||||
and one print_log row plus one audit row `report exported` for each export.
|
||||
|
||||
The PM world (pmhelpers.py) is built once for the module; one draft's title starts with `=`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import flat, page_texts, pdf_pages, q1, qall
|
||||
from pmhelpers import (
|
||||
FORMULA_TITLE,
|
||||
HEADS,
|
||||
PERIODS,
|
||||
PmWorld,
|
||||
anon,
|
||||
module_shop,
|
||||
pm_world,
|
||||
titles,
|
||||
)
|
||||
from pshelpers import needs_pg
|
||||
from shophelpers import run
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
TABLE_KEYS = tuple(HEADS) # projects, aging_*, hours_*, quality_*, jobs
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
|
||||
with module_shop(tmp_path_factory.mktemp("pm_export")) as shop:
|
||||
w = pm_world(shop)
|
||||
run(shop.settings, "UPDATE packets SET title = %s WHERE id = %s", (FORMULA_TITLE, w.k["k_draft"]))
|
||||
yield w
|
||||
|
||||
|
||||
def export(w: PmWorld, table: str, sam: str = "pm.paula", **params: Any) -> Any:
|
||||
return w.shop(sam).get("/api/pm/export.csv", params={"table": table, **params})
|
||||
|
||||
|
||||
def parse(body: bytes) -> list[list[str]]:
|
||||
assert body.startswith(b"\xef\xbb\xbf"), body[:10] # the BOM, for Excel
|
||||
assert b"\r\n" in body and body.count(b"\n") == body.count(b"\r\n"), "every line ends CRLF"
|
||||
return list(csv.reader(io.StringIO(body.decode("utf-8-sig"), newline="")))
|
||||
|
||||
|
||||
def filename(r: Any) -> str:
|
||||
cd = r.headers["content-disposition"]
|
||||
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
|
||||
assert m, cd
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def print_rows(w: PmWorld, kind: str) -> list[Any]:
|
||||
return qall(
|
||||
w.settings,
|
||||
"SELECT what, pages, by_name, packet_id, unit_id FROM print_log WHERE kind = %s ORDER BY id",
|
||||
(kind,),
|
||||
)
|
||||
|
||||
|
||||
def audit_rows(w: PmWorld) -> list[dict[str, Any]]:
|
||||
return [
|
||||
r[0] for r in qall(w.settings, "SELECT detail FROM audit_events WHERE action = 'report exported' ORDER BY id")
|
||||
]
|
||||
|
||||
|
||||
def header_ok(rows: list[list[str]], name: str, label: str = "All projects", period: str = "90") -> None:
|
||||
assert rows[0] == [f"PESCO Packet Station — {name}"], rows[0]
|
||||
assert rows[1] == ["Scope", label]
|
||||
assert rows[2] == ["History period", PERIODS[period]]
|
||||
assert rows[3][0] == "Exported" and rows[3][2] == "by" and rows[3][3] == "Paula PM" and rows[3][1]
|
||||
assert rows[4] == [] or rows[4] == [""]
|
||||
|
||||
|
||||
def table_at(rows: list[list[str]], title: str) -> tuple[list[str], list[list[str]]]:
|
||||
"""The head and the body rows of the table titled `title` in a parsed CSV."""
|
||||
i = rows.index([title])
|
||||
head = rows[i + 1]
|
||||
body = []
|
||||
for r in rows[i + 2 :]:
|
||||
if r in ([], [""]):
|
||||
break
|
||||
body.append(r)
|
||||
return head, body
|
||||
|
||||
|
||||
# ================================================================ CSV
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", TABLE_KEYS)
|
||||
def test_csv_per_table(world: PmWorld, key: str) -> None:
|
||||
r = export(world, key)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"].startswith("text/csv")
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
name = filename(r)
|
||||
assert name.startswith("PacketStation_") and name.endswith(f"_{world.today.isoformat()}.csv"), name
|
||||
assert re.fullmatch(r"PacketStation_[A-Za-z0-9_]+_\d{4}-\d{2}-\d{2}\.csv", name), name
|
||||
rows = parse(r.content)
|
||||
title = titles()[key]
|
||||
assert rows[0][0].startswith("PESCO Packet Station — ")
|
||||
head, body = table_at(rows, title)
|
||||
assert head == HEADS[key]
|
||||
assert all(len(b) == len(head) for b in body), key
|
||||
|
||||
|
||||
def test_csv_jobs_and_the_formula_guard(world: PmWorld) -> None:
|
||||
r = export(world, "jobs")
|
||||
assert r.status_code == 200, r.text
|
||||
assert filename(r) == f"PacketStation_Jobs_{world.today.isoformat()}.csv"
|
||||
rows = parse(r.content)
|
||||
header_ok(rows, "Jobs")
|
||||
head, body = table_at(rows, titles()["jobs"])
|
||||
assert head == HEADS["jobs"]
|
||||
assert len(body) == 14 # 12 open and 2 filed
|
||||
col = head.index("Title")
|
||||
assert "'" + FORMULA_TITLE in [b[col] for b in body]
|
||||
assert FORMULA_TITLE not in [b[col] for b in body]
|
||||
every = [c for b in rows for c in b]
|
||||
assert not any(c[:1] in ("=", "+", "-", "@", "\t") for c in every if c), "the formula guard on every cell"
|
||||
|
||||
|
||||
def test_csv_all(world: PmWorld) -> None:
|
||||
r = export(world, "all")
|
||||
assert r.status_code == 200, r.text
|
||||
assert filename(r) == f"PacketStation_All_reports_{world.today.isoformat()}.csv"
|
||||
rows = parse(r.content)
|
||||
header_ok(rows, "All reports")
|
||||
for key in TABLE_KEYS:
|
||||
head, _ = table_at(rows, titles()[key])
|
||||
assert head == HEADS[key], key
|
||||
_, units = table_at(rows, titles()["projects"])
|
||||
assert len(units) >= 2 + 8 # every project row and every unit (expand all)
|
||||
|
||||
|
||||
def test_csv_follows_scope_and_period(world: PmWorld) -> None:
|
||||
pa = world.projects["L670"]
|
||||
r = export(world, "quality_sentback", project_id=pa, period="365")
|
||||
assert r.status_code == 200, r.text
|
||||
rows = parse(r.content)
|
||||
assert rows[1] == ["Scope", "Project L670"]
|
||||
assert rows[2] == ["History period", PERIODS["365"]]
|
||||
_, body = table_at(rows, titles("365")["quality_sentback"])
|
||||
assert len(body) == 4
|
||||
|
||||
|
||||
def test_csv_unknown_table(world: PmWorld) -> None:
|
||||
r = export(world, "salaries")
|
||||
assert r.status_code == 404 and r.json()["code"] == "not_found", r.text
|
||||
|
||||
|
||||
def test_csv_is_logged(world: PmWorld) -> None:
|
||||
before_print = len(print_rows(world, "report-csv"))
|
||||
before_audit = len(audit_rows(world))
|
||||
assert export(world, "jobs").status_code == 200
|
||||
rows = print_rows(world, "report-csv")
|
||||
assert len(rows) == before_print + 1
|
||||
what, pages, by_name, packet_id, unit_id = rows[-1]
|
||||
assert what.endswith(" · CSV · All projects") and by_name == "Paula PM"
|
||||
assert packet_id is None and unit_id is None and pages is None
|
||||
audit = audit_rows(world)
|
||||
assert len(audit) == before_audit + 1
|
||||
detail = audit[-1]
|
||||
assert set(detail) >= {"name", "format", "scope", "period"}
|
||||
assert str(detail["format"]).lower() == "csv" and detail["scope"] == "All projects"
|
||||
assert detail["period"] in ("90", PERIODS["90"])
|
||||
|
||||
|
||||
def test_export_needs_a_session(world: PmWorld) -> None:
|
||||
c = anon(world.shop)
|
||||
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
|
||||
assert c.get("/api/pm/report.pdf").status_code == 401
|
||||
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 200
|
||||
|
||||
|
||||
# ================================================================ PDF
|
||||
|
||||
|
||||
def test_report_pdf(world: PmWorld) -> None:
|
||||
before = len(print_rows(world, "report-pdf"))
|
||||
before_audit = len(audit_rows(world))
|
||||
r = world.shop("pm.paula").get("/api/pm/report.pdf")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"].startswith("application/pdf")
|
||||
assert r.headers["content-disposition"].startswith("inline")
|
||||
assert filename(r) == f"PESCO_Production_Status_{world.today.isoformat()}.pdf"
|
||||
data = r.content
|
||||
assert data[:5] == b"%PDF-"
|
||||
pages = pdf_pages(data)
|
||||
assert pages > 5
|
||||
texts = [flat(t) for t in page_texts(data)]
|
||||
assert "PRODUCTION STATUS REPORT" in texts[0]
|
||||
assert "Packet Station · All projects" in texts[0]
|
||||
for i, t in enumerate(texts):
|
||||
assert "Controlled copy" in t, f"page {i + 1} has no stamp"
|
||||
body = texts[1:]
|
||||
for i, t in enumerate(body):
|
||||
assert "Production status report" in t, i
|
||||
assert "CONFIDENTIAL & PROPRIETARY" in t, i
|
||||
every = " ".join(texts)
|
||||
for part in ("SUMMARY", "PROJECTS", "FLOW", "LABOR", "ISSUES"):
|
||||
assert part in every, part
|
||||
assert "L670" in every
|
||||
# logged once with the page count
|
||||
rows = print_rows(world, "report-pdf")
|
||||
assert len(rows) == before + 1
|
||||
what, logged_pages, by_name, packet_id, _ = rows[-1]
|
||||
assert what == "Production status report · PDF · All projects"
|
||||
assert logged_pages == pages and by_name == "Paula PM" and packet_id is None
|
||||
audit = audit_rows(world)
|
||||
assert len(audit) == before_audit + 1 and str(audit[-1]["format"]).lower() == "pdf"
|
||||
|
||||
|
||||
def test_report_pdf_download_and_scope(world: PmWorld) -> None:
|
||||
r = world.shop("pm.paula").get(
|
||||
"/api/pm/report.pdf", params={"download": 1, "project_id": world.projects["L670"], "period": "all"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-disposition"].startswith("attachment")
|
||||
first = flat(page_texts(r.content)[0])
|
||||
assert "Packet Station · Project L670" in first
|
||||
row = q1(world.settings, "SELECT what FROM print_log WHERE kind = 'report-pdf' ORDER BY id DESC LIMIT 1")
|
||||
assert row[0] == "Production status report · PDF · Project L670"
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Phase 8: presence. Who is on which screen and packet, with the role shown; locked sessions drop out and come
|
||||
back on unlock; revoked sessions are gone at once. Driven through the EventHub directly (as test_events.py does)
|
||||
and through the API (`/api/events`, `/api/auth/lock`, `/api/auth/unlock`, the idle lock in `resolve`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pshelpers import ORIGIN_HEADERS, needs_pg, sign_in
|
||||
|
||||
from packetstation import db, events
|
||||
from packetstation.settings import Settings
|
||||
|
||||
USER_KEYS = {"user_id", "display_name", "role", "screen", "packet_id", "since"}
|
||||
|
||||
|
||||
def _sub(
|
||||
hub: events.EventHub,
|
||||
name: str,
|
||||
*,
|
||||
screen: str | None = None,
|
||||
packet_id: int | None = None,
|
||||
role: str = "Supervisor",
|
||||
user_id: Any = None,
|
||||
session_id: Any = None,
|
||||
) -> events.Subscriber:
|
||||
return hub.subscribe(
|
||||
session_id=session_id or uuid.uuid4(),
|
||||
user_id=user_id or uuid.uuid4(),
|
||||
display_name=name,
|
||||
screen=screen,
|
||||
packet_id=packet_id,
|
||||
role=role,
|
||||
)
|
||||
|
||||
|
||||
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
|
||||
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
|
||||
out = []
|
||||
while not q.empty():
|
||||
out.append(q.get_nowait())
|
||||
return out
|
||||
|
||||
|
||||
def _on(hub: events.EventHub, packet_id: int) -> list[tuple[str, str, str | None]]:
|
||||
return [(u["display_name"], u["role"], u["screen"]) for u in hub.presence()["users"] if u["packet_id"] == packet_id]
|
||||
|
||||
|
||||
def _names(hub: events.EventHub) -> list[str]:
|
||||
return [u["display_name"] for u in hub.presence()["users"]]
|
||||
|
||||
|
||||
def _session(hub: events.EventHub, sub: events.Subscriber, action: str) -> None:
|
||||
hub._deliver(json.dumps({"event": "session", "session_id": sub.session_id, "action": action}))
|
||||
|
||||
|
||||
# ================================================================ the hub
|
||||
|
||||
|
||||
def test_two_users_on_one_packet_see_each_other() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
wes = _sub(hub, "Wes Super", screen="supervisor", packet_id=7, role="Supervisor")
|
||||
andy = _sub(hub, "Andy Welder", screen="stall", packet_id=7, role="Weld Stall")
|
||||
_sub(hub, "Clara Clerk", screen="clerical", role="Clerical")
|
||||
users = hub.presence()["users"]
|
||||
assert all(set(u) == USER_KEYS for u in users), users
|
||||
assert all(u["since"] for u in users)
|
||||
assert sorted(_on(hub, 7)) == [
|
||||
("Andy Welder", "Weld Stall", "stall"),
|
||||
("Wes Super", "Supervisor", "supervisor"),
|
||||
]
|
||||
last = (await _drain(wes.queue))[-1]
|
||||
assert last[0] == "presence" and {u["display_name"] for u in last[1]["users"]} == {
|
||||
"Wes Super",
|
||||
"Andy Welder",
|
||||
"Clara Clerk",
|
||||
}
|
||||
assert (await _drain(andy.queue))[-1] == last
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_one_entry_per_user_screen_and_packet() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
uid = uuid.uuid4()
|
||||
_sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid)
|
||||
_sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid) # a second tab
|
||||
_sub(hub, "Wes Super", screen="supervisor", packet_id=8, user_id=uid)
|
||||
assert len(_on(hub, 7)) == 1 and len(_on(hub, 8)) == 1
|
||||
assert _names(hub) == ["Wes Super", "Wes Super"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_leaving_drops_out() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||
await _drain(wes.queue)
|
||||
hub.unsubscribe(andy)
|
||||
got = await _drain(wes.queue)
|
||||
assert got and got[-1][0] == "presence"
|
||||
assert [u["display_name"] for u in got[-1][1]["users"]] == ["Wes Super"]
|
||||
assert _on(hub, 7) == [("Wes Super", "Supervisor", None)]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_locked_hides_and_unlocked_brings_back() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||
await _drain(wes.queue)
|
||||
await _drain(andy.queue)
|
||||
|
||||
_session(hub, andy, "locked")
|
||||
assert _names(hub) == ["Wes Super"]
|
||||
to_wes = await _drain(wes.queue)
|
||||
assert [n for n, _ in to_wes] == ["presence"] # the others get presence, never the session event
|
||||
assert [u["display_name"] for u in to_wes[-1][1]["users"]] == ["Wes Super"]
|
||||
to_andy = await _drain(andy.queue)
|
||||
assert ("session", {"session_id": andy.session_id, "action": "locked"}) in to_andy
|
||||
|
||||
_session(hub, andy, "unlocked")
|
||||
assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"]
|
||||
to_wes = await _drain(wes.queue)
|
||||
assert to_wes and to_wes[-1][0] == "presence" and len(to_wes[-1][1]["users"]) == 2
|
||||
assert ("session", {"session_id": andy.session_id, "action": "unlocked"}) in await _drain(andy.queue)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_a_locked_session_hides_only_itself() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
uid = uuid.uuid4()
|
||||
tablet = _sub(hub, "Wes Super", packet_id=7, user_id=uid)
|
||||
_sub(hub, "Wes Super", packet_id=7, user_id=uid) # the same person on a second device
|
||||
_session(hub, tablet, "locked")
|
||||
assert _on(hub, 7) == [("Wes Super", "Supervisor", None)]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_revoked_is_gone_at_once() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||
await _drain(wes.queue)
|
||||
_session(hub, andy, "revoked")
|
||||
assert _names(hub) == ["Wes Super"] # before the stream has ended
|
||||
got = await _drain(wes.queue)
|
||||
assert got and got[-1][0] == "presence" and len(got[-1][1]["users"]) == 1
|
||||
assert ("session", {"session_id": andy.session_id, "action": "revoked"}) in await _drain(andy.queue)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_stream_forwards_lock_and_keeps_going() -> None:
|
||||
async def run() -> list[str]:
|
||||
hub = events.EventHub()
|
||||
sub = _sub(hub, "Wes Super", screen="supervisor")
|
||||
await _drain(sub.queue)
|
||||
|
||||
async def connected() -> bool:
|
||||
return False
|
||||
|
||||
gen = events.stream(hub, sub, hello={"session_id": sub.session_id}, is_disconnected=connected, ping_seconds=5)
|
||||
chunks = [await gen.__anext__()]
|
||||
_session(hub, sub, "locked")
|
||||
for _ in range(3):
|
||||
chunks.append(await asyncio.wait_for(gen.__anext__(), 2))
|
||||
if chunks[-1].startswith("event: session"):
|
||||
break
|
||||
_session(hub, sub, "revoked")
|
||||
rest = [c async for c in gen]
|
||||
assert hub.presence() == {"users": []}
|
||||
return chunks + rest
|
||||
|
||||
chunks = asyncio.run(run())
|
||||
assert chunks[0].startswith("event: hello\n")
|
||||
names = [c.split("\n", 1)[0] for c in chunks[1:]]
|
||||
assert "event: session" in names # the lock reached the locked session's own stream
|
||||
assert any('"action":"locked"' in c for c in chunks)
|
||||
|
||||
|
||||
def test_presence_by_screen() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
_sub(hub, "Wes Super", screen="supervisor")
|
||||
_sub(hub, "Lou Lead", screen="supervisor")
|
||||
_sub(hub, "Quinn QC", screen="qc", role="Q.C.")
|
||||
locked = _sub(hub, "Andy Welder", screen="stall", role="Weld Stall")
|
||||
_session(hub, locked, "locked")
|
||||
by = hub.presence_by_screen()
|
||||
assert sorted(u["display_name"] for u in by["supervisor"]) == ["Lou Lead", "Wes Super"]
|
||||
assert [u["display_name"] for u in by["qc"]] == ["Quinn QC"]
|
||||
assert not by.get("stall")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ================================================================ through the API
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||
from packetstation.main import create_app
|
||||
|
||||
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _signed(client: TestClient, sam: str) -> tuple[TestClient, str]:
|
||||
c = TestClient(client.app, headers=ORIGIN_HEADERS)
|
||||
r = sign_in(c, sam)
|
||||
assert r.status_code == 200, r.text
|
||||
return c, str(r.json()["session"]["id"])
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_events_stream_carries_the_role(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The stream body is replaced by one that answers the hub's presence and ends, so the request returns."""
|
||||
from packetstation.api import events as events_api
|
||||
|
||||
async def snapshot(
|
||||
hub: events.EventHub, sub: events.Subscriber, *, hello: dict[str, Any], **_: Any
|
||||
) -> AsyncIterator[str]:
|
||||
try:
|
||||
yield events.format_sse("presence", hub.presence())
|
||||
finally:
|
||||
hub.unsubscribe(sub)
|
||||
|
||||
monkeypatch.setattr(events_api, "stream", snapshot)
|
||||
|
||||
def users(c: TestClient, **params: Any) -> list[dict[str, Any]]:
|
||||
r = c.get("/api/events", params=params)
|
||||
assert r.status_code == 200, r.text
|
||||
line = next(x for x in r.text.splitlines() if x.startswith("data: "))
|
||||
out: list[dict[str, Any]] = json.loads(line[6:])["users"]
|
||||
return out
|
||||
|
||||
wes, _ = _signed(client, "sup.wes")
|
||||
me = users(wes, screen="supervisor", packet_id=5)
|
||||
assert [(u["display_name"], u["role"], u["screen"], u["packet_id"]) for u in me] == [
|
||||
("Wes Super", "Supervisor", "supervisor", 5)
|
||||
]
|
||||
assert me[0]["since"]
|
||||
# a screen the user does not have: no screen, the first role's label
|
||||
other = users(wes, screen="qc")
|
||||
assert [(u["role"], u["screen"]) for u in other] == [("Supervisor", None)]
|
||||
for sam, screen, role in (
|
||||
("pm.paula", "pm", "Project Mgmt"),
|
||||
("weld.andy", "stall", "Weld Stall"),
|
||||
("clerk.clara", "clerical", "Clerical"),
|
||||
("wh.wade", "warehouse", "Warehouse"),
|
||||
("qc.quinn", "qc", "Q.C."),
|
||||
("admin.ann", "admin", "Admin"),
|
||||
):
|
||||
c, _ = _signed(client, sam)
|
||||
assert [u["role"] for u in users(c, screen=screen)] == [role], sam
|
||||
paula, _ = _signed(client, "pm.paula")
|
||||
assert [u["role"] for u in users(paula)] == ["Project Mgmt"] # no screen: the primary role
|
||||
|
||||
|
||||
async def _wait_session(q: asyncio.Queue[events.Item], action: str) -> dict[str, Any]:
|
||||
while True:
|
||||
name, data = await asyncio.wait_for(q.get(), timeout=10)
|
||||
if name == "session" and data.get("action") == action:
|
||||
return data
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_lock_and_unlock_publish_session_events(client: TestClient, pg_settings: Settings) -> None:
|
||||
wes, sid = _signed(client, "sup.wes")
|
||||
|
||||
async def run() -> None:
|
||||
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
|
||||
sub = _sub(hub, "Wes Super", screen="supervisor", packet_id=3, session_id=sid)
|
||||
other = _sub(hub, "Andy Welder", screen="stall", packet_id=3, role="Weld Stall")
|
||||
hub.start()
|
||||
try:
|
||||
for _ in range(100):
|
||||
if hub.connected:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert hub.connected
|
||||
r = await asyncio.to_thread(wes.post, "/api/auth/lock")
|
||||
assert r.status_code == 204, r.text
|
||||
data = await _wait_session(sub.queue, "locked")
|
||||
assert data["session_id"] == sid
|
||||
assert _names(hub) == ["Andy Welder"]
|
||||
assert other.queue.qsize() >= 1 # the others got a presence update
|
||||
r = await asyncio.to_thread(
|
||||
wes.post, "/api/auth/unlock", json={"factor": "password", "secret": FAKE_PASSWORD}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
await _wait_session(sub.queue, "unlocked")
|
||||
assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"]
|
||||
finally:
|
||||
await asyncio.to_thread(hub.stop)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_idle_lock_publishes(client: TestClient, pg_settings: Settings) -> None:
|
||||
wes, sid = _signed(client, "sup.wes")
|
||||
|
||||
async def run() -> None:
|
||||
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
|
||||
sub = _sub(hub, "Wes Super", session_id=sid)
|
||||
hub.start()
|
||||
try:
|
||||
for _ in range(100):
|
||||
if hub.connected:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert hub.connected
|
||||
with db.connect_as("app", pg_settings, autocommit=True) as conn:
|
||||
conn.execute("UPDATE sessions SET last_seen_at = now() - interval '5 minutes' WHERE id = %s", (sid,))
|
||||
r = await asyncio.to_thread(wes.get, "/api/auth/me")
|
||||
assert r.status_code == 200 and r.json()["session"]["locked"] is True, r.text
|
||||
await _wait_session(sub.queue, "locked")
|
||||
assert _names(hub) == []
|
||||
finally:
|
||||
await asyncio.to_thread(hub.stop)
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Phase 8: the find bar search (`q=`), one test per field of the vocabulary, on `GET /api/packets`.
|
||||
|
||||
Every word must match some field; a word matches by substring, case insensitive. Each distinctive value below is
|
||||
on one packet only, so a search returns exactly the packets that carry it. The world is built once per module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pmhelpers import add_alias, make_project, make_unit, module_shop, quick_draft
|
||||
from pshelpers import needs_pg
|
||||
from shophelpers import Shop, ok, run, step
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
LONG = "Q" * 60 # a title of exactly 60 characters
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchWorld:
|
||||
shop: Shop
|
||||
k: dict[str, int] = field(default_factory=dict)
|
||||
every: set[int] = field(default_factory=set)
|
||||
|
||||
def ids(self, *keys: str) -> set[int]:
|
||||
return {self.k[x] for x in keys}
|
||||
|
||||
|
||||
def make(
|
||||
shop: Shop,
|
||||
w: SearchWorld,
|
||||
key: str,
|
||||
unit_id: int,
|
||||
title: str,
|
||||
*,
|
||||
ready_dept: str | None = None,
|
||||
**over: Any,
|
||||
) -> dict[str, Any]:
|
||||
pkt = quick_draft(shop, unit_id, title, n=len(w.k) + 1, send=ready_dept, **over)
|
||||
w.k[key] = int(pkt["id"])
|
||||
w.every.add(int(pkt["id"]))
|
||||
return pkt
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[SearchWorld]:
|
||||
with module_shop(tmp_path_factory.mktemp("search")) as shop:
|
||||
s = shop.settings
|
||||
w = SearchWorld(shop)
|
||||
p1 = make_project(s, "L489", "BPX separator", customer="BPX Energy", po="PO-4471")
|
||||
add_alias(s, p1, "OLD489X")
|
||||
u52, u53, u54 = (make_unit(s, p1, lot) for lot in ("052", "053", "054"))
|
||||
p2 = make_project(s, "L512", "Pump skid", customer="Vortex Oil", po="PO-9")
|
||||
u2 = make_unit(s, p2, "001")
|
||||
|
||||
make(shop, w, "title", u52, "Inlet spool", drawing="D-48810", rev="B")
|
||||
make(shop, w, "op", u53, "Bravo spool", op="HYDRO-TEST")
|
||||
make(shop, w, "wps", u53, "Charlie spool", wps="WPS-77X")
|
||||
make(shop, w, "cust", u54, "Delta spool", customer="Acme Gas")
|
||||
make(
|
||||
shop,
|
||||
w,
|
||||
"wo",
|
||||
u54,
|
||||
"Echo spool",
|
||||
work_orders=[{"wo_base": "5ZZQ", "lot": "007", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1}],
|
||||
)
|
||||
make(
|
||||
shop,
|
||||
w,
|
||||
"heat",
|
||||
u52,
|
||||
"Foxtrot spool",
|
||||
bom=[
|
||||
{"item": 1, "qty": 1, "part_id": "FLG-9876", "description": "Weld neck flange"},
|
||||
{"item": 2, "qty": 1, "part_id": "FLG-9877", "description": "Blind flange"},
|
||||
],
|
||||
)
|
||||
make(
|
||||
shop,
|
||||
w,
|
||||
"mat",
|
||||
u53,
|
||||
"Golf spool",
|
||||
bom=[
|
||||
{"item": 1, "qty": 2, "part_id": "GSK-1", "description": "Spiral wound gasket", "material": "A182-F316"}
|
||||
],
|
||||
)
|
||||
make(shop, w, "p2", u2, "Hotel spool")
|
||||
make(shop, w, "asg", u2, "India spool", ready_dept="03")
|
||||
make(shop, w, "co", u52, "Juliet spool")
|
||||
make(shop, w, "dept", u2, "Kilo spool", ready_dept="06")
|
||||
make(shop, w, "flag", u54, "Lima spool")
|
||||
make(shop, w, "long", u54, LONG)
|
||||
|
||||
# the assigned welder and the stamp
|
||||
asg = ok(shop("sup.wes").get(f"/api/packets/{w.k['asg']}"))
|
||||
ok(
|
||||
step(
|
||||
shop("sup.wes"), asg, "assign", station_id=shop.stall("03", "Stall 2"), welder_id=shop.uid("weld.andy")
|
||||
)
|
||||
)
|
||||
run(s, "UPDATE packets SET welder_stamp = 'A7Q' WHERE id = %s", (w.k["asg"],))
|
||||
run(s, "UPDATE users SET welder_stamp = 'A7Q' WHERE sam_account = 'weld.andy'")
|
||||
# two BOM lines with one heat and two welds with one stamp: one packet, never two rows
|
||||
run(s, "UPDATE bom_lines SET heat = 'HX44921' WHERE packet_id = %s", (w.k["heat"],))
|
||||
for no in (1, 2):
|
||||
run(
|
||||
s,
|
||||
"INSERT INTO welds (packet_id, weld_no, items, type, stamp, welder_id) VALUES (%s, %s, '{1}',"
|
||||
" 'Fillet', 'ZQ9', (SELECT id FROM users WHERE sam_account = 'lead.lou'))",
|
||||
(w.k["heat"], no),
|
||||
)
|
||||
# a change order with an ECN
|
||||
run(
|
||||
s,
|
||||
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, ecn, reason)"
|
||||
" VALUES (%s, 47, 'manual', 'approved', true, 'ECN-7781', 'Nozzle moved')",
|
||||
(w.k["co"],),
|
||||
)
|
||||
# a VISUAL draft with a flag kind
|
||||
run(
|
||||
s,
|
||||
"UPDATE packets SET source_kind = 'visual', flags = '{rev_mismatch}', drawing_state = 'rev_mismatch'"
|
||||
" WHERE id = %s",
|
||||
(w.k["flag"],),
|
||||
)
|
||||
yield w
|
||||
|
||||
|
||||
def found(w: SearchWorld, q: str, sam: str = "sup.wes") -> list[int]:
|
||||
r = w.shop(sam).get("/api/packets", params={"q": q})
|
||||
assert r.status_code == 200, r.text
|
||||
return [int(c["id"]) for c in r.json()["packets"]]
|
||||
|
||||
|
||||
def hits(w: SearchWorld, q: str) -> set[int]:
|
||||
got = found(w, q)
|
||||
assert len(got) == len(set(got)), ("duplicate rows", q, got)
|
||||
return set(got)
|
||||
|
||||
|
||||
P1 = ("title", "op", "wps", "cust", "wo", "heat", "mat", "co", "flag", "long")
|
||||
P2 = ("p2", "asg", "dept")
|
||||
DRAFTS = tuple(k for k in (*P1, *P2) if k not in ("asg", "dept"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("q", "keys"),
|
||||
[
|
||||
("L512", P2), # project number
|
||||
("pump", P2), # project name
|
||||
("L512001", P2), # unit number
|
||||
("L5121", P2), # unit number with the lot's zeros stripped (L512 || 1)
|
||||
("L48952", ("title", "heat", "co")), # the same for lot 052
|
||||
("lot 52", ("title", "heat", "co")), # lot 052 by its number
|
||||
("HYDRO-TEST", ("op",)), # operation
|
||||
("D-48810", ("title",)), # drawing
|
||||
("D-48810 rev B", ("title",)), # drawing and rev
|
||||
("WPS-77X", ("wps",)), # WPS
|
||||
("acme", ("cust",)), # packet customer
|
||||
("vortex", P2), # project customer
|
||||
("4471", P1), # project PO
|
||||
("OLD489X", P1), # project alias
|
||||
("5zzq", ("wo",)), # work order base
|
||||
("5ZZQ/007", ("wo",)), # work order and lot
|
||||
("hx449", ("heat",)), # BOM heat
|
||||
("FLG-9876", ("heat",)), # BOM part id
|
||||
("spiral", ("mat",)), # BOM description
|
||||
("A182-F316", ("mat",)), # BOM material
|
||||
("andy", ("asg",)), # assigned welder's name
|
||||
("A7Q", ("asg",)), # assigned welder's stamp
|
||||
("ZQ9", ("heat",)), # weld stamp
|
||||
("lou", ("heat",)), # the welder named on a weld
|
||||
("CO-47", ("co",)), # change order number
|
||||
("ECN-7781", ("co",)), # ECN
|
||||
("stall", ("asg",)), # station name
|
||||
("bldg", ("dept",)), # department name
|
||||
("sent", ("asg",)), # status label: assigned is "Sent to station"
|
||||
("rev_mismatch", ("flag",)), # flag kind
|
||||
("flagged", DRAFTS), # every flagged draft (hand drafts and the flagged VISUAL draft)
|
||||
("inlet", ("title",)), # title
|
||||
("INLET", ("title",)), # case insensitive
|
||||
],
|
||||
)
|
||||
def test_search_field(world: SearchWorld, q: str, keys: tuple[str, ...]) -> None:
|
||||
assert hits(world, q) == world.ids(*keys)
|
||||
|
||||
|
||||
def test_lot_and_rev_alone_match_everything(world: SearchWorld) -> None:
|
||||
assert hits(world, "lot") == world.every
|
||||
assert hits(world, "rev") == world.every
|
||||
assert hits(world, "lot 052") == world.ids("title", "heat", "co")
|
||||
|
||||
|
||||
def test_several_words_narrow(world: SearchWorld) -> None:
|
||||
assert hits(world, "L489") == world.ids(*P1)
|
||||
assert hits(world, "L489 spool") == world.ids(*(k for k in P1 if k != "long"))
|
||||
assert hits(world, "L489 52") == world.ids("title", "heat", "co")
|
||||
assert hits(world, "L489 52 inlet") == world.ids("title")
|
||||
assert hits(world, "L512 HYDRO-TEST") == set()
|
||||
assert hits(world, " inlet L489 ") == world.ids("title")
|
||||
|
||||
|
||||
def test_no_duplicates_from_child_rows(world: SearchWorld) -> None:
|
||||
for q in ("hx449", "ZQ9", "FLG", "lou", "flange"):
|
||||
got = found(world, q)
|
||||
assert got == [world.k["heat"]], (q, got)
|
||||
|
||||
|
||||
def test_long_word_is_cut_at_60(world: SearchWorld) -> None:
|
||||
assert hits(world, LONG) == world.ids("long")
|
||||
assert hits(world, LONG + "ZZZZ") == world.ids("long") # cut to the first 60 characters
|
||||
assert hits(world, "Q" * 61) == world.ids("long")
|
||||
|
||||
|
||||
def test_only_eight_words_count(world: SearchWorld) -> None:
|
||||
assert hits(world, " ".join(["inlet"] * 8)) == world.ids("title")
|
||||
assert hits(world, " ".join(["lot"] * 8 + ["inlet"])) == world.every
|
||||
|
||||
|
||||
def test_like_wildcards_are_literal(world: SearchWorld) -> None:
|
||||
assert hits(world, "%") == set()
|
||||
assert hits(world, "_") == world.ids("flag") # only the flag kind has an underscore
|
||||
Reference in New Issue
Block a user