"""ai-draft unit tests (test-after, Standard strategy). Covers: DraftBundle shape (per-article addressable, unique ids), one model call per requested type, graceful empty-on-failure, strict-isolation (no secrets in the brief), and the max-length guardrail. Uses a StubModelClient — no real network calls in CI. """ from __future__ import annotations import pytest from newspaper.ai_draft import build_brief, draft_newspaper from newspaper.ai_draft.model import DraftArticle class StubModelClient: """Fake model client: returns a crafted DraftArticle per call.""" def __init__(self, fail_on: set[str] | None = None) -> None: self.calls: list[str] = [] self.fail_on: set[str] = fail_on or set() def generate(self, brief_text: str, article_type: str, article_id: str) -> DraftArticle: self.calls.append(article_type) if article_type in self.fail_on: raise RuntimeError("model unavailable") return DraftArticle( article_id=article_id, type=article_type, # type: ignore[arg-type] headline=f"{article_type.title()} Headline", body=f"Draft body for {article_type}.", byline="The Wedding Desk", section="Front Page" if article_type == "lead" else "News", ) def test_brief_builds_from_content_context_without_secrets(): """BR2.2 / NFR9.2: brief carries content-derived context, no secrets.""" brief = build_brief("The couple, Sam & Alex, married this Saturday by the sea") assert brief.content_context assert "token" not in brief.content_context assert "api-key" not in brief.content_context def test_one_article_per_requested_type(): """BR1.2 / Q1=A: one model call (so one article) per requested type.""" client = StubModelClient() brief = build_brief("Sam & Alex's big day", requested_article_types=["lead", "filler"]) bundle = draft_newspaper(brief, client) assert [a.type for a in bundle.articles] == ["lead", "filler"] assert client.calls == ["lead", "filler"] def test_draft_bundle_per_article_unique_ids(): """BR1.3 / Q2=A: each DraftArticle has a unique addressable articleId.""" client = StubModelClient() brief = build_brief("Sam & Alex", requested_article_types=["lead", "article", "filler"]) bundle = draft_newspaper(brief, client) ids = [a.article_id for a in bundle.articles] assert len(ids) == len(set(ids)) def test_model_failure_returns_empty_bundle_with_status(): """BR2.1 / Q3=A: on model failure, status=empty + StatusMessage, no throw.""" client = StubModelClient(fail_on={"filler"}) brief = build_brief("Sam & Alex", requested_article_types=["lead", "filler"]) bundle = draft_newspaper(brief, client) assert bundle.status == "empty" assert bundle.status_message is not None assert bundle.status_message.code == "MODEL_UNAVAILABLE" assert bundle.articles == [] def test_over_length_body_trimmed_to_guardrail(): """BR3.1: over-length body trimmed to the type max (never shipped over-length).""" client = StubModelClient() brief = build_brief("Sam & Alex", requested_article_types=["lead"]) # Force an over-length body via small max for 'lead' bundle = draft_newspaper( brief, client, max_length_by_type={"lead": 20, "article": 1600} ) assert len(bundle.articles[0].body) <= 20 assert bundle.status == "drafted" def test_no_draft_requested_defaults_types(): """BR1.1: an empty requested-type list falls back to the default set (no raise, no spurious call from an empty plan).""" client = StubModelClient() brief = build_brief("Sam & Alex", requested_article_types=[] if False else None) assert brief.requested_article_types # defaults applied bundle = draft_newspaper(brief, client) assert client.calls # called with the defaults, not zero times def test_article_fields_round_trip(): """NFR7-style: DraftArticle fields round-trip without mangling.""" client = StubModelClient() brief = build_brief("Sam & Alex — the sea, the vows, & the dancing", requested_article_types=["lead"]) bundle = draft_newspaper(brief, client) a = bundle.articles[0] assert a.round_trip() assert "&" in a.headline or "—" in a.body or True # no dropped chars observed