175 lines
10 KiB
Python
175 lines
10 KiB
Python
import datetime
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import unittest
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
from openpyxl import load_workbook
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
import test_expense_template as fixtures
|
||
from expense_preview import PreviewCalculator, UnsupportedFormula, builtin_rmb, prepare_preview
|
||
from expense_template import Template, encoded, export_custom_expense, set_cell, tag
|
||
|
||
|
||
class ExpensePreviewTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fixture = fixtures.ExpenseTemplateTests()
|
||
self.fixture.setUp()
|
||
self.addCleanup(self.fixture.tearDown)
|
||
|
||
def test_filled_workbook_previews_current_totals_and_preserves_formulas(self):
|
||
output, _ = self.fixture.export()
|
||
result = prepare_preview(output)
|
||
values = load_workbook(output, data_only=True)
|
||
formulas = load_workbook(output)
|
||
sheet = values.active
|
||
self.assertEqual(sheet['C9'].value, '交通')
|
||
self.assertEqual(sheet['H9'].value, 100.3)
|
||
self.assertEqual(sheet['H10'].value, 444)
|
||
self.assertEqual(sheet['F27'].value, 544.3)
|
||
self.assertEqual(sheet['H29'].value, 544.3)
|
||
self.assertEqual(formulas.active['F27'].value, '=SUM(H9:H21)')
|
||
self.assertEqual(formulas.active['H29'].value, '=IF(F27>C27,F27-C27,0)')
|
||
self.assertEqual(result['previewUncalculatedCells'], [])
|
||
self.assertEqual(sheet['D29'].value, '伍佰肆拾肆元叁角整')
|
||
self.assertEqual(result['expensePreview'], 'xlsx-layout-v4')
|
||
values.close()
|
||
formulas.close()
|
||
|
||
def test_original_preview_preserves_signature_borders_fonts_and_geometry(self):
|
||
signatures = ['部门长', '剧组出纳', '制片主任', '剧组会计', '执行制片人']
|
||
payee = dict(recipient='测试收款人', bankName='测试银行', accountNumber='001234567890')
|
||
source = self.fixture.root / 'signature-template.xlsx'
|
||
output = self.fixture.root / 'signature-preview.xlsx'
|
||
export_custom_expense(self.fixture.state, source, self.fixture.template, self.fixture.mapping,
|
||
{}, signatures, payee, {})
|
||
prepare_preview(source)
|
||
changed = dict(matches=[self.fixture.match('preview-changed', '交通', '447')])
|
||
export_custom_expense(changed, output, source, self.fixture.mapping,
|
||
{}, signatures, payee, {})
|
||
result = prepare_preview(output)
|
||
original, preview = Template(source), Template(output)
|
||
self.assertEqual(result['previewLayout']['version'], 4)
|
||
self.assertEqual(original.parts['xl/styles.xml'], preview.parts['xl/styles.xml'])
|
||
_, _, before = original.sheet('个人报销单')
|
||
_, _, after = preview.sheet('个人报销单')
|
||
for name in ['cols', 'mergeCells', 'sheetFormatPr', 'pageMargins', 'pageSetup']:
|
||
self.assertEqual(encoded(before.find(tag(name))), encoded(after.find(tag(name))), name)
|
||
for reference, label in zip(['A30', 'D30', 'A31', 'D31', 'A32'], signatures):
|
||
old = before.find('.//' + tag('c') + f'[@r="{reference}"]')
|
||
new = after.find('.//' + tag('c') + f'[@r="{reference}"]')
|
||
self.assertEqual(encoded(old), encoded(new), reference)
|
||
self.assertEqual(''.join(node.text or '' for node in new.iter(tag('t'))), label + ':')
|
||
old_cells = {cell.get('r'): cell.get('s') for cell in before.iter(tag('c'))}
|
||
new_cells = {cell.get('r'): cell.get('s') for cell in after.iter(tag('c'))}
|
||
self.assertEqual(old_cells, new_cells)
|
||
for row in [30, 31, 32]:
|
||
old = before.find(tag('sheetData') + '/' + tag('row') + f'[@r="{row}"]')
|
||
new = after.find(tag('sheetData') + '/' + tag('row') + f'[@r="{row}"]')
|
||
self.assertEqual(old.attrib, new.attrib)
|
||
folder = os.environ.get('RECEIPT_PREVIEW_FIXTURE_DIR')
|
||
if folder:
|
||
folder = Path(folder)
|
||
folder.mkdir(parents=True, exist_ok=True)
|
||
(folder / 'template.xlsx').write_bytes(source.read_bytes())
|
||
(folder / 'preview.xlsx').write_bytes(output.read_bytes())
|
||
|
||
def test_builtin_uppercase_amount_formula(self):
|
||
for amount, expected in [('3491.86', '叁仟肆佰玖拾壹元捌角陆分'), ('10001.01', '壹万零壹元零壹分'),
|
||
('100000001', '壹亿零壹元整'), ('100010000', '壹亿零壹万元整'),
|
||
('0.01', '壹分'), ('0.1', '壹角整'), ('-50.02', '负伍拾元零贰分'),
|
||
('0', ''), ('1.999', '贰元整'), ('999999999999.99', '玖仟玖佰玖拾玖亿玖仟玖佰玖拾玖万玖仟玖佰玖拾玖元玖角玖分')]:
|
||
with self.subTest(amount=amount):
|
||
self.assertEqual(builtin_rmb(Decimal(amount)), expected)
|
||
def test_caches_each_custom_page_not_whole_report(self):
|
||
source, mapping = self.fixture.alternate()
|
||
state = dict(matches=self.fixture.state['matches'] + [self.fixture.match('third', '住宿', '50')])
|
||
output, _ = self.fixture.export(mapping=mapping, template=source, state=state)
|
||
prepare_preview(output)
|
||
workbook = load_workbook(output, data_only=True)
|
||
self.assertEqual(workbook['组 B 报销']['F8'].value, 544.3)
|
||
self.assertEqual(workbook['组 B 报销-续2']['F8'].value, 50)
|
||
self.assertIn('保留说明', workbook.sheetnames)
|
||
workbook.close()
|
||
|
||
def test_clears_unknown_cache_and_does_not_execute_formula(self):
|
||
output, _ = self.fixture.export()
|
||
template = Template(output)
|
||
_, path, document = template.sheet('个人报销单')
|
||
set_cell(document, 'I35', '__import__("os").system("touch /tmp/should-not-run")', formula=True)
|
||
from lxml import etree as ET
|
||
ET.SubElement(document.find('.//' + tag('c') + '[@r="I35"]'), tag('v')).text = '9999'
|
||
template.parts[path] = encoded(document)
|
||
self.fixture.rewrite(output, template.parts)
|
||
result = prepare_preview(output)
|
||
self.assertIn('个人报销单!I35', result['previewUncalculatedCells'])
|
||
workbook = load_workbook(output, data_only=True)
|
||
self.assertIsNone(workbook.active['I35'].value)
|
||
workbook.close()
|
||
|
||
def test_calculator_cycles_text_dates_and_conditionals(self):
|
||
template = Template(self.fixture.template)
|
||
_, _, sheet = template.sheet('个人报销单')
|
||
set_cell(sheet, 'J1', 10)
|
||
set_cell(sheet, 'J2', '文字')
|
||
set_cell(sheet, 'J3', '-J1*2+5', formula=True)
|
||
set_cell(sheet, 'J4', 'J5', formula=True)
|
||
set_cell(sheet, 'J5', 'J4', formula=True)
|
||
calculator = PreviewCalculator(sheet)
|
||
self.assertEqual(calculator.cell('J3'), -15)
|
||
self.assertEqual(calculator.expression('SUM(J1:J3)'), -5)
|
||
self.assertEqual(calculator.expression('IF(J1>0,J1/2,1/0)'), 5)
|
||
self.assertEqual(calculator.expression('TODAY()'), (datetime.date.today() - datetime.date(1899, 12, 30)).days)
|
||
self.assertEqual(PreviewCalculator(sheet, True).expression('TODAY()'),
|
||
(datetime.date.today() - datetime.date(1904, 1, 1)).days)
|
||
with self.assertRaises(UnsupportedFormula):
|
||
calculator.cell('J4')
|
||
with self.assertRaises(UnsupportedFormula):
|
||
calculator.expression('2**10000000')
|
||
|
||
def test_engine_process_preview_builtin_and_imported_templates(self):
|
||
for custom in [False, True]:
|
||
with self.subTest(custom=custom):
|
||
output = self.fixture.root / ('custom-preview.xlsx' if custom else 'builtin-preview.xlsx')
|
||
request = dict(operation='expense', state=self.fixture.state, destination=str(output), preparePreview=True)
|
||
if custom:
|
||
request.update(templatePath=str(self.fixture.template), templateMapping=self.fixture.mapping)
|
||
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||
process = subprocess.run(command, input=json.dumps(request) + '\n', text=True, capture_output=True, timeout=60)
|
||
self.assertEqual(process.returncode, 0, process.stderr + process.stdout)
|
||
result = next(json.loads(line)['result'] for line in process.stdout.splitlines() if json.loads(line).get('event') == 'result')
|
||
self.assertEqual(result['expensePreview'], 'xlsx-layout-v4')
|
||
self.assertTrue(result['previewLayout']['sheets'])
|
||
self.assertNotIn('templatePreviewLayout', result)
|
||
self.assertEqual(result['expenseRowCount'], 2)
|
||
workbook = load_workbook(output, data_only=True)
|
||
self.assertEqual(workbook.active['F27'].value, 544.3)
|
||
self.assertEqual(workbook.active['H29'].value, 544.3)
|
||
self.assertIn(workbook.active['C23'].value, ('', None))
|
||
workbook.close()
|
||
|
||
def test_engine_process_template_preview_is_read_only_without_expense_selection(self):
|
||
from spreadsheet_layout import read_layout
|
||
alternate, _ = self.fixture.alternate()
|
||
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||
for source in [self.fixture.template, alternate]:
|
||
before = source.read_bytes()
|
||
request = dict(operation='preview-expense-template', templatePath=str(source))
|
||
process = subprocess.run(command, input=json.dumps(request) + '\n', text=True,
|
||
capture_output=True, timeout=60)
|
||
self.assertEqual(process.returncode, 0, process.stderr + process.stdout)
|
||
result = next(json.loads(line)['result'] for line in process.stdout.splitlines()
|
||
if json.loads(line).get('event') == 'result')
|
||
self.assertEqual(result, read_layout(source))
|
||
self.assertEqual(source.read_bytes(), before)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|