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()