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.
This commit is contained in:
parent
0f69ed06e5
commit
0bfa30d7f5
8 changed files with 372 additions and 107 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,6 +22,7 @@ node_modules/
|
||||||
# Runtime data, databases, logs, generated artifacts
|
# Runtime data, databases, logs, generated artifacts
|
||||||
data/
|
data/
|
||||||
outputs/
|
outputs/
|
||||||
|
artifacts/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.sqlite3-journal
|
*.sqlite3-journal
|
||||||
*.log
|
*.log
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,8 @@
|
||||||
pytest==8.4.2
|
pytest==8.4.2
|
||||||
pytest-cov==6.0.0
|
pytest-cov==6.0.0
|
||||||
pytest-timeout==2.4.0
|
pytest-timeout==2.4.0
|
||||||
|
# Operator-GUI browser (E2E) tests. The Chromium binary is NOT pip-installed:
|
||||||
|
# on the build/staging host run `playwright install chromium`, then bundle the
|
||||||
|
# browser cache (PLAYWRIGHT_BROWSERS_PATH) into the offline artifact for the
|
||||||
|
# air-gapped target. Browser tests skip cleanly when Chromium is absent.
|
||||||
|
playwright==1.49.1
|
||||||
|
|
|
||||||
1
tests/operator_gui/__init__.py
Normal file
1
tests/operator_gui/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Operator-GUI test package (enables relative imports of shared page objects)."""
|
||||||
105
tests/operator_gui/conftest.py
Normal file
105
tests/operator_gui/conftest.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Shared fixtures for operator-GUI tests.
|
||||||
|
|
||||||
|
The browser fixtures lazily import Playwright (via ``importorskip``) and skip
|
||||||
|
when Chromium is unavailable, so the non-browser static tests in this package
|
||||||
|
still collect and run on a host without Playwright installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT / "src") not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
APP_DIR = ROOT / "web" / "operator-gui"
|
||||||
|
ARTIFACT_DIR = ROOT / "artifacts" / "operator-gui-e2e"
|
||||||
|
DEFAULT_VIEWPORT = {"width": 1280, "height": 900}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OperatorServer:
|
||||||
|
"""A running operator server plus the paths a spec may need to assert on."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
image_root: Path
|
||||||
|
store: Any
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.hookimpl(hookwrapper=True)
|
||||||
|
def pytest_runtest_makereport(item, call): # noqa: ANN001 - pytest hook signature
|
||||||
|
"""Stash each phase's report so fixtures can screenshot on failure."""
|
||||||
|
outcome = yield
|
||||||
|
setattr(item, f"rep_{outcome.get_result().when}", outcome.get_result())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def operator_server(tmp_path: Path) -> Iterator[OperatorServer]:
|
||||||
|
"""Build and serve the operator app against a fresh, isolated SQLite store."""
|
||||||
|
from rights_filter.server.http_app import build_server
|
||||||
|
from rights_filter.server.image_store import LocalSubmissionImageStore
|
||||||
|
from rights_filter.server.sqlite_store import CopyrighterStore
|
||||||
|
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
image_root.mkdir()
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
server = build_server(
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=0,
|
||||||
|
store=store,
|
||||||
|
image_store=LocalSubmissionImageStore(image_root),
|
||||||
|
static_dir=APP_DIR,
|
||||||
|
)
|
||||||
|
thread = Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield OperatorServer(
|
||||||
|
base_url=f"http://127.0.0.1:{server.server_port}",
|
||||||
|
image_root=image_root,
|
||||||
|
store=store,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def browser() -> Iterator[Any]:
|
||||||
|
"""Launch headless Chromium, skipping cleanly when it is unavailable."""
|
||||||
|
playwright_api = pytest.importorskip("playwright.sync_api")
|
||||||
|
with playwright_api.sync_playwright() as pw:
|
||||||
|
try:
|
||||||
|
launched = pw.chromium.launch(headless=True)
|
||||||
|
except Exception as exc: # missing browser binaries, sandbox, etc.
|
||||||
|
pytest.skip(f"Playwright Chromium is not available: {exc}")
|
||||||
|
try:
|
||||||
|
yield launched
|
||||||
|
finally:
|
||||||
|
launched.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def operator_page(browser: Any, request: pytest.FixtureRequest) -> Iterator[Any]:
|
||||||
|
"""A fresh page at the default viewport; screenshots to artifacts on failure."""
|
||||||
|
page = browser.new_page(viewport=DEFAULT_VIEWPORT)
|
||||||
|
try:
|
||||||
|
yield page
|
||||||
|
finally:
|
||||||
|
report = getattr(request.node, "rep_call", None)
|
||||||
|
if report is not None and report.failed:
|
||||||
|
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe_name = request.node.name.replace("/", "_").replace("::", "_")[:120]
|
||||||
|
try:
|
||||||
|
page.screenshot(path=str(ARTIFACT_DIR / f"{safe_name}.png"), full_page=True)
|
||||||
|
except Exception:
|
||||||
|
pass # screenshot is best-effort diagnostics, never fail teardown
|
||||||
|
page.close()
|
||||||
5
tests/operator_gui/pages/__init__.py
Normal file
5
tests/operator_gui/pages/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Page objects for operator-GUI browser (Playwright) tests."""
|
||||||
|
|
||||||
|
from .workbench_page import OperatorWorkbench
|
||||||
|
|
||||||
|
__all__ = ["OperatorWorkbench"]
|
||||||
151
tests/operator_gui/pages/workbench_page.py
Normal file
151
tests/operator_gui/pages/workbench_page.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""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
|
||||||
|
|
@ -1,33 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from .pages import OperatorWorkbench
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
from .pages.workbench_page import SUGGESTED_QUERY_LOADED_STATUS
|
||||||
sys.path.insert(0, str(ROOT / "src"))
|
|
||||||
APP_DIR = ROOT / "web" / "operator-gui"
|
|
||||||
|
|
||||||
from rights_filter.server.http_app import build_server
|
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore
|
|
||||||
from rights_filter.server.sqlite_store import CopyrighterStore
|
|
||||||
|
|
||||||
|
|
||||||
def _start(server):
|
|
||||||
thread = Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
return thread
|
|
||||||
|
|
||||||
|
|
||||||
def _browser_or_skip(playwright):
|
|
||||||
try:
|
|
||||||
return playwright.chromium.launch(headless=True)
|
|
||||||
except Exception as exc:
|
|
||||||
pytest.skip(f"Playwright Chromium is not available: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap_payload():
|
def _bootstrap_payload():
|
||||||
|
|
@ -99,93 +75,42 @@ def _bootstrap_payload():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_browser_smoke_suggested_query_fills_manual_query_without_running_search(tmp_path: Path):
|
def test_browser_smoke_suggested_query_fills_manual_query_without_running_search(operator_server, operator_page):
|
||||||
playwright = pytest.importorskip("playwright.sync_api")
|
workbench = OperatorWorkbench(operator_page, operator_server.base_url)
|
||||||
image_root = tmp_path / "submissions"
|
workbench.mock_bootstrap(_bootstrap_payload()).goto()
|
||||||
image_root.mkdir()
|
|
||||||
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
|
||||||
store.initialize()
|
|
||||||
server = build_server(
|
|
||||||
host="127.0.0.1",
|
|
||||||
port=0,
|
|
||||||
store=store,
|
|
||||||
image_store=LocalSubmissionImageStore(image_root),
|
|
||||||
static_dir=APP_DIR,
|
|
||||||
)
|
|
||||||
_start(server)
|
|
||||||
base = f"http://127.0.0.1:{server.server_port}"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with playwright.sync_playwright() as pw:
|
workbench.wait_for_case("SUB-SMOKE1", timeout=5000)
|
||||||
browser = _browser_or_skip(pw)
|
except Exception as exc:
|
||||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
body = workbench.page.locator("body").inner_text()[:1000]
|
||||||
browser_errors = []
|
pytest.fail(f"{exc}\nerrors={workbench.console_errors}\nbody={body}")
|
||||||
page.on("console", lambda message: browser_errors.append(f"console:{message.type}:{message.text}"))
|
|
||||||
page.on("pageerror", lambda error: browser_errors.append(f"pageerror:{error}"))
|
|
||||||
page.route(
|
|
||||||
"**/api/bootstrap",
|
|
||||||
lambda route: route.fulfill(
|
|
||||||
status=200,
|
|
||||||
content_type="application/json",
|
|
||||||
body=json.dumps(_bootstrap_payload(), ensure_ascii=False),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
page.goto(base + "/")
|
|
||||||
try:
|
|
||||||
page.wait_for_selector('#queue-body [data-select-case="SUB-SMOKE1"]', timeout=5000)
|
|
||||||
except Exception as exc:
|
|
||||||
pytest.fail(f"{exc}\nerrors={browser_errors}\nbody={page.locator('body').inner_text()[:1000]}")
|
|
||||||
page.get_by_role("button", name="SUB-SMOKE1").click()
|
|
||||||
page.get_by_role("button", name="Smoke sample 저작권").click()
|
|
||||||
|
|
||||||
assert page.locator('[data-workbench-panel="queries"]').is_visible()
|
workbench.select_case("SUB-SMOKE1")
|
||||||
assert page.locator("#manual-query").input_value() == "Smoke sample 저작권"
|
workbench.use_suggested_query("Smoke sample 저작권")
|
||||||
assert page.locator("#manual-query-status").inner_text() == "추천 쿼리를 입력했습니다. 실행 버튼을 눌러 검색하세요.", browser_errors
|
|
||||||
|
|
||||||
browser.close()
|
assert workbench.panel("queries").is_visible()
|
||||||
finally:
|
assert workbench.manual_query.input_value() == "Smoke sample 저작권"
|
||||||
server.shutdown()
|
assert workbench.manual_query_status.inner_text() == SUGGESTED_QUERY_LOADED_STATUS, workbench.console_errors
|
||||||
|
|
||||||
|
|
||||||
def test_browser_uploads_image_and_selects_new_submission(tmp_path: Path):
|
def test_browser_uploads_image_and_selects_new_submission(operator_server, operator_page, tmp_path):
|
||||||
playwright = pytest.importorskip("playwright.sync_api")
|
|
||||||
image_root = tmp_path / "submissions"
|
|
||||||
image_root.mkdir()
|
|
||||||
upload_file = tmp_path / "smoke upload.svg"
|
upload_file = tmp_path / "smoke upload.svg"
|
||||||
upload_file.write_text("<svg xmlns='http://www.w3.org/2000/svg' width='80' height='60'></svg>", encoding="utf-8")
|
upload_file.write_text("<svg xmlns='http://www.w3.org/2000/svg' width='80' height='60'></svg>", encoding="utf-8")
|
||||||
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
|
||||||
store.initialize()
|
workbench = OperatorWorkbench(operator_page, operator_server.base_url)
|
||||||
server = build_server(
|
workbench.goto()
|
||||||
host="127.0.0.1",
|
|
||||||
port=0,
|
workbench.upload_submission(str(upload_file))
|
||||||
store=store,
|
assert workbench.submission_image_name.inner_text() == "smoke upload.svg"
|
||||||
image_store=LocalSubmissionImageStore(image_root),
|
|
||||||
static_dir=APP_DIR,
|
workbench.confirm_upload()
|
||||||
|
workbench.wait_for_case("smoke-upload", state="attached", timeout=10000)
|
||||||
|
workbench.workbench_view.wait_for(state="visible", timeout=10000)
|
||||||
|
|
||||||
|
assert (operator_server.image_root / "images" / "smoke-upload.svg").exists()
|
||||||
|
assert (
|
||||||
|
"smoke-upload 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다."
|
||||||
|
in workbench.submission_import_status.inner_text()
|
||||||
)
|
)
|
||||||
_start(server)
|
assert workbench.case_title.inner_text() == "smoke-upload · smoke-upload"
|
||||||
base = f"http://127.0.0.1:{server.server_port}"
|
assert not workbench.console_errors
|
||||||
|
|
||||||
try:
|
|
||||||
with playwright.sync_playwright() as pw:
|
|
||||||
browser = _browser_or_skip(pw)
|
|
||||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
|
||||||
browser_errors = []
|
|
||||||
page.on("console", lambda message: browser_errors.append(f"console:{message.type}:{message.text}"))
|
|
||||||
page.on("pageerror", lambda error: browser_errors.append(f"pageerror:{error}"))
|
|
||||||
|
|
||||||
page.goto(base + "/")
|
|
||||||
page.set_input_files("#submission-image", str(upload_file))
|
|
||||||
assert page.locator("#submission-image-name").inner_text() == "smoke upload.svg"
|
|
||||||
|
|
||||||
page.get_by_role("button", name="사진 넣기").click()
|
|
||||||
page.wait_for_selector('#queue-body [data-select-case="smoke-upload"]', state="attached", timeout=10000)
|
|
||||||
page.wait_for_selector("#workbench-view", state="visible", timeout=10000)
|
|
||||||
|
|
||||||
assert (image_root / "images" / "smoke-upload.svg").exists()
|
|
||||||
assert "smoke-upload 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다." in page.locator("#submission-import-status").inner_text()
|
|
||||||
assert page.locator("#case-title").inner_text() == "smoke-upload · smoke-upload"
|
|
||||||
assert not browser_errors
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
|
|
|
||||||
72
tests/operator_gui/test_decision_flow.py
Normal file
72
tests/operator_gui/test_decision_flow.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Operator decision flow (approve / hold / reject) end-to-end.
|
||||||
|
|
||||||
|
Runs against the real server (no bootstrap stub): each test uploads an image to
|
||||||
|
seed a genuine submission, then exercises the decision pane and asserts the
|
||||||
|
decision both renders and persists through ``refreshFromApi``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .pages import OperatorWorkbench
|
||||||
|
from .pages.workbench_page import MEMO_REQUIRED_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='80' height='60'></svg>"
|
||||||
|
|
||||||
|
|
||||||
|
def _open_seeded_case(workbench: OperatorWorkbench, tmp_path: Path, case_id: str = "decide-me") -> str:
|
||||||
|
upload_file = tmp_path / f"{case_id}.svg"
|
||||||
|
upload_file.write_text(_SVG, encoding="utf-8")
|
||||||
|
workbench.goto().upload_and_open(str(upload_file), case_id)
|
||||||
|
# A freshly uploaded submission starts unreviewed.
|
||||||
|
assert "현재 운영 결정: 미심사" in workbench.recommendation_box.inner_text()
|
||||||
|
return case_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_without_memo_is_blocked_and_not_persisted(operator_server, operator_page, tmp_path):
|
||||||
|
workbench = OperatorWorkbench(operator_page, operator_server.base_url)
|
||||||
|
_open_seeded_case(workbench, tmp_path)
|
||||||
|
|
||||||
|
workbench.decide("rejected") # 반려 requires a memo
|
||||||
|
|
||||||
|
assert workbench.memo_error.inner_text() == MEMO_REQUIRED_ERROR
|
||||||
|
# The guard fires client-side, so the decision is never sent or persisted.
|
||||||
|
assert "현재 운영 결정: 미심사" in workbench.recommendation_box.inner_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_persists_without_memo(operator_server, operator_page, tmp_path):
|
||||||
|
workbench = OperatorWorkbench(operator_page, operator_server.base_url)
|
||||||
|
_open_seeded_case(workbench, tmp_path)
|
||||||
|
|
||||||
|
workbench.decide("approved") # 승인 needs no memo
|
||||||
|
workbench.wait_for_decision_label("승인")
|
||||||
|
|
||||||
|
assert workbench.memo_error.inner_text() == ""
|
||||||
|
assert not workbench.console_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_hold_persists_without_memo(operator_server, operator_page, tmp_path):
|
||||||
|
workbench = OperatorWorkbench(operator_page, operator_server.base_url)
|
||||||
|
_open_seeded_case(workbench, tmp_path)
|
||||||
|
|
||||||
|
workbench.decide("held") # 보류 needs no memo
|
||||||
|
workbench.wait_for_decision_label("보류")
|
||||||
|
|
||||||
|
assert workbench.memo_error.inner_text() == ""
|
||||||
|
assert not workbench.console_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_with_memo_persists_and_clears_memo(operator_server, operator_page, tmp_path):
|
||||||
|
workbench = OperatorWorkbench(operator_page, operator_server.base_url)
|
||||||
|
_open_seeded_case(workbench, tmp_path)
|
||||||
|
|
||||||
|
workbench.set_decision_memo("저작권 침해 소지")
|
||||||
|
workbench.decide("rejected")
|
||||||
|
workbench.wait_for_decision_label("반려")
|
||||||
|
|
||||||
|
assert workbench.memo_error.inner_text() == ""
|
||||||
|
# The memo field is cleared after a successful decision.
|
||||||
|
assert workbench.decision_memo.input_value() == ""
|
||||||
|
assert not workbench.console_errors
|
||||||
Loading…
Reference in a new issue