import json import os from collections import Counter from datetime import date from unittest.mock import patch, MagicMock from io import BytesIO from PIL import Image as PILImage, ImageDraw, ImageFont from src.cover import ( generate_programmatic_cover, generate_ai_cover, generate_cover, _get_dominant_category, _truncate_to_width, _get_font, CATEGORY_PROMPTS, ) def test_programmatic_cover_is_portrait(tmp_path): path = generate_programmatic_cover( output_dir=str(tmp_path), week_start=date(2026, 4, 6), week_end=date(2026, 4, 12), headlines=["Article One", "Article Two", "Article Three"], categories=["Government", "Government", "Culture"], ) assert os.path.exists(path) img = PILImage.open(path) assert img.format == "JPEG" assert img.size == (480, 800) def test_programmatic_cover_no_headlines(tmp_path): path = generate_programmatic_cover( output_dir=str(tmp_path), week_start=date(2026, 4, 6), week_end=date(2026, 4, 12), headlines=[], categories=[], ) assert os.path.exists(path) img = PILImage.open(path) assert img.size == (480, 800) def test_ai_cover_is_portrait(tmp_path): fake_img = PILImage.new("RGB", (480, 800), color="blue") buf = BytesIO() fake_img.save(buf, format="JPEG") fake_bytes = buf.getvalue() mock_response = MagicMock() mock_response.content = fake_bytes mock_response.raise_for_status = MagicMock() with patch("src.cover.requests.get", return_value=mock_response): path = generate_ai_cover( output_dir=str(tmp_path), week_start=date(2026, 4, 6), week_end=date(2026, 4, 12), headlines=["Test Headline"], categories=["Government"], ) assert os.path.exists(path) img = PILImage.open(path) assert img.format == "JPEG" assert img.size == (480, 800) def test_ai_cover_falls_back_to_programmatic(tmp_path): with patch("src.cover.requests.get", side_effect=Exception("API down")): path = generate_ai_cover( output_dir=str(tmp_path), week_start=date(2026, 4, 6), week_end=date(2026, 4, 12), headlines=["Test"], categories=["Government"], ) assert os.path.exists(path) img = PILImage.open(path) assert img.format == "JPEG" assert img.size == (480, 800) def test_generate_cover_dispatches(tmp_path): with patch("src.cover.generate_ai_cover") as mock_ai: mock_ai.return_value = "/fake/ai.jpg" result = generate_cover("ai", str(tmp_path), date(2026, 4, 6), date(2026, 4, 12), ["A"], ["Government"]) assert result == "/fake/ai.jpg" mock_ai.assert_called_once() with patch("src.cover.generate_programmatic_cover") as mock_prog: mock_prog.return_value = "/fake/text.jpg" result = generate_cover("text", str(tmp_path), date(2026, 4, 6), date(2026, 4, 12), ["A"], ["Government"]) assert result == "/fake/text.jpg" mock_prog.assert_called_once() def test_get_dominant_category(): assert _get_dominant_category(["Government", "Government", "Culture"]) == "Government" assert _get_dominant_category(["Culture", "Government"]) == "Culture" assert _get_dominant_category([]) == "Default" def test_category_prompts_include_massachusetts(): for category, prompt in CATEGORY_PROMPTS.items(): assert "Massachusetts" in prompt, f"Prompt for {category} missing 'Massachusetts'" def test_truncate_to_width_respects_pixel_budget(): font = _get_font(18) short = "Short text" result = _truncate_to_width(short, font, 400) assert result == short, "Short text should not be truncated" long = "A" * 200 result = _truncate_to_width(long, font, 400) assert result.endswith("\u2026"), "Long text should end with ellipsis" assert len(result) < len(long), "Truncated text should be shorter" def test_truncate_to_width_fits_within_budget(): font = _get_font(18) long = "This is a really long headline that should definitely be truncated to fit" max_width = 448 result = _truncate_to_width(long, font, max_width) dummy = PILImage.new("RGBA", (1, 1)) draw = ImageDraw.Draw(dummy) bbox = draw.textbbox((0, 0), result, font=font) rendered_width = bbox[2] - bbox[0] assert rendered_width <= max_width, f"Rendered width {rendered_width} exceeds {max_width}" def test_obituary_headlines_filtered(): """Verify the filtering pattern used by publish/issues/scheduler call sites.""" class FakeArticle: def __init__(self, title, categories): self.title = title self.categories = json.dumps(categories) articles = [ FakeArticle("Town Meeting Approves Budget", ["Government"]), FakeArticle("John Smith, 85, beloved teacher", ["Obituaries"]), FakeArticle("Harbor Festival This Weekend", ["Culture"]), FakeArticle("Jane Doe, 92, retired nurse", ["Obituaries"]), FakeArticle("Panthers Win Championship", ["Sports"]), ] headlines = [ a.title for a in articles if "Obituaries" not in json.loads(a.categories) ] assert len(headlines) == 3 assert "John Smith, 85, beloved teacher" not in headlines assert "Jane Doe, 92, retired nurse" not in headlines assert "Town Meeting Approves Budget" in headlines assert "Harbor Festival This Weekend" in headlines assert "Panthers Win Championship" in headlines