1
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from pptx import Presentation
|
||||
from pptx.util import Pt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from exports import export_ppt
|
||||
|
||||
|
||||
def fixture(root, kind, index, size=None):
|
||||
dimensions = size or ((1200, 700) if kind == 'invoice' else (360, 900))
|
||||
path = root / f'{kind}-{index}.png'
|
||||
image = Image.new('RGB', dimensions, 'white')
|
||||
drawing = ImageDraw.Draw(image)
|
||||
color = '#ab3d3d' if kind == 'invoice' else '#285b50'
|
||||
drawing.rectangle((10, 10, dimensions[0] - 10, dimensions[1] - 10), outline=color, width=3)
|
||||
drawing.text((30, 30), f'{kind.upper()} {index + 1}', fill=color, font_size=28)
|
||||
if kind == 'invoice':
|
||||
for fraction in [0.25, 0.4, 0.72, 0.82]:
|
||||
height = int(dimensions[1] * fraction)
|
||||
drawing.line((20, height, dimensions[0] - 20, height), fill=color, width=2)
|
||||
drawing.text((40, dimensions[1] // 2), 'Receipt layout test - NOT A REAL INVOICE', fill=color, font_size=24)
|
||||
else:
|
||||
drawing.text((40, 130), 'PAYMENT RECEIPT', fill=color, font_size=22)
|
||||
drawing.text((40, 240), f'Test amount: {index + 1}.00', fill=color, font_size=20)
|
||||
for row in range(6):
|
||||
drawing.text((40, 340 + row * 55), f'Test payment field {row + 1}', fill=color, font_size=16)
|
||||
image.save(path)
|
||||
return dict(id=f'{kind}-{index}', path=str(path), ocr=dict(rawText='', dates=['2026-09-16']))
|
||||
|
||||
|
||||
class PortraitPPTTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.invoices = [fixture(self.root, 'invoice', index) for index in range(5)]
|
||||
self.payments = [fixture(self.root, 'payment', index) for index in range(15)]
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def match(self, invoices, payments):
|
||||
return dict(invoices=self.invoices[:invoices], payments=self.payments[:payments], category='交通')
|
||||
|
||||
def export(self, invoices, payments):
|
||||
destination = self.root / 'portrait.pptx'
|
||||
export_ppt(dict(matches=[self.match(invoices, payments)]), destination, True)
|
||||
return Presentation(destination)
|
||||
|
||||
def pictures(self, slide, kind=None):
|
||||
return [shape for shape in slide.shapes if shape.shape_type == 13 and
|
||||
(kind is None or shape.name.startswith(kind + '-'))]
|
||||
|
||||
def assert_geometry_and_materials(self, deck, invoices, payments):
|
||||
self.assertEqual((deck.slide_width, deck.slide_height), (Pt(720), Pt(960)))
|
||||
expected = Counter(hashlib.sha256(Path(item['path']).read_bytes()).hexdigest()
|
||||
for item in self.invoices[:invoices] + self.payments[:payments])
|
||||
actual = Counter(hashlib.sha256(shape.image.blob).hexdigest()
|
||||
for page in deck.slides for shape in self.pictures(page))
|
||||
self.assertEqual(actual, expected)
|
||||
for page in deck.slides:
|
||||
invoice_shapes = self.pictures(page, 'invoice')
|
||||
payment_shapes = self.pictures(page, 'payment')
|
||||
self.assertLessEqual(len(invoice_shapes), 2)
|
||||
self.assertLessEqual(len(payment_shapes), 6)
|
||||
if len(invoice_shapes) == 2:
|
||||
self.assertEqual(len(payment_shapes), 0)
|
||||
self.assertLessEqual(invoice_shapes[0].top + invoice_shapes[0].height, invoice_shapes[1].top)
|
||||
if len(invoice_shapes) == 1:
|
||||
self.assertLessEqual(len(payment_shapes), 2)
|
||||
for payment in payment_shapes:
|
||||
self.assertLessEqual(invoice_shapes[0].top + invoice_shapes[0].height, payment.top)
|
||||
for shape in page.shapes:
|
||||
self.assertGreaterEqual(shape.left, 0)
|
||||
self.assertGreaterEqual(shape.top, 0)
|
||||
self.assertLessEqual(shape.left + shape.width, deck.slide_width)
|
||||
self.assertLessEqual(shape.top + shape.height, deck.slide_height)
|
||||
for index, shape in enumerate(page.shapes):
|
||||
for other in list(page.shapes)[index + 1:]:
|
||||
separated = (shape.left + shape.width <= other.left or other.left + other.width <= shape.left or
|
||||
shape.top + shape.height <= other.top or other.top + other.height <= shape.top)
|
||||
self.assertTrue(separated, (shape.name, other.name))
|
||||
if shape.shape_type == 13:
|
||||
image_width, image_height = shape.image.size
|
||||
self.assertAlmostEqual(shape.width / shape.height, image_width / image_height, places=4)
|
||||
self.assertEqual((shape.crop_left, shape.crop_right, shape.crop_top, shape.crop_bottom), (0, 0, 0, 0))
|
||||
|
||||
def test_single_pair_leaves_lower_right_photo_area(self):
|
||||
deck = self.export(1, 1)
|
||||
self.assertEqual(len(deck.slides), 1)
|
||||
page = deck.slides[0]
|
||||
payment = self.pictures(page, 'payment')[0]
|
||||
photo = next(shape for shape in page.shapes if shape.name == 'manual-photo-area')
|
||||
self.assertLessEqual(payment.left + payment.width, photo.left)
|
||||
self.assert_geometry_and_materials(deck, 1, 1)
|
||||
|
||||
def test_single_invoice_eight_payments_matches_reference_pages(self):
|
||||
deck = self.export(1, 8)
|
||||
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [3, 6, 0])
|
||||
grid = self.pictures(deck.slides[1])
|
||||
self.assertEqual(len({shape.top for shape in grid}), 2)
|
||||
self.assertEqual(len({shape.left for shape in grid}), 3)
|
||||
self.assertEqual(deck.slides[2].shapes[0].name, 'manual-photo-area')
|
||||
self.assert_geometry_and_materials(deck, 1, 8)
|
||||
|
||||
def test_three_invoices_two_payments_matches_reference_pages(self):
|
||||
deck = self.export(3, 2)
|
||||
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [2, 3, 0])
|
||||
self.assertEqual(len(self.pictures(deck.slides[0], 'invoice')), 2)
|
||||
self.assertEqual(len(self.pictures(deck.slides[1], 'invoice')), 1)
|
||||
self.assert_geometry_and_materials(deck, 3, 2)
|
||||
|
||||
def test_all_counts_preserve_materials_without_overlap_or_cropping(self):
|
||||
for invoices in range(6):
|
||||
for payments in [0, 1, 2, 3, 5, 8, 9, 15]:
|
||||
if not invoices and not payments:
|
||||
continue
|
||||
with self.subTest(invoices=invoices, payments=payments):
|
||||
deck = self.export(invoices, payments)
|
||||
self.assert_geometry_and_materials(deck, invoices, payments)
|
||||
if (invoices, payments) != (1, 1):
|
||||
self.assertEqual(len(self.pictures(deck.slides[-1])), 0)
|
||||
self.assertEqual(deck.slides[-1].shapes[0].name, 'manual-photo-area')
|
||||
|
||||
def test_groups_are_not_mixed_and_no_photos_are_automatically_added(self):
|
||||
destination = self.root / 'two-groups.pptx'
|
||||
state = dict(matches=[self.match(1, 1), self.match(1, 1)],
|
||||
photos=[fixture(self.root, 'photo', 0)])
|
||||
export_ppt(state, destination, True)
|
||||
deck = Presentation(destination)
|
||||
self.assertEqual(len(deck.slides), 2)
|
||||
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [2, 2])
|
||||
|
||||
def test_portrait_and_extreme_aspect_ratio_sources_stay_in_bounds(self):
|
||||
self.invoices[0] = fixture(self.root, 'invoice', 0, (700, 1400))
|
||||
self.payments[0] = fixture(self.root, 'payment', 0, (300, 2500))
|
||||
self.assert_geometry_and_materials(self.export(1, 1), 1, 1)
|
||||
|
||||
def test_empty_group_fails_instead_of_silently_omitting_it(self):
|
||||
with self.assertRaisesRegex(ValueError, '没有发票'):
|
||||
self.export(0, 0)
|
||||
|
||||
def test_portrait_engine_process_protocol(self):
|
||||
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||||
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||||
destination = self.root / 'process.pptx'
|
||||
request = dict(operation='ppt', state=dict(matches=[self.match(1, 8), self.match(3, 2)]),
|
||||
destination=str(destination))
|
||||
process = subprocess.run(command, input=json.dumps(request) + '\n', capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(process.returncode, 0, process.stdout + process.stderr)
|
||||
result = json.loads(process.stdout.splitlines()[-1])['result']
|
||||
self.assertEqual(result['pptLayout'], 'portrait-receipts-v1')
|
||||
deck = Presentation(destination)
|
||||
self.assertEqual(result['pptSlideCount'], 6)
|
||||
self.assertEqual((deck.slide_width, deck.slide_height), (Pt(720), Pt(960)))
|
||||
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [3, 6, 0, 2, 3, 0])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user