"""Диалоги должны собираться без единого проглоченного исключения. Qt не роняет приложение, когда слот падает: он печатает traceback и живёт дальше. Внешне это выглядит как «работает, но криво» — например, у формы продажи однажды не подставлялась оплата, потому что сигнал редактора строк приходил раньше, чем создавалось поле оплаты. Поэтому здесь перехватывается sys.excepthook, а не просто проверяется, что код не бросил исключение наружу. """ import os import sys from datetime import date, timedelta import pytest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication # noqa: E402 from app import journal, ledger # noqa: E402 from app.models import KIND_RETAIL, KIND_SELF # noqa: E402 from app.ui import theme # noqa: E402 @pytest.fixture(scope="session") def qapp(): app = QApplication.instance() or QApplication([]) theme.apply(app) return app @pytest.fixture def silent(): """Собрать всё, что Qt проглотил бы, напечатав в stderr.""" caught = [] def hook(exc_type, exc, tb): caught.append(f"{exc_type.__name__}: {exc}") previous = sys.excepthook sys.excepthook = hook try: yield caught finally: sys.excepthook = previous class Ctx: def __init__(self, vault): self.vault = vault self.report = ledger.build(vault.doc) @property def currency(self): return self.vault.doc.settings.currency def changed(self): self.report = ledger.build(self.vault.doc) def sync_now(self, quiet=False): pass def sync_status_text(self): return "" def restart_sync_timer(self): pass conflict_message = None @pytest.fixture def ctx(qapp, vault, buns): today = date.today() journal.create_batch( vault, today - timedelta(days=3), today + timedelta(days=11), [{"product_id": buns["мак"].id, "qty": 20, "unit_cost": 20}], ) vasya = journal.create_counterparty(vault, "Вася") journal.create_sale( vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}], counterparty_id=vasya.id, paid_amount=0, ) journal.create_sale( vault, today, KIND_SELF, [{"product_id": buns["повидло"].id, "qty": 1, "unit_price": 0}] ) return Ctx(vault) def test_new_sale_dialog_is_quiet(ctx, silent): from app.ui.page_sales import SaleDialog dialog = SaleDialog(ctx) assert silent == [] # Оплата должна подставиться сразу — ради этого сигнал и существует. assert dialog.paid.value_decimal() == dialog.lines.total() assert dialog.paid_hint.text() dialog.deleteLater() def test_existing_sale_dialog_is_quiet(ctx, silent): from app.ui.page_sales import SaleDialog sale = ctx.vault.doc.sales[0] dialog = SaleDialog(ctx, sale) assert silent == [] # У существующей продажи платежи правятся на экране долгов. assert dialog.paid is None dialog.deleteLater() def test_switching_kind_is_quiet(ctx, silent): from app.ui.page_sales import SaleDialog from app.models import SALE_KINDS dialog = SaleDialog(ctx) for index in range(len(SALE_KINDS)): dialog.kind.setCurrentIndex(index) assert silent == [] dialog.deleteLater() def test_batch_dialogs_are_quiet(ctx, silent): from app.ui.page_batches import BatchDialog, PaymentDialog new = BatchDialog(ctx) new.date.set_date(date.today() - timedelta(days=7)) # двигает дедлайн existing = BatchDialog(ctx, ctx.vault.doc.batches[0]) payment = PaymentDialog(ctx, ctx.vault.doc.batches[0], 100) assert silent == [] for d in (new, existing, payment): d.deleteLater() def test_product_dialogs_are_quiet(ctx, silent): from app.ui.page_products import PriceDialog, ProductDialog product = ctx.vault.doc.products[0] creating = ProductDialog("₽") creating.unit.setText("л") # пересобирает заголовок фасовок creating.packs.add_pack("бутылка", "1.5", 170) editing = ProductDialog("₽", product) prices = PriceDialog("₽", product) assert silent == [] for d in (creating, editing, prices): d.deleteLater() def test_quick_sales_dialog_is_quiet(ctx, silent): from app.ui.quick_sales import QuickSalesDialog dialog = QuickSalesDialog(ctx) dialog.add_row() dialog.table.setCurrentCell(0, 0) dialog.duplicate_row() dialog.period_from.set_date(date.today() - timedelta(days=60)) dialog.remove_row() assert silent == [] dialog.deleteLater() def test_payment_dialog_is_quiet(ctx, silent): from app.ui.page_debts import PaymentDialog dialog = PaymentDialog(ctx.report.summary.receivable, "₽") assert silent == [] dialog.deleteLater() def test_no_attribute_shadows_a_qt_method(): """Атрибут с именем метода Qt — мина замедленного действия. `self.metric = QComboBox()` на QWidget затеняет QWidget.metric(), который Qt зовёт при смене стиля и в некоторых путях отрисовки. Падает это не там, где написано, и с невнятным «object is not callable». """ import ast import pathlib from PySide6.QtWidgets import QDialog, QWidget reserved = {n for n in dir(QWidget) if not n.startswith("_")} reserved |= {n for n in dir(QDialog) if not n.startswith("_")} offenders = [] for path in sorted(pathlib.Path("app/ui").rglob("*.py")): tree = ast.parse(path.read_text(encoding="utf-8")) for node in ast.walk(tree): if not isinstance(node, ast.Assign): continue for target in node.targets: if ( isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == "self" and target.attr in reserved ): offenders.append(f"{path}:{node.lineno} self.{target.attr}") assert offenders == [], "Атрибуты затеняют методы Qt: " + "; ".join(offenders) def test_switching_style_does_not_break_pages(ctx, silent, qapp): """Смена стиля перебирает все виджеты и ловит затенённые методы Qt.""" from app.ui.page_dashboard import DashboardPage from app.ui.page_stats import StatsPage pages = [DashboardPage(ctx), StatsPage(ctx)] for page in pages: page.refresh() qapp.setStyle("Fusion") assert silent == [] for page in pages: page.deleteLater() def test_every_page_refreshes_quietly(ctx, silent): from app.ui.page_batches import BatchesPage from app.ui.page_dashboard import DashboardPage from app.ui.page_debts import DebtsPage from app.ui.page_journal import JournalPage from app.ui.page_products import ProductsPage from app.ui.page_sales import SalesPage for cls in (DashboardPage, BatchesPage, SalesPage, DebtsPage, ProductsPage, JournalPage): page = cls(ctx) page.refresh() page.refresh() # повторная перерисовка — там живёт восстановление выделения assert silent == [], f"{cls.__name__}: {silent}" page.deleteLater()