food-market/tests/test_dialogs.py

234 lines
7.8 KiB
Python
Raw Normal View History

Починен ввод цены и выделение в таблицах Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы из вводимого текста, чтобы принимались разделители разрядов, — но у поля цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс, Qt отвергало символ, и в поле оставалась только первая цифра: набираешь «25,50», получаешь 2. Теперь текст на каждом нажатии не трогается вообще: сначала пробуем как есть, и только если Qt отверг именно из-за точки — повторяем с запятой. Второе: форма продажи подключала сигнал редактора строк раньше, чем создавала поле оплаты. Qt глотает исключения в слотах, поэтому вместо падения оплата просто молча не подставлялась. Третье: выделение в таблицах держалось за номер строки. Таблицы отсортированы, и после перерисовки под тем же номером оказывался другой объект — можно было удалить не тот товар. Теперь выделение восстанавливается по идентификатору. Тесты: настоящие нажатия клавиш через QTest вместо проверок validate(), которые этот баг пропустили, и перехват sys.excepthook, чтобы проглоченные Qt исключения считались провалом. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:13:45 +07:00
"""Диалоги должны собираться без единого проглоченного исключения.
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()
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: 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 21:50:42 +07:00
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()
Починен ввод цены и выделение в таблицах Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы из вводимого текста, чтобы принимались разделители разрядов, — но у поля цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс, Qt отвергало символ, и в поле оставалась только первая цифра: набираешь «25,50», получаешь 2. Теперь текст на каждом нажатии не трогается вообще: сначала пробуем как есть, и только если Qt отверг именно из-за точки — повторяем с запятой. Второе: форма продажи подключала сигнал редактора строк раньше, чем создавала поле оплаты. Qt глотает исключения в слотах, поэтому вместо падения оплата просто молча не подставлялась. Третье: выделение в таблицах держалось за номер строки. Таблицы отсортированы, и после перерисовки под тем же номером оказывался другой объект — можно было удалить не тот товар. Теперь выделение восстанавливается по идентификатору. Тесты: настоящие нажатия клавиш через QTest вместо проверок validate(), которые этот баг пропустили, и перехват sys.excepthook, чтобы проглоченные Qt исключения считались провалом. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:13:45 +07:00
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()