feat: generate print files, collect delivery addresses, add provider adapters
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
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
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>
This commit is contained in:
190
tests/test_printfile.py
Normal file
190
tests/test_printfile.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user