Ports the safety review and the Photo Analyzer Library/Analyze/Stats experiences onto the shared API + service layer, and adds the Workflow home, enforcing the pipeline gates and the one-mutating-job policy. Backend - migration 0005 + models: safety_reviews (append-only, latest row is the current decision) and analysis_results (donor photos schema re-keyed to asset_id). - SafetyService: persist scores/decisions, review queue with filters, and the EXIF safety checkpoint (mutually-exclusive sfw/nsfw keyword written, read back, current_sha256 refreshed) that upload eligibility depends on. - AnalysisService: the privacy gate — the vision provider is called ONLY for canonical, confirmed-SFW assets; nsfw/undecided are recorded skipped without a request. Provider is an injected adapter (real OpenAI-compatible Gemini call extracted from photo_analyzer.analyze_image; a fake in tests). - LibraryService: Library search + Stats read model ported from webapp/query.py (LIKE search in place of FTS5; facets, top tags, years, albums, people). - WorkflowService + GET /api/v1/workflow: per-stage readiness derived from the source tables — counts, blockers, last-run, action, and an active_job that drives read-only-during-jobs. Safety scoring and analysis run as durable jobs under the library_write lock via new domain handlers, so a second mutating job is refused. - routes: workflow, safety (queue/counts/decisions/jobs), analysis (counts/results/jobs), library (assets/facets/stats). Frontend - five views (frontend/js/views.js) on the US02-05 shell: Workflow stepper (status text+icon, not colour alone; actions disabled with a reason while a job runs), Safety review (filter tabs, decide, persists across reload), Library (search + cards), Analyze (counts + live job log via the SSE adapter), Stats. Shared DOM helpers extracted to dom.js; Workflow is the home route. Tests - integration: provider-call privacy (nsfw never reaches the provider), sfw→nsfw flip drops analysis eligibility, decision persistence, one-mutating- job rejection, workflow counts, and the exiftool safety-keyword write/verify. - e2e: Workflow cards, actions disabled+explained during a job, safety decide-persists-across-reload, Library search, Stats, Analyze counts. - traceability map updated for US02-05 and US02-06. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
5.6 KiB
Python
163 lines
5.6 KiB
Python
"""Browser journeys for the Phase B views (US02-06): Workflow home, Safety review,
|
|
Library, Analyze, Stats — plus the read-only-during-jobs rule.
|
|
|
|
The library is seeded in-process (scan → one SFW/one NSFW decision → one fake-analyzed
|
|
result) before the real server starts, so the views have data to render exactly as
|
|
they would in production.
|
|
"""
|
|
|
|
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 _image(path, seed):
|
|
rng = np.random.default_rng(seed)
|
|
Image.fromarray(rng.integers(0, 256, (96, 128, 3), dtype=np.uint8)).save(path, quality=90)
|
|
|
|
|
|
def _free_port():
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
class _Fake:
|
|
def analyze(self, path, *, album_hint):
|
|
return {"description": "a sunny beach at golden hour", "tags": ["beach", "sunset"], "people_count": 2, "setting": "outdoor"}
|
|
|
|
|
|
@pytest.fixture
|
|
def server(tmp_path):
|
|
data = tmp_path / "data"
|
|
data.mkdir()
|
|
lib = tmp_path / "lib"
|
|
lib.mkdir()
|
|
_image(lib / "beach.jpg", 1)
|
|
_image(lib / "city.jpg", 2)
|
|
_image(lib / "night.jpg", 3)
|
|
|
|
from photo_pipeline.config import Config
|
|
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
|
from photo_pipeline.services.analysis import AnalysisService
|
|
from photo_pipeline.services.inventory import InventoryService
|
|
from photo_pipeline.services.safety import SafetyService
|
|
|
|
config = Config.from_env(
|
|
{"PHOTO_PIPELINE_DATA_DIR": str(data), "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)
|
|
from sqlalchemy import select
|
|
|
|
from photo_pipeline.models import Asset
|
|
|
|
with sf() as session:
|
|
ids = list(session.scalars(select(Asset.id).order_by(Asset.current_path)))
|
|
safety = SafetyService(sf)
|
|
safety.decide(ids[0], "sfw", write_exif=False)
|
|
safety.decide(ids[1], "nsfw", write_exif=False)
|
|
AnalysisService(sf, provider=_Fake()).run([ids[0]])
|
|
engine.dispose()
|
|
|
|
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")
|
|
try:
|
|
yield SimpleNamespace(base=base)
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
def test_workflow_home_shows_cards_with_counts_and_labels(page, server):
|
|
page.goto(f"{server.base}/app/#/workflow")
|
|
page.get_by_test_id("stage-safety").wait_for()
|
|
# Status is text, not color alone.
|
|
assert page.get_by_test_id("status-safety").inner_text().strip() != ""
|
|
# 1 sfw, 1 nsfw, 1 undecided from the seed.
|
|
assert "1 undecided" in page.get_by_test_id("counts-safety").inner_text()
|
|
assert page.get_by_test_id("stage-analysis").count() == 1
|
|
assert page.get_by_test_id("action-safety").is_visible()
|
|
|
|
|
|
def test_actions_disabled_and_explained_while_a_job_runs(page, server):
|
|
# Start a durable job (no worker consumes it), then the home page must disable
|
|
# job-starting actions and explain why.
|
|
httpx.post(f"{server.base}/api/v1/safety/jobs", timeout=5).raise_for_status()
|
|
page.goto(f"{server.base}/app/#/workflow")
|
|
page.get_by_test_id("job-running").wait_for()
|
|
assert page.get_by_test_id("action-safety").is_disabled()
|
|
|
|
|
|
def test_safety_review_decide_persists_across_reload(page, server):
|
|
page.goto(f"{server.base}/app/#/safety?state=undecided")
|
|
row = page.get_by_test_id("safety-row").first
|
|
row.wait_for()
|
|
asset_id = row.get_attribute("data-asset-id")
|
|
row.get_by_test_id("decide-sfw").click()
|
|
|
|
# The decided asset leaves the undecided queue and appears under SFW after reload.
|
|
page.goto(f"{server.base}/app/#/safety?state=sfw")
|
|
page.get_by_test_id("safety-body").wait_for()
|
|
page.wait_for_selector(f'[data-asset-id="{asset_id}"]')
|
|
|
|
|
|
def test_library_search_and_stats(page, server):
|
|
page.goto(f"{server.base}/app/#/library?q=beach")
|
|
page.get_by_test_id("library-card").first.wait_for()
|
|
assert "beach" in page.get_by_test_id("library-card").first.inner_text().lower()
|
|
|
|
page.goto(f"{server.base}/app/#/stats")
|
|
page.get_by_test_id("stats-albums").wait_for()
|
|
assert page.get_by_test_id("stats-status").inner_text().strip() != ""
|
|
|
|
|
|
def test_analyze_view_shows_counts(page, server):
|
|
page.goto(f"{server.base}/app/#/analyze")
|
|
page.get_by_test_id("analyze-counts").wait_for()
|
|
assert page.get_by_test_id("run-analysis").is_visible()
|