Add optional nursery roster composition

This commit is contained in:
Media
2026-08-30 20:04:00 -05:00
parent 3eab672c6c
commit 7651a3ed9d
12 changed files with 1578 additions and 116 deletions
+167
View File
@@ -1,4 +1,5 @@
from pathlib import Path
from typing import Literal, Sequence
import fitz # type: ignore
@@ -36,3 +37,169 @@ def make_geometry_pdf(path: Path) -> Path:
doc.save(path)
doc.close()
return path
def _make_subsplash_roster_pdf(
path: Path,
roster_type: Literal["main", "nursery_only"],
dates: Sequence[str],
) -> Path:
"""Create a realistic one-page, vector Subsplash-style roster fixture.
The fixture deliberately resembles the structure of ``input/original.pdf``:
dates run across the top, role headings and assignment labels run down the
left, and the cells contain volunteer names. It is not used by the legacy
geometry golden, so changes here cannot mask a single-roster regression.
"""
if not dates:
raise ValueError("at least one date is required")
document = fitz.open()
page = document.new_page(width=792, height=612)
page.draw_rect(page.rect, color=None, fill=(1, 1, 1), width=0)
if roster_type == "main":
table = fitz.Rect(34, 48, 758, 446)
roles = (
("MEDIA", "Sound", "Alex Morgan"),
("NURSERY", "Nursery Workers", "Jamie Rivera"),
("LITTLE WADERS", "Teacher", "Taylor Brooks"),
("RIVER KID", "Teacher", "Jordan Lee"),
("GREETERS", "Greet", "Morgan Reed"),
("USHERS", "Usher", "Casey Miller"),
("OFFERING MESSAGE", "Speaker", "Riley Davis"),
("PRAYER", "Prayer", "Avery Wilson"),
)
else:
# Mirrors the compact real nursery strip: roughly 36 points of date
# header plus about 66 points for the band and worker row.
table = fitz.Rect(14, 188, 778, 290)
roles = (("NURSERY WORKERS", "Nursery Workers", "Jamie Rivera"),)
first_column_width = 154
header_height = 42 if roster_type == "main" else 36
date_width = (table.width - first_column_width) / len(dates)
role_height = (table.height - header_height) / len(roles)
navy = (0.06, 0.18, 0.25)
pale_blue = (0.90, 0.95, 0.97)
grid = (0.36, 0.43, 0.47)
page.draw_rect(table, color=grid, fill=(1, 1, 1), width=0.8)
page.draw_rect(
fitz.Rect(table.x0, table.y0, table.x1, table.y0 + header_height),
color=grid,
fill=navy,
width=0.8,
)
page.insert_text(
(table.x0 + 8, table.y0 + 26),
"SERVING SCHEDULE",
fontname="helv",
fontsize=10,
color=(1, 1, 1),
)
first_date_x = table.x0 + first_column_width
page.draw_line(
fitz.Point(first_date_x, table.y0),
fitz.Point(first_date_x, table.y1),
color=grid,
width=0.8,
)
for index, date_label in enumerate(dates):
x = first_date_x + index * date_width
if index:
page.draw_line(
fitz.Point(x, table.y0),
fitz.Point(x, table.y1),
color=grid,
width=0.6,
)
page.insert_text(
(x + 8, table.y0 + 18),
date_label,
fontname="hebo",
fontsize=9,
color=(1, 1, 1),
)
page.insert_text(
(x + 8, table.y0 + 32),
"7:00pm" if date_label.lower().startswith(("wed", "thu")) else "10:00am",
fontname="helv",
fontsize=8,
color=(1, 1, 1),
)
for role_index, (heading, assignment, volunteer) in enumerate(roles):
y0 = table.y0 + header_height + role_index * role_height
y1 = y0 + role_height
if role_index:
page.draw_line(
fitz.Point(table.x0, y0),
fitz.Point(table.x1, y0),
color=grid,
width=0.6,
)
page.draw_rect(
fitz.Rect(table.x0, y0, first_date_x, y1),
color=None,
fill=pale_blue,
width=0,
)
page.insert_text(
(table.x0 + 7, y0 + 15),
heading,
fontname="hebo",
fontsize=7.5,
color=navy,
)
page.insert_text(
(
table.x0 + 7,
min(y1 - 5, y0 + (31 if roster_type == "main" else 43)),
),
assignment,
fontname="helv",
fontsize=7.5,
color=(0.12, 0.12, 0.12),
)
for date_index in range(len(dates)):
x = first_date_x + date_index * date_width + 7
name = volunteer if date_index % 2 == 0 else f"{volunteer.split()[0]} Kim"
page.insert_text(
(
x,
y0
+ min(
27 if roster_type == "main" else 41,
role_height * 0.72,
),
),
name,
fontname="helv",
fontsize=7.5,
color=(0.08, 0.08, 0.08),
)
document.save(path)
document.close()
return path
def make_main_roster_pdf(
path: Path,
dates: Sequence[str] = ("Sun 6 Sep", "Sun 13 Sep", "Sun 20 Sep", "Sun 27 Sep"),
) -> Path:
"""Create a full roster with several broad ministry-role headings."""
return _make_subsplash_roster_pdf(path, "main", dates)
def make_nursery_only_roster_pdf(
path: Path,
dates: Sequence[str] = ("Wed 2 Sep", "Wed 9 Sep", "Wed 16 Sep", "Wed 23 Sep"),
) -> Path:
"""Create a roster whose only ministry role is Nursery Workers."""
return _make_subsplash_roster_pdf(path, "nursery_only", dates)
+266 -1
View File
@@ -1,12 +1,23 @@
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,
@@ -17,13 +28,20 @@ from roster_formatter import (
LayoutSettings,
RosterFormatError,
RosterPeriod,
format_combined_roster,
format_roster,
validate_input_pdf,
)
from tests.helpers import make_geometry_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):
@@ -79,6 +97,12 @@ class LayoutRegressionTests(unittest.TestCase):
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,
)
@@ -95,6 +119,24 @@ class LayoutRegressionTests(unittest.TestCase):
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)
@@ -125,6 +167,229 @@ class LayoutRegressionTests(unittest.TestCase):
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()
+393 -13
View File
@@ -9,12 +9,42 @@ import unittest
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from roster_formatter import DEFAULT_LAYOUT, LayoutSettings, RosterPeriod, format_roster
from server import OutputStore, RosterHTTPServer, RosterWebApp
from tests.helpers import make_geometry_pdf
from PIL import Image
from roster_formatter import (
DEFAULT_LAYOUT,
PNG_HEIGHT,
PNG_WIDTH,
LayoutSettings,
RosterFormatError,
RosterPeriod,
format_roster,
)
from server import (
LAYOUT_DEFAULTS_VERSION,
OutputStore,
RequestError,
RosterHTTPServer,
RosterWebApp,
UploadedFile,
parse_multipart,
)
from tests.helpers import (
make_geometry_pdf,
make_main_roster_pdf,
make_nursery_only_roster_pdf,
)
def multipart_body(fields: dict[str, str], filename: str, file_data: bytes) -> tuple[str, bytes]:
def multipart_body(
fields: dict[str, str],
filename: str | None = None,
file_data: bytes | None = None,
*,
uploads: list[tuple[str, bytes]] | None = None,
) -> tuple[str, bytes]:
"""Build a request with zero, one, or repeated ``roster`` file fields."""
boundary = "----RosterRegressionBoundary"
chunks: list[bytes] = []
for name, value in fields.items():
@@ -26,16 +56,24 @@ def multipart_body(fields: dict[str, str], filename: str, file_data: bytes) -> t
b"\r\n",
]
)
selected_uploads = uploads
if selected_uploads is None:
selected_uploads = [] if filename is None or file_data is None else [(filename, file_data)]
for selected_filename, selected_data in selected_uploads:
chunks.extend(
[
f"--{boundary}\r\n".encode(),
(
'Content-Disposition: form-data; name="roster"; '
f'filename="{selected_filename}"\r\n'
).encode(),
b"Content-Type: application/pdf\r\n\r\n",
selected_data,
b"\r\n",
]
)
chunks.extend(
[
f"--{boundary}\r\n".encode(),
(
'Content-Disposition: form-data; name="roster"; '
f'filename="{filename}"\r\n'
).encode(),
b"Content-Type: application/pdf\r\n\r\n",
file_data,
b"\r\n",
f"--{boundary}--\r\n".encode(),
]
)
@@ -55,6 +93,12 @@ class OutputStoreTests(unittest.TestCase):
date_x=472,
date_y=91,
date_font_size=52,
main_x=73,
main_y=128,
main_scale=0.79,
nursery_x=65,
nursery_y=515,
nursery_scale=0.91,
)
store = OutputStore(root, retention_months=3)
@@ -63,7 +107,7 @@ class OutputStoreTests(unittest.TestCase):
defaults_path = root / ".roster-layout-defaults.json"
self.assertTrue(defaults_path.is_file())
self.assertEqual(
{"version": 1, "layout": settings.as_json_values()},
{"version": LAYOUT_DEFAULTS_VERSION, "layout": settings.as_json_values()},
json.loads(defaults_path.read_text(encoding="utf-8")),
)
restarted_store = OutputStore(root, retention_months=3)
@@ -74,6 +118,47 @@ class OutputStoreTests(unittest.TestCase):
self.assertTrue(defaults_path.is_file())
self.assertEqual(settings, restarted_store.load_layout_defaults())
def test_v1_layout_defaults_load_with_factory_combined_values_and_migrate(self) -> None:
legacy_layout = {
"content_shift": 117,
"bottom_margin": 63,
"logo_x": 54,
"logo_y": -6,
"logo_scale": 0.3,
"date_x": 469,
"date_y": 92,
"date_font_size": 51,
}
expected = LayoutSettings.from_mapping(
{name: str(value) for name, value in legacy_layout.items()},
defaults=DEFAULT_LAYOUT,
)
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = OutputStore(root, retention_months=3)
store.layout_defaults_path.write_text(
json.dumps({"version": 1, "layout": legacy_layout}),
encoding="utf-8",
)
loaded = store.load_layout_defaults()
self.assertEqual(expected, loaded)
self.assertEqual(DEFAULT_LAYOUT.main_x, loaded.main_x)
self.assertEqual(DEFAULT_LAYOUT.main_y, loaded.main_y)
self.assertEqual(DEFAULT_LAYOUT.main_scale, loaded.main_scale)
self.assertEqual(DEFAULT_LAYOUT.nursery_x, loaded.nursery_x)
self.assertEqual(DEFAULT_LAYOUT.nursery_y, loaded.nursery_y)
self.assertEqual(DEFAULT_LAYOUT.nursery_scale, loaded.nursery_scale)
store.save_layout_defaults(loaded)
migrated = json.loads(store.layout_defaults_path.read_text(encoding="utf-8"))
self.assertEqual(LAYOUT_DEFAULTS_VERSION, migrated["version"])
self.assertEqual(set(DEFAULT_LAYOUT.as_form_values()), set(migrated["layout"]))
self.assertEqual(expected.as_json_values(), migrated["layout"])
def test_invalid_layout_defaults_fall_back_to_factory_values(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
@@ -137,6 +222,167 @@ class OutputStoreTests(unittest.TestCase):
self.assertTrue(old.exists())
class MultipartParserTests(unittest.TestCase):
def test_one_roster_file_is_accepted(self) -> None:
content_type, body = multipart_body(
{"month": "2026-09"},
"main.pdf",
b"%PDF-one",
)
values, uploads = parse_multipart(content_type, body)
self.assertEqual({"month": "2026-09"}, values)
self.assertEqual(1, len(uploads))
self.assertEqual("main.pdf", uploads[0].filename)
self.assertEqual(b"%PDF-one", uploads[0].data)
def test_two_repeated_roster_files_are_accepted_in_order(self) -> None:
content_type, body = multipart_body(
{"month": "2026-09"},
uploads=[
("nursery.pdf", b"%PDF-nursery"),
("main.pdf", b"%PDF-main"),
],
)
values, uploads = parse_multipart(content_type, body)
self.assertEqual({"month": "2026-09"}, values)
self.assertEqual(["nursery.pdf", "main.pdf"], [upload.filename for upload in uploads])
self.assertEqual(
[b"%PDF-nursery", b"%PDF-main"],
[upload.data for upload in uploads],
)
def test_zero_roster_files_is_rejected(self) -> None:
content_type, body = multipart_body({"month": "2026-09"}, uploads=[])
with self.assertRaises(RequestError) as caught:
parse_multipart(content_type, body)
self.assertEqual("Upload one or two roster PDFs.", caught.exception.message)
def test_three_roster_files_is_rejected(self) -> None:
content_type, body = multipart_body(
{"month": "2026-09"},
uploads=[
("first.pdf", b"%PDF-first"),
("second.pdf", b"%PDF-second"),
("third.pdf", b"%PDF-third"),
],
)
with self.assertRaises(RequestError) as caught:
parse_multipart(content_type, body)
self.assertEqual("Upload one or two roster PDFs.", caught.exception.message)
class RosterWebAppUnitTests(unittest.TestCase):
@staticmethod
def _uploaded(path: Path) -> UploadedFile:
return UploadedFile(
filename=path.name,
content_type="application/pdf",
data=path.read_bytes(),
)
def test_combined_format_retains_only_the_final_png(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
output_dir = root / "output"
main = make_main_roster_pdf(root / "main.pdf")
nursery = make_nursery_only_roster_pdf(root / "nursery.pdf")
app = RosterWebApp(output_dir, retention_months=0)
values = DEFAULT_LAYOUT.as_form_values()
values["month"] = "2026-09"
png, filename, saved, removed = app.format_upload(
[self._uploaded(main), self._uploaded(nursery)],
values,
)
self.assertEqual("SEP 2026 Roster.png", filename)
self.assertTrue(saved)
self.assertEqual([], removed)
self.assertEqual(png, (output_dir / filename).read_bytes())
self.assertEqual([filename], sorted(path.name for path in output_dir.iterdir()))
self.assertFalse(list(output_dir.glob("*.pdf")))
self.assertFalse(list(output_dir.glob("*.tmp")))
with Image.open(output_dir / filename) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
def test_each_pdf_is_independently_validated(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
output_dir = root / "output"
main = make_main_roster_pdf(root / "main.pdf")
nursery = make_nursery_only_roster_pdf(root / "nursery.pdf")
valid_main = self._uploaded(main)
valid_nursery = self._uploaded(nursery)
invalid = UploadedFile("damaged.pdf", "application/pdf", b"not a pdf")
app = RosterWebApp(output_dir, retention_months=0)
for uploads, expected_index in (
([invalid, valid_nursery], 1),
([valid_main, invalid], 2),
):
with self.subTest(invalid_pdf=expected_index):
with self.assertRaisesRegex(
RosterFormatError,
rf"PDF {expected_index}: The uploaded file is not a PDF",
):
app.format_upload(uploads, {"month": "2026-09"})
self.assertEqual([], list(output_dir.iterdir()))
def test_filename_validation_is_independent_for_second_pdf(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")
app = RosterWebApp(root / "output", retention_months=0)
mislabeled = UploadedFile(
"nursery.txt",
"application/pdf",
nursery.read_bytes(),
)
with self.assertRaisesRegex(
RosterFormatError,
r"PDF 2: Choose a file whose name ends in \.pdf",
):
app.format_upload([self._uploaded(main), mislabeled], {"month": "2026-09"})
def test_per_file_limit_identifies_the_oversized_second_pdf(self) -> None:
with TemporaryDirectory() as temp_dir:
app = RosterWebApp(
Path(temp_dir) / "output",
retention_months=0,
max_upload_bytes=5,
)
first = UploadedFile("first.pdf", "application/pdf", b"%PDF")
second = UploadedFile("second.pdf", "application/pdf", b"123456")
with self.assertRaises(RequestError) as caught:
app.format_upload([first, second], {"month": "2026-09"})
self.assertIn("PDF 2:", caught.exception.message)
self.assertIn("Each PDF must be", caught.exception.message)
def test_request_limit_leaves_room_for_two_individually_valid_files(self) -> None:
with TemporaryDirectory() as temp_dir:
per_file_bytes = 20 * 1024 * 1024
app = RosterWebApp(
Path(temp_dir) / "output",
retention_months=0,
max_upload_bytes=per_file_bytes,
)
self.assertGreater(app.max_request_bytes, 2 * per_file_bytes)
class WebServerTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = TemporaryDirectory()
@@ -169,6 +415,39 @@ class WebServerTests(unittest.TestCase):
self.assertNotIn("__DEFAULT_MONTH__", html)
self.assertNotIn("__LOGO_SCALE__", html)
self.assertIn('data-max-upload-mb="20"', html)
self.assertEqual(1, html.count('name="roster" type="file"'))
self.assertIn('name="roster" type="file" accept="application/pdf,.pdf" multiple', html)
self.assertIn('id="selected-files"', html)
self.assertIn("one main roster, optional nursery roster", html)
self.assertIn("<legend>Main roster</legend>", html)
self.assertIn("<legend>Nursery-only roster</legend>", html)
for field in (
"main_x",
"main_y",
"main_scale",
"nursery_x",
"nursery_y",
"nursery_scale",
):
self.assertIn(f'name="{field}"', html)
self.assertNotIn('name="main_roster"', html)
self.assertNotIn('name="nursery_roster"', html)
def test_static_script_supports_picker_and_drop_for_two_files(self) -> None:
with urlopen(f"{self.base_url}/static/app.js", timeout=5) as response:
script = response.read().decode()
self.assertIn("let selectedFiles = [];", script)
self.assertIn("if (files.length > 2)", script)
self.assertIn("chooseFiles(fileInput.files)", script)
self.assertIn("chooseFiles(files)", script)
self.assertIn(
'selectedFiles.forEach((file) => formData.append("roster", file, file.name))',
script,
)
self.assertIn("roster types are identified automatically", script)
self.assertIn("input.value = input.dataset.default", script)
self.assertIn("input.value = input.dataset.factory", script)
def test_layout_defaults_endpoint_persists_canonical_values_in_index(self) -> None:
submitted = DEFAULT_LAYOUT.as_form_values()
@@ -182,6 +461,12 @@ class WebServerTests(unittest.TestCase):
"date_x": "475",
"date_y": "92",
"date_font_size": "53",
"main_x": "72",
"main_y": "126",
"main_scale": "0.810",
"nursery_x": "68",
"nursery_y": "514",
"nursery_scale": "0.920",
}
)
expected = LayoutSettings.from_mapping(submitted).as_form_values()
@@ -218,6 +503,16 @@ class WebServerTests(unittest.TestCase):
'data-default="53" data-factory="48"',
html,
)
self.assertIn(
'name="main_x" type="number" value="72.0" '
'data-default="72.0" data-factory="66"',
html,
)
self.assertIn(
'name="nursery_scale" type="number" value="0.92" '
'data-default="0.92" data-factory="0.85"',
html,
)
self.assertNotIn("__FACTORY_", html)
def test_layout_defaults_endpoint_requires_every_layout_field(self) -> None:
@@ -305,6 +600,91 @@ class WebServerTests(unittest.TestCase):
self.assertEqual(direct.read_bytes(), web_png)
self.assertEqual(web_png, (self.output_dir / "JULY 2026 Roster.png").read_bytes())
def test_two_pdf_upload_returns_combined_png_and_no_working_files(self) -> None:
main = make_main_roster_pdf(self.root / "main.pdf")
nursery = make_nursery_only_roster_pdf(self.root / "nursery.pdf")
fields = DEFAULT_LAYOUT.as_form_values()
fields["month"] = "2026-09"
content_type, body = multipart_body(
fields,
uploads=[
(nursery.name, nursery.read_bytes()),
(main.name, main.read_bytes()),
],
)
request = Request(
f"{self.base_url}/format",
data=body,
method="POST",
headers={"Content-Type": content_type},
)
with urlopen(request, timeout=15) as response:
png = response.read()
self.assertIn("SEP 2026 Roster.png", response.headers["Content-Disposition"])
output = self.output_dir / "SEP 2026 Roster.png"
self.assertEqual(png, output.read_bytes())
self.assertEqual([output.name], sorted(path.name for path in self.output_dir.iterdir()))
self.assertFalse(list(self.output_dir.glob("*.pdf")))
self.assertFalse(list(self.output_dir.glob("*.tmp")))
with Image.open(output) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
def test_two_main_pdfs_return_clear_ambiguity_error(self) -> None:
first = make_main_roster_pdf(self.root / "first.pdf")
second = make_main_roster_pdf(self.root / "second.pdf")
content_type, body = multipart_body(
{"month": "2026-09"},
uploads=[
(first.name, first.read_bytes()),
(second.name, second.read_bytes()),
],
)
request = Request(
f"{self.base_url}/format",
data=body,
method="POST",
headers={"Content-Type": content_type},
)
with self.assertRaises(HTTPError) as caught:
urlopen(request, timeout=10)
self.assertEqual(422, caught.exception.code)
error = json.loads(caught.exception.read())["error"]
self.assertIn("could not determine one full roster and one nursery-only roster", error)
self.assertIn("PDF 1: main", error)
self.assertIn("PDF 2: main", error)
self.assertEqual([], list(self.output_dir.iterdir()))
def test_request_level_limit_rejects_total_before_individual_file_limits(self) -> None:
main = make_main_roster_pdf(self.root / "main.pdf")
nursery = make_nursery_only_roster_pdf(self.root / "nursery.pdf")
content_type, body = multipart_body(
{"month": "2026-09"},
uploads=[
(main.name, main.read_bytes()),
(nursery.name, nursery.read_bytes()),
],
)
self.assertLess(len(main.read_bytes()), self.app.max_upload_bytes)
self.assertLess(len(nursery.read_bytes()), self.app.max_upload_bytes)
self.app.max_request_bytes = len(body) - 1
request = Request(
f"{self.base_url}/format",
data=body,
method="POST",
headers={"Content-Type": content_type},
)
with self.assertRaises(HTTPError) as caught:
urlopen(request, timeout=5)
self.assertEqual(413, caught.exception.code)
self.assertEqual({"error": "The upload is too large."}, json.loads(caught.exception.read()))
self.assertEqual([], list(self.output_dir.iterdir()))
def test_format_missing_layout_fields_uses_saved_defaults(self) -> None:
settings = LayoutSettings(
content_shift=116,