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:
@@ -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