US03-04: Review and Approve Proposals in the Browser (#64)
This commit was merged in pull request #64.
This commit is contained in:
222
tests/e2e/test_albums_ui.py
Normal file
222
tests/e2e/test_albums_ui.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Browser journeys for the Albums proposal view (US03-04).
|
||||
|
||||
Covers evidence display, editing with prompt validation, collision/invalid-name
|
||||
guidance, explicit approval, visible stale-conflict handling, keyboard operation,
|
||||
and the safety property that approving a name renames nothing on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e._pipeline_harness import Server, image, seed_library
|
||||
|
||||
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
TIMEOUT = 10
|
||||
|
||||
|
||||
def _seed(tmp_path):
|
||||
"""A library with one album ("rome") whose two photos are SFW and analysed."""
|
||||
seeded = seed_library(tmp_path, {}, {}) # empty scan; add the album below
|
||||
album = seeded.lib / "rome"
|
||||
album.mkdir()
|
||||
image(album / "a.jpg", 1)
|
||||
image(album / "b.jpg", 2)
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
||||
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
|
||||
from photo_pipeline.services.inventory import InventoryService
|
||||
|
||||
config = Config.from_env(
|
||||
{
|
||||
"PHOTO_PIPELINE_DATA_DIR": str(seeded.data),
|
||||
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(seeded.lib),
|
||||
}
|
||||
)
|
||||
run_migrations(config.database_url)
|
||||
engine = create_db_engine(config.database_url)
|
||||
sf = create_session_factory(engine)
|
||||
InventoryService(sf).scan(seeded.lib)
|
||||
with sf() as session:
|
||||
rows = list(session.execute(select(Asset.id, Asset.current_path)).all())
|
||||
for asset_id, path in rows:
|
||||
session.add(
|
||||
SafetyReview(
|
||||
id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AnalysisResult(
|
||||
asset_id=asset_id,
|
||||
status="analyzed",
|
||||
description=f"a view of {path}",
|
||||
tags='["ruins", "city"]',
|
||||
approx_year=2019,
|
||||
location_hint="Rome",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
engine.dispose()
|
||||
seeded.asset_ids.update({p.rsplit("/", 1)[-1]: a for a, p in rows})
|
||||
return seeded
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(tmp_path):
|
||||
seeded = _seed(tmp_path)
|
||||
running = Server(seeded).start()
|
||||
running.seeded = seeded
|
||||
try:
|
||||
yield running
|
||||
finally:
|
||||
running.stop()
|
||||
|
||||
|
||||
def _generate(base):
|
||||
httpx.post(f"{base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT).raise_for_status()
|
||||
|
||||
|
||||
def test_view_shows_album_evidence_rationale_and_affected_count(page, server):
|
||||
errors = []
|
||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
_generate(server.base)
|
||||
|
||||
page.goto(f"{server.base}/app/#/albums")
|
||||
page.get_by_test_id("album-row").first.wait_for()
|
||||
|
||||
expect(page.get_by_test_id("source-album")).to_have_text("rome")
|
||||
expect(page.get_by_test_id("affected-count")).to_have_text("2")
|
||||
# Suggested name, rationale and confidence are all visible before approving.
|
||||
assert "2019" in page.get_by_test_id("suggested-name").inner_text()
|
||||
assert page.get_by_test_id("rationale").inner_text().strip() != ""
|
||||
expect(page.get_by_test_id("proposal-status")).to_have_text("proposed")
|
||||
assert errors == [], f"console errors: {errors}"
|
||||
|
||||
|
||||
def test_album_without_a_proposal_is_explained(page, server):
|
||||
page.goto(f"{server.base}/app/#/albums")
|
||||
expect(page.get_by_test_id("no-proposal")).to_be_visible()
|
||||
|
||||
|
||||
def test_edit_persists_across_reload(page, server):
|
||||
_generate(server.base)
|
||||
page.goto(f"{server.base}/app/#/albums")
|
||||
|
||||
field = page.get_by_test_id("final-name")
|
||||
field.wait_for()
|
||||
field.fill("2019 Rome Holiday")
|
||||
page.get_by_test_id("save-name").click()
|
||||
expect(page.get_by_test_id("proposal-status")).to_have_text("edited")
|
||||
|
||||
page.reload()
|
||||
expect(page.get_by_test_id("final-name")).to_have_value("2019 Rome Holiday")
|
||||
|
||||
|
||||
def test_invalid_name_is_flagged_promptly_while_typing(page, server):
|
||||
_generate(server.base)
|
||||
page.goto(f"{server.base}/app/#/albums")
|
||||
|
||||
field = page.get_by_test_id("final-name")
|
||||
field.wait_for()
|
||||
field.fill("2019/Rome")
|
||||
# Validation is immediate — no request needed to learn the name is unusable.
|
||||
issues = page.get_by_test_id("name-issues")
|
||||
expect(issues).to_be_visible()
|
||||
assert "cannot contain" in issues.inner_text()
|
||||
|
||||
field.fill("2019 Rome")
|
||||
expect(issues).to_be_hidden()
|
||||
|
||||
|
||||
def test_approval_is_explicit_and_changes_no_file_paths(page, server):
|
||||
_generate(server.base)
|
||||
before = {
|
||||
row["current_path"]
|
||||
for row in httpx.get(f"{server.base}/api/v1/inventory/assets", timeout=TIMEOUT).json()[
|
||||
"items"
|
||||
]
|
||||
}
|
||||
|
||||
page.goto(f"{server.base}/app/#/albums")
|
||||
approve = page.get_by_test_id("approve")
|
||||
approve.wait_for()
|
||||
expect(page.get_by_test_id("proposal-status")).to_have_text("proposed")
|
||||
|
||||
approve.click() # approval only happens on this explicit action
|
||||
expect(page.get_by_test_id("proposal-status")).to_have_text("approved")
|
||||
|
||||
after = {
|
||||
row["current_path"]
|
||||
for row in httpx.get(f"{server.base}/api/v1/inventory/assets", timeout=TIMEOUT).json()[
|
||||
"items"
|
||||
]
|
||||
}
|
||||
assert after == before, "approving a proposal must not rename anything"
|
||||
|
||||
page.reload()
|
||||
expect(page.get_by_test_id("proposal-status")).to_have_text("approved")
|
||||
|
||||
|
||||
def test_stale_evidence_blocks_approval_visibly(page, server):
|
||||
_generate(server.base)
|
||||
# Change the album's evidence behind the UI's back: a new analysed photo.
|
||||
image(server.seeded.lib / "rome" / "c.jpg", 3)
|
||||
httpx.post(f"{server.base}/api/v1/inventory/scan", timeout=TIMEOUT).raise_for_status()
|
||||
|
||||
page.goto(f"{server.base}/app/#/albums")
|
||||
page.get_by_test_id("album-row").first.wait_for()
|
||||
expect(page.get_by_test_id("stale")).to_be_visible()
|
||||
expect(page.get_by_test_id("approve")).to_be_disabled()
|
||||
|
||||
|
||||
def test_version_conflict_is_reported_without_mutating(page, server):
|
||||
_generate(server.base)
|
||||
page.goto(f"{server.base}/app/#/albums")
|
||||
field = page.get_by_test_id("final-name")
|
||||
field.wait_for()
|
||||
|
||||
# Another client edits first, so the page's expected_version goes stale.
|
||||
current = httpx.get(f"{server.base}/api/v1/albums/proposals/rome", timeout=TIMEOUT).json()
|
||||
httpx.post(
|
||||
f"{server.base}/api/v1/albums/proposals/rome/edit",
|
||||
json={"name": "Someone Else", "expected_version": current["version"]},
|
||||
timeout=TIMEOUT,
|
||||
).raise_for_status()
|
||||
|
||||
field.fill("My Name")
|
||||
page.get_by_test_id("save-name").click()
|
||||
|
||||
expect(page.get_by_test_id("conflict")).to_be_visible()
|
||||
# The other client's value survived; this page's edit was not applied.
|
||||
expect(page.get_by_test_id("final-name")).to_have_value("Someone Else")
|
||||
|
||||
|
||||
def test_album_list_and_editing_work_from_the_keyboard(page, server):
|
||||
_generate(server.base)
|
||||
page.goto(f"{server.base}/app/#/albums")
|
||||
page.get_by_test_id("album-row").first.wait_for()
|
||||
|
||||
# Follow the album link with the keyboard — no mouse involved. Selecting it
|
||||
# re-renders, so wait for the selection to be reflected before typing;
|
||||
# otherwise focus lands on a node the re-render is about to replace.
|
||||
page.get_by_test_id("album-row").first.focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.get_by_test_id("album-row").first).to_have_attribute("aria-current", "true")
|
||||
expect(page.get_by_test_id("final-name")).to_be_visible()
|
||||
|
||||
field = page.get_by_test_id("final-name")
|
||||
field.focus()
|
||||
page.keyboard.press("ControlOrMeta+a")
|
||||
page.keyboard.type("Keyboard Named Album")
|
||||
page.keyboard.press("Tab")
|
||||
page.keyboard.press("Enter") # focus is now the Save button
|
||||
expect(page.get_by_test_id("proposal-status")).to_have_text("edited")
|
||||
expect(page.get_by_test_id("final-name")).to_have_value("Keyboard Named Album")
|
||||
Reference in New Issue
Block a user