Introduce a reusable browser-test harness under tests/operator_gui/: - conftest.py: shared fixtures (operator_server, browser with graceful skip, operator_page with screenshot-on-failure artifacts) - pages/: OperatorWorkbench Page Object encapsulating selectors and actions - refactor test_browser_smoke.py onto the fixtures + POM (drops duplicated server/browser plumbing) - test_decision_flow.py: approve/hold/reject E2E against the real server, covering the reject-requires-memo guard and decision persistence Pin playwright in requirements-dev.txt (Chromium bundled offline for the air-gapped target) and ignore the artifacts/ screenshot dir.
151 lines
5.7 KiB
Python
151 lines
5.7 KiB
Python
"""Page Object Model for the operator workbench single-page app.
|
|
|
|
Encapsulates the operator-GUI selectors and interactions so browser specs read
|
|
as user intent ("select case", "use suggested query") instead of raw locators.
|
|
Mirrors the existing app structure in ``web/operator-gui/index.html``.
|
|
|
|
All locator accessors return Playwright ``Locator`` objects, which auto-wait;
|
|
prefer them over arbitrary timeouts (see the e2e-testing skill guidance).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
|
|
# Status string the app shows after a suggested query is loaded but not yet run.
|
|
SUGGESTED_QUERY_LOADED_STATUS = "추천 쿼리를 입력했습니다. 실행 버튼을 눌러 검색하세요."
|
|
|
|
# Client-side guard message when a reject/correct decision is missing its memo.
|
|
MEMO_REQUIRED_ERROR = "반려 또는 보정 결정에는 메모가 필요합니다."
|
|
|
|
|
|
class OperatorWorkbench:
|
|
"""Drives the operator GUI for browser-level (E2E) assertions."""
|
|
|
|
def __init__(self, page: Any, base_url: str) -> None:
|
|
self.page = page
|
|
self.base_url = base_url.rstrip("/")
|
|
self.console_errors: list[str] = []
|
|
page.on("console", lambda message: self.console_errors.append(f"console:{message.type}:{message.text}"))
|
|
page.on("pageerror", lambda error: self.console_errors.append(f"pageerror:{error}"))
|
|
|
|
# -- Locators ---------------------------------------------------------
|
|
@property
|
|
def queue_body(self) -> Any:
|
|
return self.page.locator("#queue-body")
|
|
|
|
@property
|
|
def workbench_view(self) -> Any:
|
|
return self.page.locator("#workbench-view")
|
|
|
|
@property
|
|
def case_title(self) -> Any:
|
|
return self.page.locator("#case-title")
|
|
|
|
@property
|
|
def manual_query(self) -> Any:
|
|
return self.page.locator("#manual-query")
|
|
|
|
@property
|
|
def manual_query_status(self) -> Any:
|
|
return self.page.locator("#manual-query-status")
|
|
|
|
@property
|
|
def submission_image_name(self) -> Any:
|
|
return self.page.locator("#submission-image-name")
|
|
|
|
@property
|
|
def submission_import_status(self) -> Any:
|
|
return self.page.locator("#submission-import-status")
|
|
|
|
@property
|
|
def recommendation_box(self) -> Any:
|
|
"""Selected-case summary; includes the "현재 운영 결정: <label>" line."""
|
|
return self.page.locator("#recommendation-box")
|
|
|
|
@property
|
|
def decision_memo(self) -> Any:
|
|
return self.page.locator("#decision-memo")
|
|
|
|
@property
|
|
def memo_error(self) -> Any:
|
|
return self.page.locator("#memo-error")
|
|
|
|
def panel(self, name: str) -> Any:
|
|
"""Workbench panel by ``data-workbench-panel`` (e.g. ``queries``, ``evidence``)."""
|
|
return self.page.locator(f'[data-workbench-panel="{name}"]')
|
|
|
|
def case_row(self, case_id: str) -> Any:
|
|
return self.page.locator(f'#queue-body [data-select-case="{case_id}"]')
|
|
|
|
# -- Navigation / setup ----------------------------------------------
|
|
def mock_bootstrap(self, payload: dict) -> "OperatorWorkbench":
|
|
"""Stub ``/api/bootstrap`` so a spec controls the queue state.
|
|
|
|
Must be called before :meth:`goto` so the route is registered before the
|
|
app issues its first request.
|
|
"""
|
|
self.page.route(
|
|
"**/api/bootstrap",
|
|
lambda route: route.fulfill(
|
|
status=200,
|
|
content_type="application/json",
|
|
body=json.dumps(payload, ensure_ascii=False),
|
|
),
|
|
)
|
|
return self
|
|
|
|
def goto(self) -> "OperatorWorkbench":
|
|
self.page.goto(self.base_url + "/")
|
|
return self
|
|
|
|
# -- Actions ----------------------------------------------------------
|
|
def wait_for_case(self, case_id: str, **kwargs: Any) -> "OperatorWorkbench":
|
|
self.page.wait_for_selector(f'#queue-body [data-select-case="{case_id}"]', **kwargs)
|
|
return self
|
|
|
|
def select_case(self, case_id: str) -> "OperatorWorkbench":
|
|
self.page.get_by_role("button", name=case_id).click()
|
|
return self
|
|
|
|
def use_suggested_query(self, label: str) -> "OperatorWorkbench":
|
|
"""Click a suggested-query chip, which fills the manual-query field."""
|
|
self.page.get_by_role("button", name=label).click()
|
|
return self
|
|
|
|
def upload_submission(self, file_path: str) -> "OperatorWorkbench":
|
|
self.page.set_input_files("#submission-image", str(file_path))
|
|
return self
|
|
|
|
def confirm_upload(self) -> "OperatorWorkbench":
|
|
self.page.get_by_role("button", name="사진 넣기").click()
|
|
return self
|
|
|
|
def upload_and_open(self, file_path: str, case_id: str, timeout: int = 10000) -> "OperatorWorkbench":
|
|
"""Upload an image and wait until its new case is queued and the workbench opens."""
|
|
self.upload_submission(file_path)
|
|
self.confirm_upload()
|
|
self.wait_for_case(case_id, state="attached", timeout=timeout)
|
|
self.workbench_view.wait_for(state="visible", timeout=timeout)
|
|
return self
|
|
|
|
def set_decision_memo(self, text: str) -> "OperatorWorkbench":
|
|
self.decision_memo.fill(text)
|
|
return self
|
|
|
|
def decide(self, decision: str) -> "OperatorWorkbench":
|
|
"""Click a decision button by its ``data-decision`` value (approved/held/rejected)."""
|
|
self.page.locator(f'[data-decision="{decision}"]').click()
|
|
return self
|
|
|
|
def wait_for_decision_label(self, label: str, timeout: int = 10000) -> "OperatorWorkbench":
|
|
"""Wait until the recommendation box reflects a persisted decision label."""
|
|
self.page.wait_for_function(
|
|
"(label) => document.getElementById('recommendation-box')"
|
|
"?.innerText.includes('현재 운영 결정: ' + label)",
|
|
arg=label,
|
|
timeout=timeout,
|
|
)
|
|
return self
|