Files
dtf-system/tests/test_printfile.py
Cauê Faleiros c18b9e5b87
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
feat: generate print files, collect delivery addresses, add provider adapters
Week 2 work that did not need client inputs.

Print files (1.4): each paid item gets a PDF the width of the film and the
length of the approved layout, with every copy at its reviewed position,
rotation and mirror. Sources are embedded once at original resolution; JPEG
bytes pass through and PNG alpha becomes a soft mask. Artwork the generator
cannot reproduce goes to hand preparation with the reason. The worker renders
outside any transaction, and the operator approves the generated file as the
final one through the existing review.

Delivery address (3.8): required for any non-pickup quote, bound to the
quoted CEP, carried into the order snapshot, the Kanban card and Tiny.

Kanban (1.5): print-file status per item, and a panel of payment events that
need a person (money without an order, refunds after an order) until an
operator records the resolution.

Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API
documentation and tested against fake transports only. Selectable for
sandbox testing with their credentials; the production preflight still
blocks release. Adds payment intents and a PIX step on the Site.

MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI
storage use Chainguard's MinIO build, pinned by digest.

Verified with the full CI integration sequence on a fresh local build,
including the new print_file_test and both browser suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:56:46 -03:00

191 lines
8.7 KiB
Python

"""The print file must reproduce the reviewed layout: size, place, turn and mirror.
Runs where Pillow is installed (the API image, or a local virtualenv). The
raster checks additionally need PyMuPDF to draw the page, and skip without it;
the structural checks do not.
"""
import io
import os
import re
import tempfile
import unittest
import zlib
from PIL import Image
from app.printfile import PT_PER_CM, Unsupported, placement_matrix, render
try:
import fitz # PyMuPDF, a development-only rasteriser
except ImportError:
fitz = None
RED, GREEN, BLUE, YELLOW = (255, 0, 0), (0, 160, 0), (0, 0, 255), (255, 220, 0)
def quadrants(width=80, height=40, alpha=False):
"""Top-left red, top-right green, bottom-left blue, bottom-right yellow."""
image = Image.new('RGBA' if alpha else 'RGB', (width, height))
for x in range(width):
for y in range(height):
left, top = x < width // 2, y < height // 2
color = RED if left and top else GREEN if top else BLUE if left else YELLOW
image.putpixel((x, y), color + ((255,) if alpha else ()))
return image
def item(placements, sources, height_cm, billed='1.0', film=57):
return {'mode': 'avulsa', 'billed_metres': billed,
'production': {'version': 2, 'film_width_cm': film, 'height_cm': height_cm,
'sources': sources, 'placements': placements}}
def source(width_cm, length_cm, copies=1, rotation=0, mirrored=False):
return {'upload_id': '00000000-0000-0000-0000-000000000000', 'kind': 'artwork',
'width_cm': width_cm, 'length_cm': length_cm, 'copies': copies,
'rotation_degrees': rotation, 'mirrored': mirrored, 'measurement': 'file'}
def placement(x, y, width, length, rotation=0, mirrored=False, index=0, copy=0):
return {'source_index': index, 'copy_index': copy, 'x_cm': x, 'y_cm': y,
'width_cm': width, 'length_cm': length,
'rotation_degrees': rotation, 'mirrored': mirrored}
class PrintFileTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def save(self, image, name, **options):
path = os.path.join(self.dir.name, name)
image.save(path, **options)
return path
def render(self, spec, paths):
out = io.BytesIO()
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
return out.getvalue(), detail
def test_page_is_the_film_and_the_layout_length(self):
path = self.save(quadrants(), 'a.png')
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
[source(20, 10, copies=2)], 10), [path])
box = re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf)
self.assertAlmostEqual(float(box.group(1)), 57 * PT_PER_CM, places=2)
self.assertAlmostEqual(float(box.group(2)), 10 * PT_PER_CM, places=2)
# One embedded image, drawn once per copy.
self.assertEqual(pdf.count(b'/Subtype /Image'), 1)
content = self.content(pdf)
self.assertEqual(content.count(b' Do '), 2)
self.assertEqual(detail['placements'], 2)
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
self.assertTrue(pdf.startswith(b'%PDF-1.6') and pdf.rstrip().endswith(b'%%EOF'))
def content(self, pdf):
# The page content is the last Flate stream before the page object.
streams = re.findall(rb'/Filter /FlateDecode/Length \d+ 0 R>>\nstream\n(.*?)\nendstream', pdf, re.S)
return zlib.decompress(streams[-1])
def test_jpeg_bytes_are_embedded_unchanged(self):
path = self.save(quadrants(), 'a.jpg', quality=90)
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
with open(path, 'rb') as original:
self.assertIn(original.read(), pdf)
self.assertIn(b'/DCTDecode', pdf)
def test_transparency_survives_as_a_soft_mask(self):
image = quadrants(alpha=True)
image.putpixel((0, 0), (0, 0, 0, 0))
path = self.save(image, 'a.png')
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
self.assertIn(b'/SMask', pdf)
self.assertEqual(pdf.count(b'/Subtype /Image'), 2)
def test_exif_orientation_is_honoured_like_a_browser(self):
# Stored landscape, tagged "rotate 90": browsers show it portrait.
image = quadrants(80, 40)
exif = Image.Exif()
exif[0x0112] = 6
path = self.save(image, 'a.jpg', exif=exif)
pdf, _ = self.render(item([placement(0, 0, 10, 20)], [source(10, 20)], 20), [path])
self.assertNotIn(b'/DCTDecode', pdf)
with self.assertRaises(Unsupported):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
def test_refuses_what_it_cannot_reproduce(self):
png = self.save(quadrants(), 'a.png')
with self.assertRaisesRegex(Unsupported, 'proportions'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
with self.assertRaisesRegex(Unsupported, 'billed'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
with open(fake_pdf, 'wb') as handle:
handle.write(b'%PDF-1.4\n%%EOF\n')
with self.assertRaisesRegex(Unsupported, 'not an image'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
def test_long_layouts_scale_user_space_instead_of_splitting(self):
# 6 m is longer than a PDF page may be (about 5.08 m).
path = self.save(quadrants(40, 800), 'a.png')
pdf, detail = self.render(item([placement(0, 0, 30, 600)], [source(30, 600)], 600,
billed='6.0'), [path])
self.assertEqual(detail['user_unit'], 2)
self.assertIn(b'/UserUnit 2', pdf)
def test_matrix_maps_corners_like_the_canvas(self):
# Box 20 x 10 cm at the page's top-left; which image corner lands where.
page = 10 * PT_PER_CM
def corner(matrix, u, v):
a, b, c, d, e, f = matrix
x, y = a * u + c * v + e, b * u + d * v + f
return round(x / PT_PER_CM, 6), round((page - y) / PT_PER_CM, 6)
# Image top-left is unit (0, 1).
cases = {(0, False): (0, 0), (90, False): (20, 0), (180, False): (20, 10),
(270, False): (0, 10), (0, True): (20, 0), (90, True): (20, 10)}
for (rotation, mirrored), expected in cases.items():
matrix = placement_matrix(placement(0, 0, 20, 10, rotation, mirrored), page)
self.assertEqual(corner(matrix, 0, 1), expected, (rotation, mirrored))
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
def test_drawn_page_matches_the_layout(self):
# Each case: the colour expected in the box's TL, TR, BL, BR quadrant.
cases = {
(0, False): (RED, GREEN, BLUE, YELLOW),
(90, False): (BLUE, RED, YELLOW, GREEN),
(180, False): (YELLOW, BLUE, GREEN, RED),
(0, True): (GREEN, RED, YELLOW, BLUE),
(90, True): (YELLOW, GREEN, BLUE, RED),
}
image = quadrants(80, 40)
for (rotation, mirrored), expected in cases.items():
turned = rotation % 180 == 90
width, length = (10, 20) if turned else (20, 10)
path = self.save(image, f'q{rotation}{mirrored}.png')
# Sources are described after the customer's turn, like the box.
spec = item([placement(12, 5, width, length, rotation, mirrored)],
[source(width, length, rotation=rotation % 180, mirrored=mirrored)], 30)
pdf, _ = self.render(spec, [path])
page = fitz.open(stream=pdf, filetype='pdf')[0]
dpi = 40
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
def sample(x_cm, y_cm):
px = int(x_cm / 2.54 * dpi)
py = int(y_cm / 2.54 * dpi)
return pixmap.pixel(px, py)
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
for actual, wanted in zip(got, expected):
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
f'rotation {rotation} mirrored {mirrored}: {got} != {expected}')
# Outside the box the film stays empty.
self.assertEqual(sample(2, 2), (255, 255, 255))
if __name__ == '__main__':
unittest.main()