"""Vorlagen, GUI-Zustände und optional die vollständige echte Rechnung prüfen.""" import calendar from datetime import date import importlib.util import logging import os from pathlib import Path import tempfile import time import unittest from unittest.mock import patch import yaml from jr_einvoice.calc import compute_totals from jr_einvoice.create import create_invoice, save_customer from jr_einvoice.models import Invoice # Beim Rollback ältere Laufzeiten mit ihrer tatsächlich verfügbaren Funktion prüfen. HAS_GUI_DATA = importlib.util.find_spec('jr_einvoice.gui_data') is not None if HAS_GUI_DATA: from jr_einvoice.gui_data import InvoiceForm, InvoiceTemplate, load_template, save_template ROOT = Path(__file__).resolve().parent.parent SAMPLE = Invoice.model_validate(yaml.safe_load((ROOT / 'examples/invoice.sample.yaml').read_text())) HAS_QT = importlib.util.find_spec('PySide6') is not None @unittest.skipUnless(HAS_GUI_DATA, 'Diese Laufzeit enthält noch keinen Desktop-Assistenten') class TemplateTest(unittest.TestCase): def test_roundtrip_excludes_old_number_dates_and_bank_details(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) template = load_template(ROOT / 'examples/invoice.sample.yaml') path = save_template(root / 'templates', 'IT-Beratung', template) self.assertEqual(load_template(path), template) data = yaml.safe_load(path.read_text()) for excluded in ('meta', 'number', 'issued', 'start', 'end', 'seller', 'payment'): self.assertNotIn(excluded, data) self.assertEqual(path.stat().st_mode & 0o777, 0o600) self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700) with self.assertRaises(FileExistsError): save_template(path.parent, 'IT-Beratung', template) save_template(path.parent, 'IT-Beratung', template, overwrite=True) for name in ('../escape', '/tmp/escape', '', 'a/b', '..', 'trailing.'): with self.assertRaises(ValueError): save_template(path.parent, name, template) def test_invalid_template_version_and_position_rejected(self): template = load_template(ROOT / 'examples/invoice.sample.yaml') for changes in ({'schema_version': 2}, {'items': []}, {'old_number': '2020-1234'}): with self.assertRaises(ValueError): InvoiceTemplate.model_validate(template.model_dump() | changes) def test_multiple_items_custom_period_and_notes_use_shared_core(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) presets = root / 'presets' presets.mkdir() (presets / 'seller.default.yaml').write_text(yaml.safe_dump({ 'seller': SAMPLE.seller.model_dump(mode='json'), 'payment': SAMPLE.payment.model_dump(mode='json')})) save_customer(presets, SAMPLE.buyer.name, SAMPLE.buyer.model_dump(mode='json')) items = SAMPLE.items form = InvoiceForm(buyer=SAMPLE.buyer, items=items, start=date(2026, 9, 10), end=date(2026, 9, 20), notes='Individuelle Hinweise', issued=date(2026, 10, 19)) invoice = form.invoice(presets) self.assertEqual(compute_totals(invoice).gross_total, compute_totals(SAMPLE).gross_total) jar = root / 'mustang.jar' jar.touch() # Abbruch genau am Übergang zur PDF-Erzeugung: gespeicherte Daten prüfen. with patch('jr_einvoice.create.build_in_folder', side_effect=RuntimeError('Testabbruch')): with self.assertRaises(RuntimeError): create_invoice(root=root / 'invoices', resources=ROOT, presets=presets, customer=SAMPLE.buyer.name, entry='Oktober 2026', volume=1, price=1, items=items, notes=form.notes, jar=jar, logger=logging.getLogger('test'), service_period_start=form.start, service_period_end=form.end) saved = Invoice.model_validate(yaml.safe_load(next((root / 'invoices').glob('*/invoice.yaml')).read_text())) self.assertEqual(saved.items, items) self.assertEqual(saved.meta.service_period_start, form.start) self.assertEqual(saved.meta.service_period_end, form.end) self.assertEqual(saved.meta.notes, form.notes) @unittest.skipUnless(HAS_QT and HAS_GUI_DATA, 'Diese Laufzeit enthält keine installierte Desktop-Oberfläche') class GuiTest(unittest.TestCase): @classmethod def setUpClass(cls): os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') from PySide6.QtWidgets import QApplication cls.app = QApplication.instance() or QApplication([]) cls.app.setStyle('Fusion') def setUp(self): from jr_einvoice.gui import InvoiceWindow self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.home = Path(self.tmp.name) self.env = patch.dict(os.environ, {'JR_EINVOICE_HOME': str(self.home)}) self.env.start() self.addCleanup(self.env.stop) presets = self.home / 'presets' presets.mkdir() (presets / 'seller.default.yaml').write_text(yaml.safe_dump({ 'seller': SAMPLE.seller.model_dump(mode='json'), 'payment': SAMPLE.payment.model_dump(mode='json')})) self.jar = Path(os.environ.get('JR_EINVOICE_MUSTANG_JAR', ROOT / 'tools/mustang/Mustang-CLI.jar')) self.window = InvoiceWindow(root=self.home / 'invoices', presets=presets, jar=self.jar) self.addCleanup(self.cleanup_window) self.window.confirm_replace = lambda: True self.errors = [] self.window.error = self.errors.append def cleanup_window(self): self.wait_worker() self.window.dirty = False self.window.close() self.window.deleteLater() self.app.processEvents() def wait_worker(self): from PySide6.QtTest import QTest deadline = time.monotonic() + 150 while self.window.worker is not None and time.monotonic() < deadline: self.app.processEvents() QTest.qWait(20) self.assertIsNone(self.window.worker, 'Hintergrundauftrag nicht rechtzeitig abgeschlossen') self.app.processEvents() def load_sample(self): self.window.apply_template(ROOT / 'examples/invoice.sample.yaml') self.assertFalse(self.errors) def test_load_earlier_invoice_resets_dates_but_preserves_details(self): self.load_sample() form = self.window.form() self.assertEqual(form.issued, date.today()) self.assertEqual(form.start, date.today().replace(day=1)) self.assertEqual(form.end.day, calendar.monthrange(date.today().year, date.today().month)[1]) self.assertIsNone(form.number) self.assertEqual(form.buyer, SAMPLE.buyer) self.assertEqual(form.items, SAMPLE.items) self.window.positions[0].quantity.setText('7,5') self.window.positions[0].price.setText('1.234,56 €') self.assertEqual(str(self.window.form().items[0].unit_price_net), '1234.56') self.assertIn('Brutto', self.window.totals.text()) def test_invalid_fields_do_not_save_customer_or_reserve_number(self): self.load_sample() self.window.positions[0].quantity.setText('-1') self.window.generate() self.assertTrue(self.errors) self.assertIsNone(self.window.worker) self.assertFalse(self.window.root.exists()) self.assertEqual(list((self.window.presets / 'customers').glob('*.yaml')), []) def test_existing_customer_cannot_be_silently_overwritten(self): save_customer(self.window.presets, SAMPLE.buyer.name, SAMPLE.buyer.model_dump(mode='json') | {'email': 'andere@example.invalid'}) self.load_sample() self.window.generate() self.assertIn('anderen Stammdaten', self.errors[0]) self.assertIsNone(self.window.worker) self.assertFalse(self.window.root.exists()) def test_preview_creates_no_customer_or_invoice_number(self): self.load_sample() self.window.preview() self.wait_worker() self.assertFalse(self.errors) self.assertGreater(self.window.document.pageCount(), 0) self.assertIn('ENTWURF', self.window.preview_state.text()) self.assertFalse(self.window.root.exists()) self.assertEqual(list((self.window.presets / 'customers').glob('*.yaml')), []) self.assertFalse(self.window.open_pdf.isEnabled()) def test_background_failure_restores_editing_without_success(self): self.load_sample() self.window.jar = self.home / 'kaputte-mustang.jar' self.window.jar.write_text('Keine gültige JAR') self.window.generate() self.wait_worker() self.assertEqual(len(self.errors), 1) self.assertIn('Erstellung fehlgeschlagen', self.errors[0]) self.assertTrue(self.window.editor.isEnabled()) self.assertTrue(self.window.generate_button.isEnabled()) self.assertFalse(self.window.open_pdf.isEnabled()) self.assertIsNone(self.window.result) def test_worker_start_failure_restores_form(self): self.load_sample() with patch('jr_einvoice.gui.sys.executable', '/nonexistent-jr-einvoice-python'): self.window.preview() self.wait_worker() self.assertEqual(len(self.errors), 1) self.assertIn('Hintergrundprozess fehlgeschlagen', self.errors[0]) self.assertTrue(self.window.editor.isEnabled()) self.assertTrue(self.window.preview_button.isEnabled()) @unittest.skipUnless(os.environ.get('JR_EINVOICE_INTEGRATION') == '1', 'Echte Mustang-Prüfung nur nach Opt-in') def test_real_click_to_validated_pdf_preview_and_new_invoice(self): self.load_sample() self.window.generate_button.click() self.assertFalse(self.window.editor.isEnabled()) self.wait_worker() self.assertFalse(self.errors) result = self.window.result self.assertIsNotNone(result) self.assertIn('status="valid"', result.build.validate_report.read_text()) self.assertGreater(self.window.document.pageCount(), 0) self.assertEqual(result.attachment.read_bytes(), result.build.facturx_pdf.read_bytes()) self.assertFalse(self.window.generate_button.isEnabled()) self.assertFalse(self.window.preview_button.isEnabled()) self.assertTrue(self.window.open_pdf.isEnabled()) # Doppelaufruf desselben Formulars legt keine zusätzliche Nummer an. self.window.generate() self.assertIsNone(self.window.worker) self.assertEqual(len(list(self.window.root.glob('*/invoice.yaml'))), 1) self.window.notes.setPlainText('Geänderte Hinweise') self.assertIn('vorherigen Stand', self.window.preview_state.text()) self.assertFalse(self.window.open_pdf.isEnabled()) self.window.new_invoice() self.assertIsNone(self.window.result) self.assertEqual(len(self.window.positions), 1) self.assertEqual(self.window.document.pageCount(), 0) self.assertFalse(self.window.dirty)