Files
Roster/tests/test_formatter.py
T

396 lines
16 KiB
Python

from datetime import date
from dataclasses import replace
from hashlib import sha256
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
import fitz # type: ignore
from PIL import Image
from combined_roster import compose_combined_pdf
from roster_classifier import (
RosterClassificationError,
RosterType,
assign_roster_pdfs,
classify_roster_pdf,
classify_roster_text,
normalize_roster_text,
)
from roster_formatter import (
DEFAULT_LAYOUT,
PAGE_HEIGHT,
PAGE_WIDTH,
PNG_DPI,
PNG_HEIGHT,
PNG_WIDTH,
LayoutSettings,
RosterFormatError,
RosterPeriod,
format_combined_roster,
format_roster,
validate_input_pdf,
)
from tests.helpers import (
make_geometry_pdf,
make_main_roster_pdf,
make_nursery_only_roster_pdf,
)
LEGACY_GEOMETRY_SHA256 = "8b000127605f4fd78f9f39606de955eb02392ca113e8ddd808421d4f04c09c31"
REAL_SUBSPLASH_INPUT_SHA256 = "a09d8d4479da7f038ba37e08ec51c2959eadfe21f9f3c6ae70aea34784f7e5e0"
REAL_SUBSPLASH_JULY_SHA256 = "89f70043ab92a8683e9f35be54b55f34e33efb59cbdba24dc03eda03b99f146c"
class RosterPeriodTests(unittest.TestCase):
def test_original_month_abbreviation_rules_are_preserved(self) -> None:
expected = {
1: "JAN 2026",
2: "FEB 2026",
3: "MARCH 2026",
4: "APRIL 2026",
5: "MAY 2026",
6: "JUNE 2026",
7: "JULY 2026",
8: "AUG 2026",
9: "SEP 2026",
10: "OCT 2026",
11: "NOV 2026",
12: "DEC 2026",
}
self.assertEqual(
expected,
{month: RosterPeriod(2026, month).label for month in range(1, 13)},
)
def test_suggested_period_changes_on_the_fifteenth(self) -> None:
self.assertEqual(RosterPeriod(2026, 7), RosterPeriod.suggested(date(2026, 7, 14)))
self.assertEqual(RosterPeriod(2026, 8), RosterPeriod.suggested(date(2026, 7, 15)))
self.assertEqual(RosterPeriod(2027, 1), RosterPeriod.suggested(date(2026, 12, 31)))
def test_parse_accepts_browser_and_cli_formats(self) -> None:
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("2026-09"))
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("September 2026"))
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("SEP 2026"))
def test_parse_rejects_untrusted_filename_text(self) -> None:
with self.assertRaises(RosterFormatError):
RosterPeriod.parse("../../September 2026")
class LayoutRegressionTests(unittest.TestCase):
def test_production_defaults_are_explicit(self) -> None:
self.assertEqual(792, PAGE_WIDTH)
self.assertEqual(612, PAGE_HEIGHT)
self.assertEqual(150, PNG_DPI)
self.assertEqual(1650, PNG_WIDTH)
self.assertEqual(1275, PNG_HEIGHT)
self.assertEqual(
LayoutSettings(
content_shift=100,
bottom_margin=60,
logo_x=50,
logo_y=-10,
logo_scale=0.27,
date_x=460,
date_y=88,
date_font_size=48,
main_x=66,
main_y=120,
main_scale=0.86,
nursery_x=71,
nursery_y=519,
nursery_scale=0.85,
),
DEFAULT_LAYOUT,
)
def test_legacy_default_is_byte_identical(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = make_geometry_pdf(root / "source.pdf")
output = root / "output.png"
format_roster(source, output, RosterPeriod(2026, 7))
self.assertEqual(LEGACY_GEOMETRY_SHA256, sha256(output.read_bytes()).hexdigest())
with Image.open(output) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
self.assertEqual("RGB", image.mode)
def test_real_subsplash_fixture_is_byte_identical(self) -> None:
repository = Path(__file__).resolve().parents[1]
source = repository / "input" / "original.pdf"
self.assertTrue(source.is_file(), "the real Subsplash regression fixture is missing")
self.assertEqual(REAL_SUBSPLASH_INPUT_SHA256, sha256(source.read_bytes()).hexdigest())
with TemporaryDirectory() as temp_dir:
output = Path(temp_dir) / "JULY 2026 Roster.png"
format_roster(source, output, RosterPeriod(2026, 7))
self.assertEqual(
REAL_SUBSPLASH_JULY_SHA256,
sha256(output.read_bytes()).hexdigest(),
)
with Image.open(output) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
self.assertEqual("RGB", image.mode)
def test_explicit_adjustment_changes_pixels_but_not_canvas(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = make_geometry_pdf(root / "source.pdf")
default_output = root / "default.png"
adjusted_output = root / "adjusted.png"
format_roster(source, default_output, RosterPeriod(2026, 7))
format_roster(
source,
adjusted_output,
RosterPeriod(2026, 7),
LayoutSettings(logo_x=55),
)
self.assertNotEqual(default_output.read_bytes(), adjusted_output.read_bytes())
with Image.open(adjusted_output) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
def test_input_geometry_is_validated(self) -> None:
with TemporaryDirectory() as temp_dir:
source = Path(temp_dir) / "portrait.pdf"
doc = fitz.open()
page = doc.new_page(width=612, height=792)
page.insert_text((20, 20), "not landscape")
doc.save(source)
doc.close()
with self.assertRaisesRegex(RosterFormatError, "landscape"):
validate_input_pdf(source)
def test_combined_section_values_have_independent_numeric_limits(self) -> None:
invalid_values = {
"main_x": "not-a-number",
"main_scale": "0.09",
"nursery_y": "2001",
"nursery_scale": "2.01",
}
for field, value in invalid_values.items():
with self.subTest(field=field):
with self.assertRaises(RosterFormatError):
LayoutSettings.from_mapping({field: value}, defaults=DEFAULT_LAYOUT)
class RosterClassificationTests(unittest.TestCase):
def test_normalized_full_roster_text_classifies_main(self) -> None:
result = classify_roster_text(
" media\nNursery Workers\n little waders \nGreeters\nUSHERS\nPrayer "
)
self.assertEqual(RosterType.MAIN, result.roster_type)
self.assertTrue(result.nursery_found)
self.assertIn("MEDIA", result.broader_roles)
self.assertIn("LITTLE WADERS", result.broader_roles)
def test_normalized_nursery_heading_without_broad_roles_is_nursery_only(self) -> None:
result = classify_roster_text(
"Wed 2 Sep\n nursery---workers \nJamie Rivera\nWed 9 Sep\nJamie Kim"
)
self.assertEqual(RosterType.NURSERY_ONLY, result.roster_type)
self.assertEqual("NURSERY WORKERS", normalize_roster_text(" nursery---workers "))
self.assertEqual((), result.broader_roles)
def test_wednesday_and_thursday_full_roster_is_still_main(self) -> None:
with TemporaryDirectory() as temp_dir:
source = make_main_roster_pdf(
Path(temp_dir) / "conference.pdf",
dates=("Wed 9 Sep", "Thu 10 Sep", "Fri 11 Sep", "Sat 12 Sep"),
)
result = classify_roster_pdf(source)
self.assertEqual(RosterType.MAIN, result.roster_type)
self.assertGreaterEqual(len(result.broader_roles), 2)
def test_weekday_and_filename_never_override_role_structure(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
misleading_main = make_main_roster_pdf(
root / "Wednesday Nursery Only.pdf",
dates=("Wed 2 Sep", "Thu 3 Sep"),
)
misleading_nursery = make_nursery_only_roster_pdf(
root / "Sunday Full Conference Roster.pdf",
dates=("Sun 6 Sep", "Sun 13 Sep"),
)
self.assertEqual(RosterType.MAIN, classify_roster_pdf(misleading_main).roster_type)
self.assertEqual(
RosterType.NURSERY_ONLY,
classify_roster_pdf(misleading_nursery).roster_type,
)
def test_character_count_is_not_used_as_a_classifier(self) -> None:
long_unknown = ("Wednesday service notes and volunteer names " * 100) + "\nMEDIA"
compact_main = "MEDIA\nGREETERS"
self.assertEqual(RosterType.UNKNOWN, classify_roster_text(long_unknown).roster_type)
self.assertEqual(RosterType.MAIN, classify_roster_text(compact_main).roster_type)
def test_unordered_pair_is_assigned_by_content(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main = make_main_roster_pdf(root / "main.pdf")
nursery = make_nursery_only_roster_pdf(root / "nursery.pdf")
forward = assign_roster_pdfs(main, nursery)
reverse = assign_roster_pdfs(nursery, main)
self.assertEqual(main, forward.main)
self.assertEqual(nursery, forward.nursery)
self.assertEqual(main, reverse.main)
self.assertEqual(nursery, reverse.nursery)
def test_two_main_rosters_are_rejected_with_diagnostics(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
first = make_main_roster_pdf(root / "first.pdf")
second = make_main_roster_pdf(root / "second.pdf")
with self.assertRaises(RosterClassificationError) as caught:
assign_roster_pdfs(first, second)
message = str(caught.exception)
self.assertIn("could not determine one full roster and one nursery-only roster", message)
self.assertIn("PDF 1: main", message)
self.assertIn("PDF 2: main", message)
def test_two_nursery_only_rosters_are_rejected_with_diagnostics(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
first = make_nursery_only_roster_pdf(root / "first.pdf")
second = make_nursery_only_roster_pdf(root / "second.pdf")
with self.assertRaises(RosterClassificationError) as caught:
assign_roster_pdfs(first, second)
message = str(caught.exception)
self.assertIn("could not determine one full roster and one nursery-only roster", message)
self.assertIn("PDF 1: nursery_only", message)
self.assertIn("PDF 2: nursery_only", message)
class CombinedRosterRegressionTests(unittest.TestCase):
@staticmethod
def _make_pair(root: Path) -> tuple[Path, Path]:
return (
make_main_roster_pdf(root / "main.pdf"),
make_nursery_only_roster_pdf(root / "nursery.pdf"),
)
def test_combined_output_has_fixed_dimensions_and_main_above_nursery(self) -> None:
compositions = []
def recording_compose(*args, **kwargs):
composition = compose_combined_pdf(*args, **kwargs)
compositions.append(composition)
return composition
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main, nursery = self._make_pair(root)
output = root / "combined.png"
with patch("roster_formatter.compose_combined_pdf", side_effect=recording_compose):
format_combined_roster(main, nursery, output, RosterPeriod(2026, 9))
with Image.open(output) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
self.assertEqual("RGB", image.mode)
self.assertEqual(1, len(compositions))
composition = compositions[0]
self.assertLessEqual(composition.main.destination.y1, composition.nursery.destination.y0)
self.assertGreater(composition.main.source.height, composition.nursery.source.height)
self.assertLessEqual(composition.main.destination.y1, PAGE_HEIGHT)
self.assertLessEqual(composition.nursery.destination.y1, PAGE_HEIGHT)
def test_reverse_upload_order_produces_identical_combined_png(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main, nursery = self._make_pair(root)
forward = root / "forward.png"
reverse = root / "reverse.png"
format_combined_roster(main, nursery, forward, RosterPeriod(2026, 9))
format_combined_roster(nursery, main, reverse, RosterPeriod(2026, 9))
self.assertEqual(forward.read_bytes(), reverse.read_bytes())
def test_main_and_nursery_placement_settings_are_independent(self) -> None:
placements = []
def recording_compose(*args, **kwargs):
placements.append((kwargs["main_placement"], kwargs["nursery_placement"]))
return compose_combined_pdf(*args, **kwargs)
main_adjusted = replace(
DEFAULT_LAYOUT,
main_x=DEFAULT_LAYOUT.main_x + 17,
main_y=DEFAULT_LAYOUT.main_y + 9,
main_scale=DEFAULT_LAYOUT.main_scale - 0.08,
)
nursery_adjusted = replace(
DEFAULT_LAYOUT,
nursery_x=DEFAULT_LAYOUT.nursery_x - 11,
nursery_y=DEFAULT_LAYOUT.nursery_y - 13,
nursery_scale=DEFAULT_LAYOUT.nursery_scale + 0.07,
)
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main, nursery = self._make_pair(root)
with patch("roster_formatter.compose_combined_pdf", side_effect=recording_compose):
format_combined_roster(
main,
nursery,
root / "default.png",
RosterPeriod(2026, 9),
)
format_combined_roster(
main,
nursery,
root / "main-adjusted.png",
RosterPeriod(2026, 9),
main_adjusted,
)
format_combined_roster(
main,
nursery,
root / "nursery-adjusted.png",
RosterPeriod(2026, 9),
nursery_adjusted,
)
default_main, default_nursery = placements[0]
changed_main, unchanged_nursery = placements[1]
unchanged_main, changed_nursery = placements[2]
self.assertNotEqual(default_main, changed_main)
self.assertEqual(default_nursery, unchanged_nursery)
self.assertEqual(default_main, unchanged_main)
self.assertNotEqual(default_nursery, changed_nursery)
def test_ambiguous_pair_does_not_create_an_output(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
first = make_main_roster_pdf(root / "first.pdf")
second = make_main_roster_pdf(root / "second.pdf")
output = root / "should-not-exist.png"
with self.assertRaisesRegex(RosterFormatError, "could not determine"):
format_combined_roster(first, second, output, RosterPeriod(2026, 9))
self.assertFalse(output.exists())
if __name__ == "__main__":
unittest.main()