feat(wip): preserve filters between Overview and Detail with thundering-herd fix

URL is now single source of truth for filter state (workorder, lotid,
package, type, status) across WIP Overview and Detail pages. Drill-down
carries all filters + status; back button dynamically reflects Detail
changes. Backend Detail API now supports pj_type filter parameter.

Harden concurrency: add pagehide abort for MPA navigation, double-check
locking on Redis JSON parse and snapshot build to prevent thread pool
saturation during rapid page switching. Fix watchdog setsid and PID
discovery. Fix test_realtime_equipment_cache RUNCARDLOTID field mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-11 07:22:48 +08:00
parent 3a15b0abaf
commit be22571421
24 changed files with 1522 additions and 266 deletions

View File

@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
"""E2E coverage for WIP Overview / WIP Detail / Hold Detail pages."""
from __future__ import annotations
import time
from urllib.parse import parse_qs, quote, urlparse
import pytest
import requests
from playwright.sync_api import Page, expect
def _pick_workcenter(app_server: str) -> str:
"""Pick a real workcenter to reduce flaky E2E failures."""
try:
response = requests.get(f"{app_server}/api/wip/meta/workcenters", timeout=10)
payload = response.json() if response.ok else {}
items = payload.get("data") or []
if items:
return items[0].get("name") or "TMTT"
except Exception:
pass
return "TMTT"
def _pick_hold_reason(app_server: str) -> str:
"""Pick a real hold reason to reduce flaky E2E failures."""
try:
response = requests.get(f"{app_server}/api/wip/overview/hold", timeout=10)
payload = response.json() if response.ok else {}
items = (payload.get("data") or {}).get("items") or []
if items:
return items[0].get("reason") or "YieldLimit"
except Exception:
pass
return "YieldLimit"
def _get_with_retry(url: str, attempts: int = 3, timeout: float = 10.0):
"""Best-effort GET helper to reduce transient test flakiness."""
last_exc = None
for _ in range(max(attempts, 1)):
try:
return requests.get(url, timeout=timeout, allow_redirects=False)
except requests.RequestException as exc:
last_exc = exc
time.sleep(0.5)
if last_exc:
raise last_exc
raise RuntimeError("request retry exhausted without exception")
def _wait_for_response_url_tokens(page: Page, tokens: list[str], timeout_seconds: float = 30.0):
"""Wait until a response URL contains all tokens."""
matched = []
def handle_response(resp):
if all(token in resp.url for token in tokens):
matched.append(resp)
page.on("response", handle_response)
deadline = time.time() + timeout_seconds
while time.time() < deadline and not matched:
page.wait_for_timeout(200)
return matched[0] if matched else None
def _wait_for_response(page: Page, predicate, timeout_seconds: float = 30.0):
"""Wait until a response satisfies the predicate."""
matched = []
def handle_response(resp):
try:
if predicate(resp):
matched.append(resp)
except Exception:
return
page.on("response", handle_response)
deadline = time.time() + timeout_seconds
while time.time() < deadline and not matched:
page.wait_for_timeout(200)
return matched[0] if matched else None
@pytest.mark.e2e
class TestWipAndHoldPagesE2E:
"""E2E tests for WIP/Hold page URL + API behavior."""
def test_wip_overview_restores_status_from_url(self, page: Page, app_server: str):
page.goto(
f"{app_server}/wip-overview?type=PJA3460&status=queue",
wait_until="commit",
timeout=60000,
)
response = _wait_for_response_url_tokens(
page,
["/api/wip/overview/matrix", "type=PJA3460", "status=QUEUE"],
timeout_seconds=30.0,
)
assert response is not None, "Did not observe expected matrix request with URL filters"
assert response.ok
expect(page.locator("body")).to_be_visible()
def test_wip_detail_reads_status_and_back_link_keeps_filters(self, page: Page, app_server: str):
workcenter = _pick_workcenter(app_server)
page.goto(
f"{app_server}/wip-detail?workcenter={quote(workcenter)}&type=PJA3460&status=queue",
wait_until="commit",
timeout=60000,
)
response = _wait_for_response(
page,
lambda resp: (
"/api/wip/detail/" in resp.url
and (
parse_qs(urlparse(resp.url).query).get("type", [None])[0] == "PJA3460"
or parse_qs(urlparse(resp.url).query).get("pj_type", [None])[0] == "PJA3460"
)
and parse_qs(urlparse(resp.url).query).get("status", [None])[0] in {"QUEUE", "queue"}
),
timeout_seconds=30.0,
)
assert response is not None, "Did not observe expected detail request with URL filters"
assert response.ok
back_href = page.locator("a.btn-back").get_attribute("href") or ""
parsed = urlparse(back_href)
params = parse_qs(parsed.query)
assert parsed.path == "/wip-overview"
assert params.get("type", [None])[0] == "PJA3460"
assert params.get("status", [None])[0] in {"queue", "QUEUE"}
def test_hold_detail_without_reason_redirects_to_overview(self, page: Page, app_server: str):
response = _get_with_retry(f"{app_server}/hold-detail", attempts=3, timeout=10.0)
assert response.status_code == 302
assert response.headers.get("Location") == "/wip-overview"
def test_hold_detail_calls_summary_distribution_and_lots(self, page: Page, app_server: str):
reason = _pick_hold_reason(app_server)
seen = set()
def handle_response(resp):
parsed = urlparse(resp.url)
query = parse_qs(parsed.query)
if query.get("reason", [None])[0] != reason:
return
if parsed.path.endswith("/api/wip/hold-detail/summary"):
seen.add("summary")
elif parsed.path.endswith("/api/wip/hold-detail/distribution"):
seen.add("distribution")
elif parsed.path.endswith("/api/wip/hold-detail/lots"):
seen.add("lots")
page.on("response", handle_response)
page.goto(
f"{app_server}/hold-detail?reason={quote(reason)}",
wait_until="commit",
timeout=60000,
)
deadline = time.time() + 30
while time.time() < deadline and len(seen) < 3:
page.wait_for_timeout(200)
assert seen == {"summary", "distribution", "lots"}