Add optional nursery roster composition
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user