food-market/tests/test_dialogs.py
Claude 26b401e507 Цена по фасовке не подставлялась после удаления строки
Обработчики строк запоминали номер строки в момент создания. После
удаления любой строки всё, что было ниже, съезжает вверх, и запомненный
номер начинает указывать на соседа или за пределы таблицы. Строка молча
переставала работать целиком: выбираешь фасовку — цена не меняется,
меняешь товар — не пересобирается список фасовок. Приходилось править
цену руками.

Теперь обработчики привязаны к самому виджету, а строка ищется по нему
в момент вызова. То же исправлено в быстром вводе, где строк больше
и удаляют их чаще.

Заодно найдена мина, которую посадил я сам в разделе статистики:
self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(),
который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным
«object is not callable». Такая же история была с self.layout в сводке.
Оба переименованы, добавлен тест, который обходит все виджеты и проверяет,
что ни один атрибут не затеняет метод Qt.

И подсказка в карточке товара: себестоимость фасовки нигде не вводится,
она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо
написано, какая сумма подставится в закупку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:50:42 +03:00

234 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Диалоги должны собираться без единого проглоченного исключения.
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()