220 lines
8.3 KiB
Python
220 lines
8.3 KiB
Python
"""Phase A end-to-end acceptance gate.
|
|
|
|
Drives the whole Phase A journey against a real API process over HTTP, plus one
|
|
browser reload check, then restarts the process and asserts durable API + database
|
|
state survived. Everything is offline: deterministic synthetic fixtures, no network.
|
|
|
|
Note: Phase A has no separate worker yet (durable jobs arrive in Phase B/US02); the
|
|
harness launches the real API process, which is the production boundary for this
|
|
phase. Scan and detection run synchronously via their endpoints.
|
|
"""
|
|
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _structured(path, seed, size=(320, 240)):
|
|
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 _oriented(path, w, h, orientation=6, seed=7):
|
|
arr = np.random.default_rng(seed).integers(0, 256, (h, w, 3), dtype=np.uint8)
|
|
img = Image.fromarray(arr)
|
|
exif = img.getexif()
|
|
exif[274] = orientation
|
|
img.save(path, exif=exif, quality=95)
|
|
|
|
|
|
def _free_port():
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
class ServerController:
|
|
def __init__(self, data, lib):
|
|
self.data = data
|
|
self.lib = lib
|
|
self.proc = None
|
|
self.base = None
|
|
self.client = None
|
|
|
|
def start(self):
|
|
port = _free_port()
|
|
env = {
|
|
"PATH": __import__("os").environ.get("PATH", ""),
|
|
"PHOTO_PIPELINE_DATA_DIR": str(self.data),
|
|
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(self.lib),
|
|
"PHOTO_PIPELINE_HOST": "127.0.0.1",
|
|
"PHOTO_PIPELINE_PORT": str(port),
|
|
}
|
|
self.proc = subprocess.Popen(
|
|
[sys.executable, "-m", "photo_pipeline", "serve"],
|
|
cwd=str(REPO),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
self.base = f"http://127.0.0.1:{port}"
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
if self.proc.poll() is not None:
|
|
_, err = self.proc.communicate()
|
|
pytest.fail(f"server exited: {err.decode(errors='replace')}")
|
|
try:
|
|
if httpx.get(f"{self.base}/api/v1/health/ready", timeout=1).status_code == 200:
|
|
self.client = httpx.Client(base_url=self.base, timeout=10)
|
|
return
|
|
except httpx.HTTPError:
|
|
time.sleep(0.2)
|
|
self.stop()
|
|
pytest.fail("server never became ready")
|
|
|
|
def restart(self):
|
|
self.stop()
|
|
self.start()
|
|
|
|
def stop(self):
|
|
if self.client:
|
|
self.client.close()
|
|
self.client = None
|
|
if self.proc:
|
|
self.proc.terminate()
|
|
try:
|
|
self.proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.kill()
|
|
self.proc = None
|
|
|
|
|
|
@pytest.fixture
|
|
def pipeline(tmp_path):
|
|
data = tmp_path / "data"
|
|
data.mkdir()
|
|
lib = tmp_path / "lib"
|
|
lib.mkdir()
|
|
|
|
# Deterministic corpus exercising every Phase A journey.
|
|
photo = _structured(lib / "album" / "photo.jpg", 1)
|
|
shutil.copy2(photo, lib / "album" / "photo_copy.jpg") # exact duplicate
|
|
scene = _structured(lib / "album" / "scene.jpg", 2)
|
|
_resized(scene, lib / "album" / "scene_small.jpg") # perceptual near-duplicate
|
|
_oriented(lib / "rotated.jpg", 800, 480, orientation=6) # orientation fixture
|
|
_structured(lib / "movable.jpg", 5) # moved between scans
|
|
_structured(lib / "_IGNORE" / "secret.jpg", 9) # exclusion sentinel
|
|
|
|
controller = ServerController(data, lib)
|
|
controller.start()
|
|
yield controller
|
|
controller.stop()
|
|
|
|
|
|
def _assets(client):
|
|
return client.get("/api/v1/inventory/assets?limit=200").json()["items"]
|
|
|
|
|
|
def _id_for(assets, suffix):
|
|
return next(a["id"] for a in assets if (a["current_path"] or "").endswith(suffix))
|
|
|
|
|
|
def test_phase_a_full_pipeline_and_restart(page, pipeline):
|
|
client = pipeline.client
|
|
|
|
# 1. Scan + detect through the real API.
|
|
assert client.post("/api/v1/inventory/scan").status_code == 200
|
|
detect = client.post("/api/v1/duplicates/detect").json()
|
|
assert detect["clusters"] >= 2
|
|
|
|
assets = _assets(client)
|
|
paths = [a["current_path"] for a in assets]
|
|
|
|
# 2. Exclusion: the _IGNORE sentinel is never discovered.
|
|
assert not any("_IGNORE" in p or p.endswith("secret.jpg") for p in paths)
|
|
# 5 discovered files (photo, photo_copy, scene, scene_small, rotated, movable = 6)
|
|
assert len(assets) == 6
|
|
movable_id = _id_for(assets, "movable.jpg")
|
|
rotated_id = _id_for(assets, "rotated.jpg")
|
|
|
|
# 3. Move reconciliation: identity survives a move across a rescan.
|
|
shutil.move(str(pipeline.lib / "movable.jpg"), str(pipeline.lib / "album" / "moved.jpg"))
|
|
assert client.post("/api/v1/inventory/scan").status_code == 200
|
|
assert _id_for(_assets(client), "moved.jpg") == movable_id
|
|
|
|
# 4. Exact + fuzzy clusters exist with the right confidence handling.
|
|
clusters = client.get("/api/v1/duplicates/clusters").json()["items"]
|
|
exact = next(c for c in clusters if c["method"] == "exact")
|
|
perceptual = next(c for c in clusters if c["method"] == "perceptual")
|
|
assert exact["state"] == "decided" # exact auto-resolved
|
|
assert perceptual["state"] == "open" # fuzzy left for review
|
|
|
|
# 5. Thumbnail orientation: a rotated source serves an upright preview.
|
|
thumb = client.get(f"/api/v1/assets/{rotated_id}/thumbnail?size=256")
|
|
assert thumb.status_code == 200 and thumb.headers["content-type"] == "image/webp"
|
|
with Image.open(BytesIO(thumb.content)) as image:
|
|
assert image.height > image.width # 800x480 landscape shown upright as portrait
|
|
|
|
# 6. Canonical selection on the fuzzy cluster persists with variant links.
|
|
detail = client.get(f"/api/v1/duplicates/clusters/{perceptual['id']}").json()
|
|
chosen = detail["members"][0]["asset_id"]
|
|
decided = client.post(
|
|
f"/api/v1/duplicates/clusters/{perceptual['id']}/decision",
|
|
json={
|
|
"decision": "canonical",
|
|
"expected_version": detail["version"],
|
|
"canonical_asset_id": chosen,
|
|
},
|
|
)
|
|
assert decided.status_code == 200 and decided.json()["state"] == "decided"
|
|
variant_ids = {m["asset_id"] for m in detail["members"]} - {chosen}
|
|
after = {a["id"]: a for a in _assets(client)}
|
|
assert all(after[v]["canonical_asset_id"] == chosen for v in variant_ids)
|
|
|
|
# 7. Reload in the browser: the decision is restored, not re-fetched fresh state.
|
|
page.goto(f"{pipeline.base}/app/#/duplicates/{perceptual['id']}")
|
|
page.get_by_test_id("cluster-state").wait_for()
|
|
page.reload()
|
|
page.get_by_test_id("cluster-state").wait_for()
|
|
assert "decided" in page.get_by_test_id("cluster-state").inner_text()
|
|
|
|
# 8. Full process restart: durable API + DB state survives.
|
|
pipeline.restart()
|
|
client = pipeline.client
|
|
assert client.get("/api/v1/health/ready").status_code == 200
|
|
restored = client.get("/api/v1/duplicates/clusters").json()["items"]
|
|
restored_perceptual = next(c for c in restored if c["id"] == perceptual["id"])
|
|
assert restored_perceptual["state"] == "decided" # decision durable
|
|
restored_assets = {a["id"]: a for a in _assets(client)}
|
|
assert _id_for(list(restored_assets.values()), "moved.jpg") == movable_id # identity durable
|
|
assert all(restored_assets[v]["canonical_asset_id"] == chosen for v in variant_ids)
|
|
restored_paths = [a["current_path"] for a in restored_assets.values()]
|
|
assert not any("_IGNORE" in p for p in restored_paths) # exclusion still holds
|