396 lines
16 KiB
Python
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()
|