Phase 8: Project Management, find bar, presence and motion
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s

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:
2026-10-18 15:08:34 -06:00
co-authored by Claude Fable 5.1
parent 904437153d
commit 6b6db0230f
90 changed files with 10450 additions and 563 deletions
+9 -1
View File
@@ -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 },
},
],
});
+330
View File
@@ -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);
});
});
+183
View File
@@ -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);
});
});
+422
View File
@@ -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,
);
}
+69
View File
@@ -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);
});
});
+2 -1
View File
@@ -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);
});