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
+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,