POSA_Copyrighter/tests/operator_gui/pages/workbench_page.py
changukyu 0bfa30d7f5 test: add Playwright E2E harness for operator GUI with decision-flow spec
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.
2026-06-21 21:10:22 +09:00

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