2026-04-06 15:12:01 -04:00
|
|
|
import os
|
2026-04-06 17:02:36 -04:00
|
|
|
from collections import Counter
|
2026-04-06 15:12:01 -04:00
|
|
|
from datetime import date
|
|
|
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
|
from io import BytesIO
|
2026-04-06 18:58:42 -04:00
|
|
|
from PIL import Image as PILImage, ImageDraw, ImageFont
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
from src.cover import (
|
|
|
|
|
generate_programmatic_cover,
|
|
|
|
|
generate_ai_cover,
|
|
|
|
|
generate_cover,
|
|
|
|
|
_get_dominant_category,
|
2026-04-06 18:58:42 -04:00
|
|
|
_truncate_to_width,
|
|
|
|
|
_get_font,
|
2026-04-06 17:02:36 -04:00
|
|
|
CATEGORY_PROMPTS,
|
|
|
|
|
)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
|
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
def test_programmatic_cover_is_portrait(tmp_path):
|
|
|
|
|
path = generate_programmatic_cover(
|
2026-04-06 15:12:01 -04:00
|
|
|
output_dir=str(tmp_path),
|
|
|
|
|
week_start=date(2026, 4, 6),
|
|
|
|
|
week_end=date(2026, 4, 12),
|
|
|
|
|
headlines=["Article One", "Article Two", "Article Three"],
|
2026-04-06 17:02:36 -04:00
|
|
|
categories=["Government", "Government", "Culture"],
|
2026-04-06 15:12:01 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(path)
|
|
|
|
|
img = PILImage.open(path)
|
|
|
|
|
assert img.format == "JPEG"
|
2026-04-06 17:02:36 -04:00
|
|
|
assert img.size == (480, 800)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
|
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
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")
|
2026-04-06 15:12:01 -04:00
|
|
|
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"],
|
2026-04-06 17:02:36 -04:00
|
|
|
categories=["Government"],
|
2026-04-06 15:12:01 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(path)
|
|
|
|
|
img = PILImage.open(path)
|
|
|
|
|
assert img.format == "JPEG"
|
2026-04-06 17:02:36 -04:00
|
|
|
assert img.size == (480, 800)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
|
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
def test_ai_cover_falls_back_to_programmatic(tmp_path):
|
2026-04-06 15:12:01 -04:00
|
|
|
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"],
|
2026-04-06 17:02:36 -04:00
|
|
|
categories=["Government"],
|
2026-04-06 15:12:01 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(path)
|
|
|
|
|
img = PILImage.open(path)
|
|
|
|
|
assert img.format == "JPEG"
|
2026-04-06 17:02:36 -04:00
|
|
|
assert img.size == (480, 800)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
2026-04-06 17:02:36 -04:00
|
|
|
date(2026, 4, 12), ["A"], ["Government"])
|
2026-04-06 15:12:01 -04:00
|
|
|
assert result == "/fake/ai.jpg"
|
|
|
|
|
mock_ai.assert_called_once()
|
|
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
with patch("src.cover.generate_programmatic_cover") as mock_prog:
|
|
|
|
|
mock_prog.return_value = "/fake/text.jpg"
|
2026-04-06 15:12:01 -04:00
|
|
|
result = generate_cover("text", str(tmp_path), date(2026, 4, 6),
|
2026-04-06 17:02:36 -04:00
|
|
|
date(2026, 4, 12), ["A"], ["Government"])
|
2026-04-06 15:12:01 -04:00
|
|
|
assert result == "/fake/text.jpg"
|
2026-04-06 17:02:36 -04:00
|
|
|
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'"
|
2026-04-06 18:58:42 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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}"
|