Bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — 6 feuilles réalistes : 11 pages ⇒ 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
7.6 KiB
Python
229 lines
7.6 KiB
Python
"""Génération des feuilles de poste (module pur, sans base)."""
|
||
|
||
import base64
|
||
import datetime as dt
|
||
import io
|
||
|
||
from app.sheets.pdf import (
|
||
PositionDaySheet,
|
||
SheetArtist,
|
||
SheetShift,
|
||
SheetVolunteer,
|
||
generate_position_sheets_pdf,
|
||
)
|
||
|
||
# PNG 1×1 valide (transparent), le plus petit possible.
|
||
_PNG = base64.b64decode(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||
"42YAAAAASUVORK5CYII="
|
||
)
|
||
|
||
|
||
def _sheet(volunteers=(), min_staff=1, ideal_staff=2) -> PositionDaySheet:
|
||
return PositionDaySheet(
|
||
position_name="Bar central",
|
||
position_location="Chapiteau",
|
||
date=dt.date(2026, 7, 10),
|
||
shifts=(
|
||
SheetShift(
|
||
starts_at=dt.datetime(2026, 7, 10, 19, 0),
|
||
ends_at=dt.datetime(2026, 7, 10, 22, 0),
|
||
min_staff=min_staff,
|
||
ideal_staff=ideal_staff,
|
||
volunteers=volunteers,
|
||
),
|
||
),
|
||
)
|
||
|
||
|
||
def test_shift_is_understaffed_when_below_min_staff():
|
||
fully_staffed = _sheet(
|
||
volunteers=(SheetVolunteer(name="Alex Martin", phone="0600000000"),),
|
||
min_staff=1,
|
||
)
|
||
assert fully_staffed.shifts[0].understaffed is False
|
||
|
||
understaffed = _sheet(volunteers=(), min_staff=1)
|
||
assert understaffed.shifts[0].understaffed is True
|
||
|
||
|
||
def test_generates_a_valid_pdf_with_volunteers():
|
||
sheet = _sheet(
|
||
volunteers=(
|
||
SheetVolunteer(name="Alex Martin", phone="0600000000"),
|
||
SheetVolunteer(name="Sam Nuit", phone=None),
|
||
)
|
||
)
|
||
pdf = generate_position_sheets_pdf([sheet])
|
||
assert pdf.startswith(b"%PDF")
|
||
assert pdf.rstrip().endswith(b"%%EOF")
|
||
|
||
|
||
def test_generates_a_valid_pdf_with_no_volunteers_assigned():
|
||
pdf = generate_position_sheets_pdf([_sheet(volunteers=())])
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
def test_generates_a_valid_pdf_with_no_shifts_that_day():
|
||
sheet = PositionDaySheet(
|
||
position_name="Bar central", position_location=None,
|
||
date=dt.date(2026, 7, 10), shifts=(),
|
||
)
|
||
pdf = generate_position_sheets_pdf([sheet])
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
def test_multiple_sheets_produce_a_larger_pdf():
|
||
one = generate_position_sheets_pdf([_sheet()])
|
||
two = generate_position_sheets_pdf([_sheet(), _sheet()])
|
||
assert len(two) > len(one)
|
||
|
||
|
||
def test_generates_a_valid_pdf_with_a_referent():
|
||
sheet = PositionDaySheet(
|
||
position_name="Bar central", position_location="Chapiteau",
|
||
date=dt.date(2026, 7, 10), shifts=(),
|
||
referent_name="Sam Nuit", referent_phone="0600000000",
|
||
)
|
||
pdf = generate_position_sheets_pdf([sheet])
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
def test_generates_a_valid_pdf_without_a_referent():
|
||
sheet = PositionDaySheet(
|
||
position_name="Bar central", position_location="Chapiteau",
|
||
date=dt.date(2026, 7, 10), shifts=(),
|
||
)
|
||
assert sheet.referent_name is None
|
||
pdf = generate_position_sheets_pdf([sheet])
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
def test_generates_a_valid_pdf_with_volunteer_photos():
|
||
sheet = _sheet(
|
||
volunteers=(
|
||
SheetVolunteer(name="Alex Martin", phone="0600000000", photo=_PNG),
|
||
SheetVolunteer(name="Sam Nuit", phone=None, photo=None),
|
||
)
|
||
)
|
||
assert sheet.shifts[0].volunteers[0].photo == _PNG
|
||
assert sheet.shifts[0].volunteers[1].photo is None
|
||
pdf = generate_position_sheets_pdf([sheet])
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
def test_a_volunteer_photo_makes_the_pdf_larger():
|
||
without_photo = generate_position_sheets_pdf(
|
||
[_sheet(volunteers=(SheetVolunteer(name="Alex Martin", phone=None, photo=None),))]
|
||
)
|
||
with_photo = generate_position_sheets_pdf(
|
||
[_sheet(volunteers=(SheetVolunteer(name="Alex Martin", phone=None, photo=_PNG),))]
|
||
)
|
||
assert len(with_photo) > len(without_photo)
|
||
|
||
|
||
def test_generates_a_valid_pdf_with_the_day_lineup():
|
||
sheet = PositionDaySheet(
|
||
position_name="Bar central", position_location="Chapiteau",
|
||
date=dt.date(2026, 7, 10), shifts=(),
|
||
artists=(
|
||
SheetArtist(name="Chilla", start_time=dt.time(20, 0), end_time=dt.time(21, 0)),
|
||
SheetArtist(name="XYZ", start_time=dt.time(21, 30), end_time=dt.time(23, 0)),
|
||
),
|
||
)
|
||
pdf = generate_position_sheets_pdf([sheet])
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
def test_generates_a_valid_pdf_without_a_lineup():
|
||
sheet = PositionDaySheet(
|
||
position_name="Bar central", position_location="Chapiteau",
|
||
date=dt.date(2026, 7, 10), shifts=(),
|
||
)
|
||
assert sheet.artists == ()
|
||
pdf = generate_position_sheets_pdf([sheet])
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
def _phone_photo(seed: int) -> bytes:
|
||
"""Photo de téléphone : 3000 × 4000 px, plusieurs Mo."""
|
||
from PIL import Image as PILImage
|
||
|
||
img = PILImage.new("RGB", (3000, 4000), (seed * 40 % 255, 120, 60))
|
||
img.paste(PILImage.effect_noise((800, 800), 80).convert("RGB"), (seed * 100, 200))
|
||
out = io.BytesIO()
|
||
img.save(out, format="JPEG", quality=92)
|
||
return out.getvalue()
|
||
|
||
|
||
def test_large_photos_are_shrunk_to_the_thumbnail():
|
||
# Régression : la photo d'origine était intégrée telle quelle pour
|
||
# chaque bénévole de chaque créneau — génération interminable, PDF
|
||
# énorme, puis erreur.
|
||
photos = [_phone_photo(i) for i in range(5)]
|
||
volunteers = tuple(
|
||
SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=p) for i, p in enumerate(photos)
|
||
)
|
||
pdf = generate_position_sheets_pdf([_sheet(volunteers=volunteers)])
|
||
assert sum(len(p) for p in photos) > 2_000_000
|
||
assert len(pdf) < 200_000
|
||
|
||
|
||
def test_unreadable_photo_does_not_break_the_sheet():
|
||
pdf = generate_position_sheets_pdf(
|
||
[_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))]
|
||
)
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
def _page_texts(pdf: bytes) -> list[str]:
|
||
from pypdf import PdfReader
|
||
|
||
return [page.extract_text() for page in PdfReader(io.BytesIO(pdf)).pages]
|
||
|
||
|
||
def test_footer_message_is_printed_at_the_bottom_of_every_page():
|
||
from app.sheets.pdf import SheetFooter
|
||
|
||
# Deux feuilles, dont une assez longue pour déborder sur une 2e page.
|
||
many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(120))
|
||
pdf = generate_position_sheets_pdf(
|
||
[_sheet(volunteers=many), _sheet()],
|
||
SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"),
|
||
)
|
||
pages = _page_texts(pdf)
|
||
assert len(pages) >= 3
|
||
for text in pages:
|
||
assert "Message de l'organisation" in text
|
||
assert "Repas à 18h30" in text
|
||
assert "Badges à l'accueil" in text
|
||
|
||
|
||
def test_no_footer_without_message():
|
||
pages = _page_texts(generate_position_sheets_pdf([_sheet()]))
|
||
assert "Message de l'organisation" not in pages[0]
|
||
|
||
|
||
def test_volunteers_are_listed_two_per_row():
|
||
from app.sheets.pdf import _shift_table, _styles
|
||
|
||
shift = _sheet(
|
||
volunteers=tuple(SheetVolunteer(name=f"B{i}", phone=None, photo=None) for i in range(5))
|
||
).shifts[0]
|
||
table = _shift_table(shift, {}, _styles(), 180)
|
||
# 5 bénévoles, deux par ligne : 3 lignes (la dernière à moitié vide).
|
||
assert len(table._cellvalues) == 3
|
||
|
||
|
||
def test_a_typical_sheet_fits_on_one_page():
|
||
volunteers = tuple(
|
||
SheetVolunteer(name=f"Prénom{i} Nom{i}", phone="06 12 34 56 78", photo=_PNG)
|
||
for i in range(8)
|
||
)
|
||
shift = _sheet(volunteers=volunteers).shifts[0]
|
||
sheet = _sheet(volunteers=volunteers)
|
||
many_shifts = PositionDaySheet(
|
||
**{**sheet.__dict__, "shifts": (shift, shift, shift, shift)}
|
||
)
|
||
assert len(_page_texts(generate_position_sheets_pdf([many_shifts]))) == 1
|