This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
# Repo root = two levels above tests/ (tests/ → newspaper/ → repo root)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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
|
||||
@@ -0,0 +1,73 @@
|
||||
"""funnies unit tests (test-after, Standard strategy).
|
||||
|
||||
Covers: theme word derivation, find-a-word placement, crossword grid layout,
|
||||
cartoon fallback, empty FunniesResult, self-contained output. Uses stubs for
|
||||
the clue-drafter and cartoon source — no real network in CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from newspaper.funnies import (
|
||||
FunniesBrief,
|
||||
FunniesBuilder,
|
||||
StubXkcdCartoonSource,
|
||||
derive_theme_words,
|
||||
)
|
||||
from newspaper.funnies.find_a_word import build_find_a_word
|
||||
from newspaper.funnies.crossword import build_crossword
|
||||
|
||||
|
||||
def test_theme_words_derived_from_content():
|
||||
"""BR1.1: theme words extracted from content (drops stopwords/short)."""
|
||||
content = "Sam and Alex married by the sea on Saturday with the family"
|
||||
words = derive_theme_words(content, max_words=6)
|
||||
assert words # some theme words present
|
||||
assert "the" not in words # stopword dropped
|
||||
|
||||
|
||||
def test_find_a_word_places_every_word():
|
||||
"""BR1.3: every listed word appears in the grid."""
|
||||
words = ["sam", "alex", "sea", "rings"]
|
||||
faw = build_find_a_word(words)
|
||||
grid_text = "".join("".join(row) for row in faw.grid)
|
||||
for word in words:
|
||||
assert word.upper() in grid_text
|
||||
|
||||
|
||||
def test_crossword_builds_10x10_grid_with_clues():
|
||||
"""BR1.2 / Q2=B: crossword is 10x10 with across/down clues."""
|
||||
xw = build_crossword(["sam", "alex", "sea"], "wedding")
|
||||
assert len(xw.grid) == 10
|
||||
assert all(len(row) == 10 for row in xw.grid)
|
||||
assert xw.clues_across
|
||||
assert xw.clues_down
|
||||
|
||||
|
||||
def test_cartoon_success_embedded_locally():
|
||||
"""BR2.1: xkcd source found → cartoon art_ref is a local (non-remote) path."""
|
||||
result = FunniesBuilder().build(FunniesBrief(theme_words=["sam"], context="wedding"))
|
||||
cartoon = result.cartoons[0]
|
||||
assert cartoon.source == "xkcd"
|
||||
assert not cartoon.art_ref.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def test_cartoon_failure_falls_back_to_tasteful_strip():
|
||||
"""BR2.2: no cartoon found → tasteful fallback (not a remote URL)."""
|
||||
builder = FunniesBuilder(cartoon_source=StubXkcdCartoonSource(found=False))
|
||||
result = builder.build(FunniesBrief(theme_words=["sam"], context="wedding"))
|
||||
assert result.cartoons[0].source == "generated"
|
||||
assert result.status == "fallback"
|
||||
assert result.is_self_contained()
|
||||
|
||||
|
||||
def test_empty_funnies_result_on_no_content():
|
||||
"""BR3.2 / Q5=A: no viable content → empty result + statusMessage, no throw."""
|
||||
result = FunniesBuilder().build(FunniesBrief(theme_words=[], context=""))
|
||||
assert result.status == "empty"
|
||||
assert result.status_message
|
||||
|
||||
|
||||
def test_funnies_output_self_contained():
|
||||
"""BR4.1 / NFR4: no remote URL survives in any art_ref."""
|
||||
result = FunniesBuilder().build(FunniesBrief(theme_words=["sam", "alex"], context="wedding"))
|
||||
assert result.is_self_contained()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""generator unit tests (test-after, Standard strategy).
|
||||
|
||||
Covers: config/content ingestion, markdown→article fidelity (NFR7), layout +
|
||||
pagination (page breaks/running headers), self-contained zero-network emit
|
||||
(NFR4), empty-state (US10), no-fabricated-photos (FR7.4), graceful no-AI-draft
|
||||
(BR4.1), funnies embedding (US3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from newspaper.generator import (
|
||||
load_config,
|
||||
load_content,
|
||||
run_generate,
|
||||
build_layout,
|
||||
emit_empty_state,
|
||||
emit_newspaper,
|
||||
)
|
||||
from newspaper.generator.model import Article, Issue, MastheadConfig
|
||||
|
||||
|
||||
def _sample_project(tmp: Path, content: dict | None = None, article: str | None = None) -> None:
|
||||
cfg = tmp / "config.json"
|
||||
cfg.write_text(json.dumps(content or {
|
||||
"title": "The Wedding Times", "couple_names": "Sam & Alex",
|
||||
"date_line": "14 Sep", "issue_number": "1", "volume": "1",
|
||||
}))
|
||||
(tmp / "content").mkdir(exist_ok=True)
|
||||
if article is not None:
|
||||
(tmp / "content" / "lead.md").write_text(article)
|
||||
|
||||
|
||||
def test_config_loads_masthead():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
tmp = Path(d)
|
||||
_sample_project(tmp)
|
||||
m = load_config(tmp / "config.json")
|
||||
assert m.title == "The Wedding Times"
|
||||
assert m.couple_names == "Sam & Alex"
|
||||
|
||||
|
||||
def test_parse_article_fidelity():
|
||||
md = "---\nheadline: The Big Day\nbyline: Sam & Alex\n---\nVows said & dancing began."
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
tmp = Path(d)
|
||||
_sample_project(tmp, article=md)
|
||||
arts = load_content(tmp / "content")
|
||||
assert len(arts) == 1
|
||||
a = arts[0]
|
||||
assert "&" in a.body and "&" in a.headline # no mangled escaping
|
||||
|
||||
|
||||
def test_empty_content_emits_guided_empty_state():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
tmp = Path(d)
|
||||
_sample_project(tmp)
|
||||
out = run_generate(tmp)
|
||||
assert out.name == "newspaper.html"
|
||||
text = out.read_text()
|
||||
assert "Welcome" in text # friendly empty-state, not blank
|
||||
|
||||
|
||||
def test_layout_flows_pages_with_running_headers():
|
||||
arts = [Article(f"a{i}", "article", f"Head {i}", "Body" * 20, "", "News") for i in range(9)]
|
||||
pages = build_layout(arts)
|
||||
assert len(pages) >= 1
|
||||
assert all(p.running_header for p in pages)
|
||||
|
||||
|
||||
def test_emitted_newspaper_is_self_contained():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
tmp = Path(d)
|
||||
mh = MastheadConfig("T", "C", "d", "1", "1")
|
||||
issue = Issue("i", mh, [Article("a1", "article", "H", "B", "", "News")], build_layout([Article("a1", "article", "H", "B", "", "News")]))
|
||||
out = tmp / "newspaper.html"
|
||||
emit_newspaper(issue, out)
|
||||
text = out.read_text()
|
||||
assert "http://" not in text and "https://" not in text # NFR4 no remote
|
||||
|
||||
|
||||
def test_no_photos_no_fabricated_images():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
tmp = Path(d)
|
||||
_sample_project(tmp, article="Just text, no photos.")
|
||||
out = run_generate(tmp)
|
||||
assert "<img" not in out.read_text() # FR7.4 no fabricated placeholders
|
||||
|
||||
|
||||
def test_draft_unavailable_builds_without_ai():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
tmp = Path(d)
|
||||
_sample_project(tmp, article="Story one.")
|
||||
out = run_generate(tmp, draft=True)
|
||||
assert out.exists() and "Story one." in out.read_text() # fallback to human copy
|
||||
|
||||
|
||||
def test_funnies_embedded():
|
||||
from newspaper.funnies import FunniesBuilder, FunniesBrief
|
||||
result = FunniesBuilder().build(FunniesBrief(theme_words=["sam", "alex"], context="wedding"))
|
||||
assert result.status in ("ready", "fallback")
|
||||
Reference in New Issue
Block a user