185 lines
6.4 KiB
Python
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()
|