POSA_Copyrighter/tests/operator_gui/pages/workbench_page.py
changukyu 60903ec1f7 test: 업로드 E2E 플레이크 해소 (대기 10초 -> 30초 상수)
test_browser_uploads_image_and_selects_new_submission 이 전체 스위트에서만
실패하고 단독 실행에서는 통과했다. 업로드는 케이스가 큐에 나타나기 전에
서버에서 실분석(디코드/지문/YuNet)을 돌리므로, 다른 스펙과 CPU 를 다투는
전체 실행에서 10초를 넘겼다.

UPLOAD_ANALYSIS_TIMEOUT_MS(30초)로 올리고 이유를 상수 옆에 남겼다. 모킹된
부트스트랩 대기와 달리 이 경로만 여유가 필요하다는 점이 코드에서 보이게 했다.
upload_and_open 의 기본값도 같은 상수를 쓴다.
2026-08-08 09:49:55 +09:00

159 lines
6.1 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 = "반려 또는 보정 결정에는 메모가 필요합니다."
# Uploading runs the real analysis pipeline server-side (decode, fingerprints,
# YuNet face detection) before the case appears in the queue, so this path needs
# far more headroom than a mocked-bootstrap wait. At 10s it passed standalone but
# failed inside the full suite, where the other specs contend for the same CPU.
UPLOAD_ANALYSIS_TIMEOUT_MS = 30000
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 = UPLOAD_ANALYSIS_TIMEOUT_MS
) -> "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