1
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class EngineBuildTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
engine = Path(__file__).resolve().parents[1]
|
||||
for name in ['engine-fingerprint.sh', 'ensure-engine.sh']:
|
||||
shutil.copyfile(engine / name, self.root / name)
|
||||
for name in ['engine.py', 'exports.py', 'requirements.txt', 'requirements.lock.txt',
|
||||
'personal-expense-template.xlsx', 'helper.entitlements']:
|
||||
(self.root / name).write_text('fixture\n')
|
||||
self.binary = self.root / 'dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper'
|
||||
(self.root / 'build-engine.sh').write_text('''#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
echo build >> builds.log
|
||||
mkdir -p dist/receipt-engine-helper.app/Contents/MacOS
|
||||
printf 'fixture engine\\n' > dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper
|
||||
chmod +x dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper
|
||||
SOURCE_HASH="$(bash engine-fingerprint.sh)"
|
||||
BINARY_HASH="$(shasum -a 256 dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper | cut -d ' ' -f 1)"
|
||||
printf '%s\\n%s\\n' "$SOURCE_HASH" "$BINARY_HASH" > dist/receipt-engine-helper.source.sha256
|
||||
''')
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def ensure(self):
|
||||
return subprocess.run(['bash', str(self.root / 'ensure-engine.sh')],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
|
||||
def build_count(self):
|
||||
return len((self.root / 'builds.log').read_text().splitlines())
|
||||
|
||||
def test_missing_engine_builds_and_unchanged_engine_is_reused(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 1)
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 1)
|
||||
|
||||
def test_changed_sources_rebuild_even_when_timestamp_is_unchanged(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
source = self.root / 'exports.py'
|
||||
original = source.stat()
|
||||
source.write_text('new grouping implementation\n')
|
||||
os.utime(source, ns=(original.st_atime_ns, original.st_mtime_ns))
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 2)
|
||||
|
||||
def test_replaced_binary_rebuilds(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.binary.write_text('old engine\n')
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 2)
|
||||
|
||||
def test_missing_stamp_rebuilds(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
(self.root / 'dist/receipt-engine-helper.source.sha256').unlink()
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 2)
|
||||
|
||||
def test_failed_rebuild_does_not_accept_old_engine(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
(self.root / 'build-engine.sh').write_text('exit 17\n')
|
||||
self.assertEqual(self.ensure().returncode, 17)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,4 +1,8 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
@@ -68,6 +72,8 @@ class ExportTests(unittest.TestCase):
|
||||
|
||||
def test_expense_pagination(self):
|
||||
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)]
|
||||
for index, match in enumerate(self.state['matches']):
|
||||
match['category'] = f'类别{index}'
|
||||
destination = self.root / 'expense.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [])
|
||||
workbook = load_workbook(destination)
|
||||
@@ -76,6 +82,137 @@ class ExportTests(unittest.TestCase):
|
||||
self.assertEqual(workbook.worksheets[1]['H10'].value, '')
|
||||
self.assertEqual(workbook.worksheets[1]['A30'].value, '部门长:')
|
||||
|
||||
def test_expense_payee_is_text_on_every_page(self):
|
||||
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)]
|
||||
for index, match in enumerate(self.state['matches']):
|
||||
match['category'] = f'类别{index}'
|
||||
destination = self.root / 'payee.xlsx'
|
||||
profile = dict(recipient='测试收款人', bankName='=测试银行支行',
|
||||
accountNumber='0012345678901234567890', preparer='测试制单人')
|
||||
export_expense(self.state, destination, self.template, {}, [], profile)
|
||||
for sheet in load_workbook(destination).worksheets:
|
||||
for reference, value in [('C23', profile['recipient']), ('C24', profile['bankName']),
|
||||
('C25', profile['accountNumber']), ('H6', profile['preparer'])]:
|
||||
self.assertEqual(sheet[reference].value, value)
|
||||
self.assertEqual(sheet[reference].data_type, 's')
|
||||
self.assertEqual(sheet['C25'].style_id, load_workbook(self.template)['1个人报销单']['C25'].style_id)
|
||||
|
||||
def test_expense_without_profile_clears_template_identity(self):
|
||||
destination = self.root / 'empty-payee.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [])
|
||||
sheet = load_workbook(destination).active
|
||||
for reference in ['C23', 'C24', 'C25', 'H6']:
|
||||
self.assertEqual(sheet[reference].value, '')
|
||||
|
||||
def test_expense_preparer_defaults_to_recipient(self):
|
||||
destination = self.root / 'default-preparer.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [], {'recipient': '测试收款人'})
|
||||
self.assertEqual(load_workbook(destination).active['H6'].value, '测试收款人')
|
||||
|
||||
def test_expense_profile_through_engine_process(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-expense.xlsx'
|
||||
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(6)]
|
||||
for match in self.state['matches']:
|
||||
match['category'] = '交通'
|
||||
request = dict(operation='expense', state=self.state, destination=str(destination),
|
||||
categoryPurposes={'交通': '交通'},
|
||||
payee=dict(recipient='测试收款人', bankName='测试支行', accountNumber='0001234567890123456789'))
|
||||
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)
|
||||
events = [json.loads(line) for line in process.stdout.splitlines()]
|
||||
self.assertEqual(events[-1]['event'], 'result')
|
||||
sheet = load_workbook(destination).active
|
||||
self.assertEqual(sheet['C23'].value, '测试收款人')
|
||||
self.assertEqual(sheet['C24'].value, '测试支行')
|
||||
self.assertEqual(sheet['C25'].value, '0001234567890123456789')
|
||||
self.assertEqual(sheet['C25'].data_type, 's')
|
||||
self.assertEqual(sheet['H6'].value, '测试收款人')
|
||||
self.assertEqual(sheet['C9'].value, '交通')
|
||||
self.assertEqual(sheet['G9'].value, 6)
|
||||
self.assertEqual(sheet['H9'].value, 600)
|
||||
self.assertEqual(sheet['H10'].value, '')
|
||||
|
||||
def test_expense_engine_process_groups_selected_traffic_and_office(self):
|
||||
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||||
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||||
amounts = [['1058.00', '1058.00'], ['140.00'], ['300.00'], ['163.92'], ['253.45'], ['74.49'], ['444.00']]
|
||||
selected = []
|
||||
for index, invoice_amounts in enumerate(amounts):
|
||||
match = copy.deepcopy(self.state['matches'][0])
|
||||
match['id'] = f'selected-{index}'
|
||||
match['category'] = '办公用品' if index == 6 else '交通'
|
||||
match['invoices'] = []
|
||||
for invoice_index, amount in enumerate(invoice_amounts):
|
||||
invoice = copy.deepcopy(self.state['matches'][0]['invoices'][0])
|
||||
invoice['id'] = f'invoice-{index}-{invoice_index}'
|
||||
invoice['ocr']['amounts'] = [amount]
|
||||
match['invoices'].append(invoice)
|
||||
selected.append(match)
|
||||
self.state['matches'] = selected
|
||||
destination = self.root / 'two-categories.xlsx'
|
||||
request = dict(operation='expense', state=self.state, destination=str(destination),
|
||||
categoryPurposes={'交通': '交通', '办公用品': '办公用品'})
|
||||
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)
|
||||
events = [json.loads(line) for line in process.stdout.splitlines()]
|
||||
self.assertEqual(events[-1]['result']['expenseGrouping'], 'category-v1')
|
||||
self.assertEqual(events[-1]['result']['expenseRowCount'], 2)
|
||||
workbook = load_workbook(destination)
|
||||
self.assertEqual(workbook.sheetnames, ['个人报销单'])
|
||||
sheet = workbook.active
|
||||
rows = [(sheet[f'C{row}'].value, sheet[f'G{row}'].value, sheet[f'H{row}'].value)
|
||||
for row in range(9, 22) if sheet[f'H{row}'].value not in ('', None)]
|
||||
self.assertEqual(rows, [('交通', 7, 3047.86), ('办公用品', 1, 444)])
|
||||
self.assertEqual(round(sum(row[2] for row in rows), 2), 3491.86)
|
||||
self.assertEqual(sheet['F27'].value, '=SUM(H9:H21)')
|
||||
|
||||
def test_expense_same_category_collapses_before_pagination(self):
|
||||
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)]
|
||||
for match in self.state['matches']:
|
||||
match['category'] = '交通'
|
||||
destination = self.root / 'collapsed.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [])
|
||||
workbook = load_workbook(destination)
|
||||
self.assertEqual(workbook.sheetnames, ['个人报销单'])
|
||||
sheet = workbook.active
|
||||
self.assertEqual(sheet['C9'].value, '交通')
|
||||
self.assertEqual(sheet['G9'].value, 14)
|
||||
self.assertEqual(sheet['H9'].value, 1400)
|
||||
self.assertEqual(sheet['H10'].value, '')
|
||||
|
||||
def test_expense_category_totals_counts_and_mixed_invoice_types(self):
|
||||
traffic = [copy.deepcopy(self.state['matches'][0]) for _ in range(6)]
|
||||
for match in traffic:
|
||||
match['category'] = '交通'
|
||||
match['invoices'][0]['ocr']['amounts'] = ['100.10', '2']
|
||||
traffic[0]['invoices'][0]['ocr']['rawText'] = '专用发票'
|
||||
hotel = copy.deepcopy(traffic[0])
|
||||
hotel['category'] = '住宿'
|
||||
hotel['invoices'] *= 2
|
||||
hotel['invoices'][0]['ocr']['amounts'] = ['-1,200.30', '2']
|
||||
self.state['matches'] = [traffic[0], hotel] + traffic[1:]
|
||||
original = copy.deepcopy(self.state)
|
||||
destination = self.root / 'grouped.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [], category_purposes={'交通': '=交通用途'})
|
||||
sheet = load_workbook(destination).active
|
||||
self.assertEqual(sheet['C9'].value, '=交通用途')
|
||||
self.assertEqual(sheet['C9'].data_type, 's')
|
||||
self.assertEqual(sheet['B9'].value, '专票/普票')
|
||||
self.assertEqual(sheet['G9'].value, 6)
|
||||
self.assertEqual(sheet['H9'].value, 600.6)
|
||||
self.assertEqual(sheet['C10'].value, '住宿')
|
||||
self.assertEqual(sheet['G10'].value, 2)
|
||||
self.assertEqual(sheet['H10'].value, 2400.6)
|
||||
self.assertEqual(sheet['H11'].value, '')
|
||||
self.assertEqual(self.state, original)
|
||||
|
||||
def test_expense_empty_selection_is_rejected(self):
|
||||
self.state['matches'] = []
|
||||
with self.assertRaisesRegex(ValueError, '勾选'):
|
||||
export_expense(self.state, self.root / 'empty.xlsx', self.template, {}, [])
|
||||
|
||||
def test_travel_only_verified_invoices(self):
|
||||
travel = self.state['invoices'][0]['ocr']['travel']
|
||||
travel.update(type='train', travelerName='张三', departure='北京南', destination='上海虹桥', departureTime='2026-06-24 15:00', transportNumber='G21')
|
||||
|
||||
Reference in New Issue
Block a user