# -*- 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()