Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

104 lines
4.2 KiB
Python

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