feat(query-tool): rewrite frontend with ECharts tree, multi-select, and modular composables
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>
This commit is contained in:
533
tests/e2e/test_query_tool_e2e.py
Normal file
533
tests/e2e/test_query_tool_e2e.py
Normal file
@@ -0,0 +1,533 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user