Replace the monolithic useQueryToolData composable and nested Vue component tree with a modular architecture: useLotResolve, useLotLineage, useLotDetail, and useEquipmentQuery. Introduce ECharts TreeChart (LR orthogonal layout) for lot lineage visualization with multi-select support, subtree expansion, zoom/pan, and serial number normalization. Add unified LineageEngine backend with split descendant traversal and leaf serial number queries. Archive the query-tool-rewrite openspec change and sync delta specs to main. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
534 lines
21 KiB
Python
534 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""E2E coverage for the Query Tool page (LOT 追蹤 + 設備查詢 tabs)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import pytest
|
|
import requests
|
|
from playwright.sync_api import Page, expect
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# The query-tool page is served inside the portal shell as a native Vue SPA.
|
|
# Standalone /query-tool redirects to /portal-shell/query-tool.
|
|
QUERY_TOOL_BASE = "/portal-shell/query-tool"
|
|
|
|
|
|
def _intercept_navigation_as_admin(page: Page, app_server: str):
|
|
"""Intercept /api/portal/navigation to inject is_admin=True + query-tool route.
|
|
|
|
The query-tool page has status 'dev' in an admin-only drawer. The server
|
|
filters out admin-only drawers for non-admin requests, so we must both set
|
|
``is_admin=True`` AND inject the query-tool page into the drawers list.
|
|
"""
|
|
|
|
def handle_route(route):
|
|
response = route.fetch()
|
|
body = response.json()
|
|
|
|
body["is_admin"] = True
|
|
|
|
# Ensure the query-tool page is present in a drawer
|
|
query_tool_entry = {
|
|
"name": "批次追蹤工具",
|
|
"order": 4,
|
|
"route": "/query-tool",
|
|
"status": "dev",
|
|
}
|
|
|
|
drawers = body.get("drawers", [])
|
|
found = False
|
|
for drawer in drawers:
|
|
for pg in drawer.get("pages", []):
|
|
if pg.get("route") == "/query-tool":
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
# Add to the first non-admin drawer, or create a test drawer
|
|
target_drawer = None
|
|
for drawer in drawers:
|
|
if not drawer.get("admin_only"):
|
|
target_drawer = drawer
|
|
break
|
|
if target_drawer:
|
|
target_drawer["pages"].append(query_tool_entry)
|
|
else:
|
|
drawers.append({
|
|
"id": "e2e-test",
|
|
"name": "E2E Test",
|
|
"order": 99,
|
|
"admin_only": False,
|
|
"pages": [query_tool_entry],
|
|
})
|
|
|
|
body["drawers"] = drawers
|
|
route.fulfill(
|
|
status=response.status,
|
|
headers={**response.headers, "content-type": "application/json"},
|
|
body=json.dumps(body),
|
|
)
|
|
|
|
page.route("**/api/portal/navigation", handle_route)
|
|
|
|
|
|
def _wait_for_api_response(page: Page, url_token: str, timeout_seconds: float = 60.0):
|
|
"""Wait until a response URL contains the given token and return it."""
|
|
matched = []
|
|
|
|
def handle_response(resp):
|
|
if url_token in resp.url and resp.status < 500:
|
|
matched.append(resp)
|
|
|
|
page.on("response", handle_response)
|
|
deadline = time.time() + timeout_seconds
|
|
while time.time() < deadline and not matched:
|
|
page.wait_for_timeout(300)
|
|
page.remove_listener("response", handle_response)
|
|
return matched[0] if matched else None
|
|
|
|
|
|
def _collect_api_responses(page: Page, url_tokens: list[str], timeout_seconds: float = 60.0):
|
|
"""Collect responses whose URLs contain any of the given tokens."""
|
|
collected = {}
|
|
|
|
def handle_response(resp):
|
|
for token in url_tokens:
|
|
if token in resp.url:
|
|
collected.setdefault(token, []).append(resp)
|
|
|
|
page.on("response", handle_response)
|
|
deadline = time.time() + timeout_seconds
|
|
while time.time() < deadline and len(collected) < len(url_tokens):
|
|
page.wait_for_timeout(300)
|
|
page.remove_listener("response", handle_response)
|
|
return collected
|
|
|
|
|
|
def _api_post_json(app_server: str, path: str, body: dict, timeout: float = 60.0):
|
|
"""Direct API POST for backend integration checks."""
|
|
resp = requests.post(
|
|
f"{app_server}{path}",
|
|
json=body,
|
|
timeout=timeout,
|
|
)
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backend Integration Tests (no browser needed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.e2e
|
|
class TestQueryToolBackendIntegration:
|
|
"""Verify query-tool API endpoints are functional."""
|
|
|
|
def test_resolve_work_order(self, app_server: str):
|
|
"""POST /api/query-tool/resolve with work_order returns lots."""
|
|
resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
|
"input_type": "work_order",
|
|
"values": ["GA26010001"],
|
|
})
|
|
assert resp.status_code == 200, f"resolve returned {resp.status_code}: {resp.text[:300]}"
|
|
payload = resp.json()
|
|
assert "data" in payload, f"response missing 'data': {list(payload.keys())}"
|
|
assert isinstance(payload["data"], list)
|
|
assert len(payload["data"]) > 0, "Expected at least 1 resolved lot for GA26010001"
|
|
|
|
def test_resolve_returns_not_found_for_garbage(self, app_server: str):
|
|
"""POST /api/query-tool/resolve returns not_found for non-existent values."""
|
|
resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
|
"input_type": "lot_id",
|
|
"values": ["NONEXISTENT_LOT_12345"],
|
|
})
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
not_found = payload.get("not_found", [])
|
|
assert "NONEXISTENT_LOT_12345" in not_found
|
|
|
|
def test_workcenter_groups_endpoint(self, app_server: str):
|
|
"""GET /api/query-tool/workcenter-groups returns list."""
|
|
resp = requests.get(f"{app_server}/api/query-tool/workcenter-groups", timeout=30)
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
assert "data" in payload
|
|
assert isinstance(payload["data"], list)
|
|
|
|
def test_equipment_list_endpoint(self, app_server: str):
|
|
"""GET /api/query-tool/equipment-list returns equipment options."""
|
|
resp = requests.get(f"{app_server}/api/query-tool/equipment-list", timeout=30)
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
assert "data" in payload
|
|
assert isinstance(payload["data"], list)
|
|
assert len(payload["data"]) > 0, "Expected at least 1 equipment option"
|
|
|
|
def test_lot_history_with_resolved_container(self, app_server: str):
|
|
"""Resolve a work order then fetch lot history for first container."""
|
|
resolve_resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
|
"input_type": "work_order",
|
|
"values": ["GA26010001"],
|
|
})
|
|
assert resolve_resp.status_code == 200
|
|
lots = resolve_resp.json().get("data", [])
|
|
assert len(lots) > 0
|
|
|
|
container_id = str(
|
|
lots[0].get("container_id")
|
|
or lots[0].get("CONTAINERID")
|
|
or lots[0].get("containerId")
|
|
or ""
|
|
)
|
|
assert container_id, "Could not extract container_id from resolved lot"
|
|
|
|
history_resp = requests.get(
|
|
f"{app_server}/api/query-tool/lot-history",
|
|
params={"container_id": container_id},
|
|
timeout=60,
|
|
)
|
|
assert history_resp.status_code == 200
|
|
history_payload = history_resp.json()
|
|
assert "data" in history_payload
|
|
assert isinstance(history_payload["data"], list)
|
|
|
|
def test_lot_associations_materials(self, app_server: str):
|
|
"""Fetch materials association for a resolved container."""
|
|
resolve_resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
|
"input_type": "work_order",
|
|
"values": ["GA26010001"],
|
|
})
|
|
lots = resolve_resp.json().get("data", [])
|
|
if not lots:
|
|
pytest.skip("No lots resolved for GA26010001")
|
|
|
|
container_id = str(
|
|
lots[0].get("container_id")
|
|
or lots[0].get("CONTAINERID")
|
|
or ""
|
|
)
|
|
resp = requests.get(
|
|
f"{app_server}/api/query-tool/lot-associations",
|
|
params={"container_id": container_id, "type": "materials"},
|
|
timeout=60,
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_lineage_for_resolved_container(self, app_server: str):
|
|
"""POST /api/trace/lineage for a resolved container."""
|
|
resolve_resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
|
"input_type": "work_order",
|
|
"values": ["GA26010001"],
|
|
})
|
|
lots = resolve_resp.json().get("data", [])
|
|
if not lots:
|
|
pytest.skip("No lots resolved for GA26010001")
|
|
|
|
container_id = str(
|
|
lots[0].get("container_id")
|
|
or lots[0].get("CONTAINERID")
|
|
or ""
|
|
)
|
|
lineage_resp = _api_post_json(app_server, "/api/trace/lineage", {
|
|
"profile": "query_tool",
|
|
"container_ids": [container_id],
|
|
})
|
|
assert lineage_resp.status_code == 200
|
|
payload = lineage_resp.json()
|
|
assert "ancestors" in payload or "data" in payload
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Browser E2E Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.e2e
|
|
class TestQueryToolPageE2E:
|
|
"""Browser-based E2E tests for the query-tool page."""
|
|
|
|
def test_page_loads_with_tab_shell(self, page: Page, app_server: str):
|
|
"""Query tool page loads and displays both top-level tabs."""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(f"{app_server}{QUERY_TOOL_BASE}", wait_until="commit", timeout=60000)
|
|
page.wait_for_timeout(3000)
|
|
|
|
# Header should be visible (portal shell has its own h1, use the page heading)
|
|
heading = page.get_by_role("heading", name="批次追蹤工具")
|
|
expect(heading).to_be_visible()
|
|
|
|
# Both tab buttons should exist
|
|
lot_tab = page.locator("button", has_text="LOT 追蹤")
|
|
equipment_tab = page.locator("button", has_text="設備查詢")
|
|
expect(lot_tab).to_be_visible()
|
|
expect(equipment_tab).to_be_visible()
|
|
|
|
def test_lot_tab_resolve_work_order(self, page: Page, app_server: str):
|
|
"""Enter work order, click resolve, verify lineage tree and detail panel appear."""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Select work_order input type
|
|
select = page.locator("select")
|
|
select.select_option("work_order")
|
|
|
|
# Enter work order in textarea
|
|
textarea = page.locator("textarea")
|
|
textarea.fill("GA26010001")
|
|
|
|
# Collect API responses during resolve
|
|
api_tokens = ["/api/query-tool/resolve", "/api/trace/lineage"]
|
|
collected = {}
|
|
|
|
def handle_response(resp):
|
|
for token in api_tokens:
|
|
if token in resp.url:
|
|
collected.setdefault(token, []).append(resp)
|
|
|
|
page.on("response", handle_response)
|
|
|
|
# Click resolve button
|
|
resolve_btn = page.locator("button", has_text="解析")
|
|
resolve_btn.click()
|
|
|
|
# Wait for resolve + lineage responses
|
|
deadline = time.time() + 60
|
|
while time.time() < deadline and len(collected) < 2:
|
|
page.wait_for_timeout(500)
|
|
page.remove_listener("response", handle_response)
|
|
|
|
# Verify resolve API was called
|
|
assert "/api/query-tool/resolve" in collected, "Resolve API was not called"
|
|
resolve_resp = collected["/api/query-tool/resolve"][0]
|
|
assert resolve_resp.ok, f"Resolve API returned {resolve_resp.status}"
|
|
|
|
# Verify lineage API was auto-fired
|
|
assert "/api/trace/lineage" in collected, "Lineage API was not auto-fired after resolve"
|
|
|
|
# Lineage tree should show nodes
|
|
page.wait_for_timeout(2000)
|
|
tree_section = page.locator("text=批次血緣樹")
|
|
expect(tree_section).to_be_visible()
|
|
|
|
def test_lot_tab_url_state_sync(self, page: Page, app_server: str):
|
|
"""URL params are written after resolve and restored on reload."""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(
|
|
f"{app_server}{QUERY_TOOL_BASE}?tab=lot&input_type=work_order&values=GA26010001",
|
|
wait_until="commit",
|
|
timeout=60000,
|
|
)
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Verify URL params are preserved
|
|
url = page.url
|
|
assert "tab=lot" in url
|
|
assert "input_type=work_order" in url
|
|
assert "values=GA26010001" in url or "GA26010001" in url
|
|
|
|
def test_lot_detail_sub_tabs_render(self, page: Page, app_server: str):
|
|
"""After resolve, clicking a tree node shows detail panel with sub-tabs."""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
|
page.wait_for_timeout(3000)
|
|
|
|
# Select work_order and resolve
|
|
page.locator("select").select_option("work_order")
|
|
page.locator("textarea").fill("GA26010001")
|
|
page.locator("button", has_text="解析").click()
|
|
|
|
# Wait for resolve + lineage + detail loading
|
|
resolve_done = _wait_for_api_response(page, "/api/query-tool/resolve", timeout_seconds=60)
|
|
if not resolve_done:
|
|
pytest.fail("Resolve did not complete within timeout")
|
|
|
|
page.wait_for_timeout(8000)
|
|
|
|
# Detail panel sub-tabs should be visible
|
|
detail_tabs = ["歷程", "物料", "退貨", "Hold", "Split", "Job"]
|
|
for tab_label in detail_tabs:
|
|
tab_btn = page.locator(f"button:has-text('{tab_label}')")
|
|
if tab_btn.count() > 0:
|
|
expect(tab_btn.first).to_be_visible()
|
|
|
|
def test_equipment_tab_loads_filter_bar(self, page: Page, app_server: str):
|
|
"""Switching to equipment tab shows filter bar with equipment MultiSelect."""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=equipment", wait_until="commit", timeout=60000)
|
|
|
|
# Wait for equipment list to bootstrap
|
|
equipment_resp = _wait_for_api_response(page, "/api/query-tool/equipment-list", timeout_seconds=30)
|
|
assert equipment_resp is not None, "Equipment list API was not called"
|
|
assert equipment_resp.ok
|
|
|
|
# Filter bar should show date inputs
|
|
start_date = page.locator("input[type='date']").first
|
|
expect(start_date).to_be_visible()
|
|
|
|
# Equipment sub-tabs should be visible
|
|
for label in ["生產紀錄", "維修紀錄", "報廢紀錄", "Timeline"]:
|
|
tab_btn = page.locator(f"button:has-text('{label}')")
|
|
if tab_btn.count() > 0:
|
|
expect(tab_btn.first).to_be_visible()
|
|
|
|
def test_tab_switching_preserves_state(self, page: Page, app_server: str):
|
|
"""Switching between LOT and equipment tabs preserves entered data."""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
|
page.wait_for_timeout(1500)
|
|
|
|
# Enter text in LOT tab
|
|
textarea = page.locator("textarea")
|
|
textarea.fill("GA26010001")
|
|
|
|
# Switch to equipment tab
|
|
equipment_tab = page.locator("button", has_text="設備查詢")
|
|
equipment_tab.click()
|
|
page.wait_for_timeout(500)
|
|
|
|
# Switch back to LOT tab
|
|
lot_tab = page.locator("button", has_text="LOT 追蹤")
|
|
lot_tab.click()
|
|
page.wait_for_timeout(500)
|
|
|
|
# Verify textarea still has the value (v-show preserves state)
|
|
expect(textarea).to_have_value("GA26010001")
|
|
|
|
def test_lineage_tree_expand_collapse(self, page: Page, app_server: str):
|
|
"""After resolve, expand-all and collapse-all buttons work."""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
|
page.wait_for_timeout(1500)
|
|
|
|
page.locator("select").select_option("work_order")
|
|
page.locator("textarea").fill("GA26010001")
|
|
page.locator("button", has_text="解析").click()
|
|
|
|
# Wait for resolve + lineage
|
|
page.wait_for_timeout(8000)
|
|
|
|
# Try expand all
|
|
expand_btn = page.locator("button", has_text="全部展開")
|
|
if expand_btn.count() > 0 and expand_btn.is_visible():
|
|
expand_btn.click()
|
|
page.wait_for_timeout(5000)
|
|
|
|
# Try collapse all
|
|
collapse_btn = page.locator("button", has_text="收合")
|
|
if collapse_btn.count() > 0:
|
|
collapse_btn.click()
|
|
page.wait_for_timeout(1000)
|
|
|
|
def test_export_button_present_when_data_loaded(self, page: Page, app_server: str):
|
|
"""After resolving and selecting a lot, export button should appear."""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
|
page.wait_for_timeout(1500)
|
|
|
|
page.locator("select").select_option("work_order")
|
|
page.locator("textarea").fill("GA26010001")
|
|
page.locator("button", has_text="解析").click()
|
|
|
|
# Wait for resolve + detail load
|
|
page.wait_for_timeout(8000)
|
|
|
|
# Look for export button (should appear in detail panel)
|
|
export_btn = page.locator("button", has_text="匯出")
|
|
# May or may not be visible depending on data state, just verify no crash
|
|
page.wait_for_timeout(1000)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Full Flow Integration (API → UI round-trip)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.e2e
|
|
class TestQueryToolFullFlowE2E:
|
|
"""End-to-end full workflow: resolve → lineage → history → association."""
|
|
|
|
def test_work_order_full_flow(self, page: Page, app_server: str):
|
|
"""
|
|
Complete flow:
|
|
1. Navigate to query-tool
|
|
2. Select work_order, enter GA26010001
|
|
3. Click resolve
|
|
4. Verify resolve API → lineage auto-fire → history load
|
|
5. Click through sub-tabs
|
|
"""
|
|
_intercept_navigation_as_admin(page, app_server)
|
|
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Step 1: Configure input
|
|
page.locator("select").select_option("work_order")
|
|
page.locator("textarea").fill("GA26010001")
|
|
|
|
# Step 2: Track all API calls
|
|
api_calls = {}
|
|
|
|
def track_response(resp):
|
|
for key in [
|
|
"/api/query-tool/resolve",
|
|
"/api/trace/lineage",
|
|
"/api/query-tool/lot-history",
|
|
"/api/query-tool/lot-associations",
|
|
]:
|
|
if key in resp.url:
|
|
api_calls.setdefault(key, []).append({
|
|
"status": resp.status,
|
|
"url": resp.url,
|
|
})
|
|
|
|
page.on("response", track_response)
|
|
|
|
# Step 3: Click resolve
|
|
page.locator("button", has_text="解析").click()
|
|
|
|
# Step 4: Wait for cascade of API calls
|
|
deadline = time.time() + 90
|
|
while time.time() < deadline:
|
|
page.wait_for_timeout(500)
|
|
# Minimum: resolve + lineage + history
|
|
if (
|
|
"/api/query-tool/resolve" in api_calls
|
|
and "/api/trace/lineage" in api_calls
|
|
and "/api/query-tool/lot-history" in api_calls
|
|
):
|
|
break
|
|
|
|
page.remove_listener("response", track_response)
|
|
|
|
# Step 5: Verify API cascade
|
|
assert "/api/query-tool/resolve" in api_calls, \
|
|
f"resolve not called. Calls seen: {list(api_calls.keys())}"
|
|
assert api_calls["/api/query-tool/resolve"][0]["status"] == 200
|
|
|
|
assert "/api/trace/lineage" in api_calls, \
|
|
f"lineage not auto-fired. Calls seen: {list(api_calls.keys())}"
|
|
|
|
assert "/api/query-tool/lot-history" in api_calls, \
|
|
f"lot-history not loaded. Calls seen: {list(api_calls.keys())}"
|
|
|
|
# Step 6: Verify URL state updated
|
|
current_url = page.url
|
|
assert "tab=lot" in current_url
|
|
assert "work_order" in current_url
|
|
|
|
# Step 7: Click through sub-tabs if available
|
|
for tab_label in ["物料", "Hold", "歷程"]:
|
|
tab_btn = page.locator(f"button:has-text('{tab_label}')")
|
|
if tab_btn.count() > 0 and tab_btn.first.is_visible():
|
|
tab_btn.first.click()
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Step 8: Success message should be visible
|
|
success_msg = page.locator("text=解析完成")
|
|
if success_msg.count() > 0:
|
|
expect(success_msg.first).to_be_visible()
|