Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
7.8 KiB
Python
234 lines
7.8 KiB
Python
"""Диалоги должны собираться без единого проглоченного исключения.
|
||
|
||
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()
|