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