Ctrl K
ComunidadGuías…

tests/e2e/test_ai_row.py

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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"