US01-06: Review Inventory and Duplicates in the Browser (#51)
This commit was merged in pull request #51.
This commit is contained in:
184
tests/e2e/test_review_ui.py
Normal file
184
tests/e2e/test_review_ui.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user