tests/e2e/test_ai_row.py
Rama dev · réplica de lectura del repositorio original.
"""E2E: an AI row, from the template gallery to a practice run for one person (#138).
Full stack: the built SPA and the real API, the fake PMS and TMDB. Only the AI provider is faked, at the
`make_curator` boundary, so the one call "Write the list" makes is seen and counted. The row is added, its list
previewed and saved, and "Try it" runs it as a dry run for one person: the picks and the reasons come back, and
nothing reaches Plex.
"""
from __future__ import annotations
import copy
import json
import re
import pytest
from playwright.sync_api import Page, expect
from tests.e2e.conftest import ShortlistApp
from tests.fakes.fake_plex import FakePlexState
pytestmark = pytest.mark.e2e
LOAD = 20_000
RUN = 90_000
BRIEF = "Tense thrillers with a slow-burn standoff"
#: Films the demo library holds and sarah has not watched (she has seen rating keys 101-108).
TITLES = {
"Heat": (1995, "A slow-burn standoff across a diner table"),
"Prisoners": (2013, "A father's patience runs out"),
"Nightcrawler": (2014, "Tension that never lets go"),
}
class _FakeCurator:
"""The AI provider: answers "Write the list" with canned JSON and counts every call."""
name = "anthropic"
can_complete = True
last_tokens = 321
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
def complete(self, system: str, user: str, *, max_tokens: int | None = None) -> str:
self.calls.append((system, user))
return json.dumps(
{
"name": "Tense Thrillers",
"emoji": "🎯",
"rules": {"max_runtime": None},
"tags": [],
"genres": [],
"titles": [
{"title": title, "year": year, "media": "movie", "reason": reason}
for title, (year, reason) in TITLES.items()
],
}
)
@pytest.fixture
def curator(app: ShortlistApp, monkeypatch) -> _FakeCurator:
fake = _FakeCurator()
monkeypatch.setattr("shortlist.server.api.themes.make_curator", lambda provider, **kwargs: fake)
monkeypatch.setattr("shortlist.server.services.context_builder.make_curator", lambda provider, **kwargs: fake)
# The settings the API reads to know an AI provider is set; the key is never used (the curator is faked).
app.api("PUT", "/api/settings", json={"values": {"curator.provider": "anthropic", "curator.api_key": "sk-fake"}})
return fake
def _plex_writes(state: FakePlexState) -> tuple:
"""Everything a run could change on the fake server: its collections and each account's share filters."""
return (
copy.deepcopy(state.collections),
{account: dict(user.filters) for account, user in state.users.items()},
)
def test_an_ai_row_is_added_from_the_gallery_and_tried_for_one_person_with_nothing_written_to_plex(
page: Page, app: ShortlistApp, curator: _FakeCurator, reset_fake_plex: FakePlexState
):
# 1. The AI kind tile, on the add-a-row screen, opens an editor that wants a list before it can be added.
page.goto("/rows")
expect(page.get_by_role("heading", name="Rows", exact=True)).to_be_visible(timeout=LOAD)
page.get_by_role("button", name="Add a row").click()
page.get_by_role("group", name="Kinds of row", exact=True).get_by_role(
"button", name=re.compile(r"^AI row")
).click()
page.get_by_role("button", name="Continue").click()
expect(page.get_by_role("heading", name="Add a row")).to_be_visible(timeout=LOAD)
expect(page.get_by_role("button", name="Add row")).to_be_disabled()
# 2. One AI call writes the list; every title is checked against TMDB and the library, and shown with its reason.
page.get_by_label("What should this row be?").fill(BRIEF)
page.get_by_role("button", name="Write the list").click()
card = page.get_by_role("region", name="The list")
expect(card).to_contain_text("Tense Thrillers", timeout=RUN)
for title, (_year, reason) in TITLES.items():
expect(card).to_contain_text(title)
expect(card).to_contain_text(reason)
expect(card.get_by_text("Not saved yet")).to_be_visible()
assert len(curator.calls) == 1
assert BRIEF in curator.calls[0][1]
assert not any(c.get("theme_id") for c in app.api("GET", "/api/collections").json()), "a preview saves nothing"
# 3. Adding the row saves the list and the row, switched on like every other row (owner decision 2026-10-05).
page.get_by_role("button", name="Add row").click()
expect(page).to_have_url(re.compile(r"/rows$"), timeout=LOAD)
row = next(c for c in app.api("GET", "/api/collections").json() if c.get("theme_id"))
assert row["enabled"] is True
theme = app.api("GET", f"/api/themes/{row['theme_id']}").json()
assert theme["brief"] == BRIEF and theme["origin"] == "ai"
assert [pick["title"] for pick in theme["picks"]] == list(TITLES)
assert len(curator.calls) == 1, "saving never asks the AI again"
# 4. Try it for sarah: a dry run of this row alone.
before = _plex_writes(reset_fake_plex)
page.goto(f"/rows/{row['id']}")
expect(page.get_by_label("Try it for")).to_be_visible(timeout=LOAD)
page.get_by_label("Try it for").select_option(label="sarah")
page.get_by_role("button", name=re.compile(r"^Try it$")).click()
result = page.get_by_text("What sarah would get").locator("..")
expect(result).to_contain_text("Heat", timeout=RUN)
for title, (_year, reason) in TITLES.items():
expect(result).to_contain_text(title)
expect(result).to_contain_text(reason)
expect(result).to_contain_text("Fits Tense Thrillers")
# 5. It was a dry run: recorded as one, the picks live on the run, and neither Plex nor the AI was touched.
runs = app.api("GET", "/api/runs").json()
latest = runs[0] if isinstance(runs, list) else runs["runs"][0]
assert latest["dry_run"] is True
with_picks = app.api("GET", f"/api/runs/{latest['id']}").json()
sarah = next(u for u in with_picks["users"] if u["slug"] == "sarah")
assert {pick["title"] for pick in sarah["picks"]} == set(TITLES)
assert _plex_writes(reset_fake_plex) == before
assert len(curator.calls) == 1, "Try it never asks the AI"