Files
photoanalyzer/tests/e2e/test_review_ui.py

185 lines
6.4 KiB
Python

"""Browser end-to-end review journeys against the real server + static frontend.
Covers keyboard inventory review, fuzzy-decision confirmation, stale-version
conflict handling, and decision persistence across reload. The server runs as a
real child process; the browser talks to it over HTTP like production.
"""
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import httpx
import numpy as np
import pytest
from PIL import Image
REPO = Path(__file__).resolve().parents[2]
def _structured(path, seed, size=(256, 192)):
path.parent.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(seed)
w, h = size
base = np.zeros((h, w, 3), dtype=np.uint8)
for _ in range(6):
x0, y0 = int(rng.integers(0, w - 60)), int(rng.integers(0, h - 60))
base[y0 : y0 + 60, x0 : x0 + 60] = rng.integers(0, 256, 3)
grad = np.linspace(0, 120, w, dtype=np.uint8)
base[:, :, 0] = np.clip(base[:, :, 0].astype(int) + grad[None, :], 0, 255)
Image.fromarray(base).save(path, quality=95)
return path
def _resized(src, dst, scale=0.5):
with Image.open(src) as image:
image.resize(
(int(image.width * scale), int(image.height * scale)), Image.LANCZOS
).save(dst, quality=95)
def _free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _seed(data_dir, lib):
# In-process seed, then dispose so the server process owns the database freely.
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.services.duplicates import DuplicateService
from photo_pipeline.services.inventory import InventoryService
a = _structured(lib / "a.jpg", 1)
shutil.copy2(a, lib / "a_copy.jpg") # exact pair
b = _structured(lib / "b.jpg", 2)
_resized(b, lib / "b_small.jpg") # perceptual pair
config = Config.from_env(
{"PHOTO_PIPELINE_DATA_DIR": str(data_dir), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)}
)
run_migrations(config.database_url)
engine = create_db_engine(config.database_url)
sf = create_session_factory(engine)
InventoryService(sf).scan(lib)
DuplicateService(sf).detect()
engine.dispose()
@pytest.fixture
def server(tmp_path):
data = tmp_path / "data"
data.mkdir()
lib = tmp_path / "lib"
lib.mkdir()
_seed(data, lib)
port = _free_port()
env = {
"PATH": __import__("os").environ.get("PATH", ""),
"PHOTO_PIPELINE_DATA_DIR": str(data),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_HOST": "127.0.0.1",
"PHOTO_PIPELINE_PORT": str(port),
}
proc = subprocess.Popen(
[sys.executable, "-m", "photo_pipeline", "serve"],
cwd=str(REPO),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
base = f"http://127.0.0.1:{port}"
deadline = time.monotonic() + 30
ready = False
while time.monotonic() < deadline:
if proc.poll() is not None:
out, err = proc.communicate()
pytest.fail(f"server exited: {err.decode(errors='replace')}")
try:
if httpx.get(f"{base}/api/v1/health/ready", timeout=1).status_code == 200:
ready = True
break
except httpx.HTTPError:
time.sleep(0.2)
if not ready:
proc.terminate()
pytest.fail("server never became ready")
client = httpx.Client(base_url=base, timeout=5)
def cluster_by_method(method):
clusters = client.get("/api/v1/duplicates/clusters").json()["items"]
return next(c for c in clusters if c["method"] == method)
try:
yield SimpleNamespace(base=base, client=client, cluster_by_method=cluster_by_method)
finally:
client.close()
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
def test_inventory_keyboard_review(page, server):
page.goto(f"{server.base}/app/#/inventory")
rows = page.get_by_test_id("asset-row")
rows.first.wait_for()
assert rows.count() == 4
# First row auto-selected; arrow-down moves the selection.
assert rows.nth(0).get_attribute("aria-selected") == "true"
page.keyboard.press("ArrowDown")
assert rows.nth(1).get_attribute("aria-selected") == "true"
assert rows.nth(0).get_attribute("aria-selected") == "false"
def test_fuzzy_decision_requires_confirmation(page, server):
cluster = server.cluster_by_method("perceptual")
page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}")
page.get_by_test_id("not-duplicate").click()
# A confirmation appears before anything is persisted.
page.get_by_test_id("confirm").wait_for()
assert "open" in page.get_by_test_id("cluster-state").inner_text()
page.get_by_test_id("confirm-yes").click()
page.wait_for_function(
"document.querySelector('[data-testid=cluster-state]').innerText.includes('dismissed')"
)
def test_stale_version_shows_conflict(page, server):
cluster = server.cluster_by_method("perceptual")
page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}")
# Open the confirmation for a fuzzy decision (this refetches the current version).
page.get_by_test_id("defer").click()
page.get_by_test_id("confirm").wait_for()
# While the dialog is open, another actor decides — bumping the version.
resp = server.client.post(
f"/api/v1/duplicates/clusters/{cluster['id']}/decision",
json={"decision": "deferred", "expected_version": cluster["version"]},
)
assert resp.status_code == 200
# Confirming now carries the stale version; the server refuses it.
page.get_by_test_id("confirm-yes").click()
page.get_by_role("alert").wait_for()
assert "changed" in page.get_by_role("alert").inner_text()
def test_decision_persists_after_reload(page, server):
cluster = server.cluster_by_method("perceptual")
page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}")
page.get_by_test_id("not-duplicate").click()
page.get_by_test_id("confirm-yes").click()
page.wait_for_function(
"document.querySelector('[data-testid=cluster-state]').innerText.includes('dismissed')"
)
page.reload()
page.get_by_test_id("cluster-state").wait_for()
assert "dismissed" in page.get_by_test_id("cluster-state").inner_text()