"""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")