Files
reimburse/native-engine/tests/test_ppt_portrait.py
T
2026-09-16 21:02:44 +08:00

170 lines
8.7 KiB
Python

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()