diff --git a/app/ui/page_sales.py b/app/ui/page_sales.py index 5a6baca..6c6024a 100644 --- a/app/ui/page_sales.py +++ b/app/ui/page_sales.py @@ -78,6 +78,13 @@ class SaleDialog(QDialog): form.addRow("Заметка", self.note) layout.addLayout(form) + # Поля оплаты создаются ниже, но редактор строк начинает слать сигналы + # сразу при заполнении. Без этих заглушек первый же сигнал приходит + # в обработчик, для которого self.paid ещё не существует, — Qt съедает + # исключение, и подстановка оплаты молча не работает. + self.paid = None + self.paid_hint = None + layout.addWidget(w.label("Что ушло", "h2")) products = [p for p in ctx.vault.doc.products if not p.archived] self.lines = w.LinesEditor( @@ -92,7 +99,6 @@ class SaleDialog(QDialog): # Оплату задаём только при создании: у существующей продажи платежи # живут своей жизнью и правятся на экране долгов. - self.paid = None if sale is None: pay_form = QFormLayout() self.paid = w.MoneySpin(ctx.currency) diff --git a/app/ui/widgets.py b/app/ui/widgets.py index 06a17f4..7f0d59d 100644 --- a/app/ui/widgets.py +++ b/app/ui/widgets.py @@ -10,7 +10,7 @@ from datetime import date from decimal import Decimal from PySide6.QtCore import QDate, Qt, Signal -from PySide6.QtGui import QColor, QFont +from PySide6.QtGui import QColor, QFont, QValidator from PySide6.QtWidgets import ( QComboBox, QCompleter, @@ -47,11 +47,17 @@ class DecimalSpin(QDoubleSpinBox): """ def validate(self, text: str, pos: int): - # Пробелы — это разделители разрядов; при вводе они только мешают. - fixed = text.replace(".", ",").replace(" ", "").replace(" ", "") - if fixed != text: - pos = min(pos, len(fixed)) - return super().validate(fixed, pos) + # Сначала пробуем как есть. Трогать текст на каждом нажатии нельзя: + # у поля цены суффикс « ₽» начинается с пробела, и любая «очистка» + # ломает его — тогда принимается только первый символ, а набрать + # цену становится невозможно. + state, fixed, new_pos = super().validate(text, pos) + if state != QValidator.Invalid or "." not in text: + return state, fixed, new_pos + + # Не приняли из-за точки — повторяем с запятой. + candidate = text.replace(".", ",") + return super().validate(candidate, min(pos, len(candidate))) def fixup(self, text: str) -> str: return super().fixup(text.replace(".", ",")) @@ -293,7 +299,15 @@ def sortable_num_item(text: str, value, color: str = "", bold: bool = False) -> def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None: - """Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки.""" + """Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки. + + Выделение восстанавливается по идентификатору, а не по номеру строки. + Таблицы отсортированы, и после перерисовки под тем же номером оказывается + уже другой объект: правишь цену одного товара, а «Удалить» нажимается + на соседнем. + """ + previously = selected_key(widget) + was_sorting = widget.isSortingEnabled() widget.setSortingEnabled(False) widget.setRowCount(len(rows)) @@ -304,6 +318,18 @@ def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = cells[0].setData(Qt.UserRole, keys[r]) widget.setSortingEnabled(was_sorting) + if keys is not None and previously is not None: + select_by_key(widget, previously) + + +def select_by_key(widget: QTableWidget, key) -> bool: + for r in range(widget.rowCount()): + item = widget.item(r, 0) + if item is not None and item.data(Qt.UserRole) == key: + widget.selectRow(r) + return True + return False + def selected_key(widget: QTableWidget): row_index = widget.currentRow() diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py new file mode 100644 index 0000000..31e2eb5 --- /dev/null +++ b/tests/test_dialogs.py @@ -0,0 +1,185 @@ +"""Диалоги должны собираться без единого проглоченного исключения. + +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_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() diff --git a/tests/test_input.py b/tests/test_input.py new file mode 100644 index 0000000..9bae205 --- /dev/null +++ b/tests/test_input.py @@ -0,0 +1,160 @@ +"""Ввод с клавиатуры и поведение таблиц. + +Отдельный файл, потому что проверять надо именно нажатия клавиш через QTest. +Проверки уровня validate() однажды уже прошли зелёными, пока набрать цену +в поле было физически невозможно: у поля суффикс « ₽», и «очистка» текста +на каждом нажатии его ломала, из-за чего принимался только первый символ. +""" + +import os +from decimal import Decimal + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtTest import QTest # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + +from app import journal # noqa: E402 +from app.ui import theme # noqa: E402 +from app.ui import widgets as w # noqa: E402 + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() or QApplication([]) + theme.apply(app) + return app + + +def type_in(spin, keys: str): + """Набрать значение так, как это делает человек: выделить и напечатать.""" + spin.show() + spin.setFocus() + spin.lineEdit().selectAll() + QTest.keyClicks(spin.lineEdit(), keys) + spin.interpretText() + return spin.value_decimal() + + +# --- деньги --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "keys,expected", + [ + ("25", "25.00"), + ("25,50", "25.50"), + ("25.50", "25.50"), + ("1200", "1200.00"), + ("1234,56", "1234.56"), + ("0,5", "0.50"), + ("0.5", "0.50"), + ], +) +def test_price_can_actually_be_typed(qapp, keys, expected): + """Каждый символ должен доходить до значения, а не только первый.""" + spin = w.MoneySpin() + spin.set_decimal(20) + assert type_in(spin, keys) == Decimal(expected) + spin.deleteLater() + + +def test_currency_suffix_survives_typing(qapp): + spin = w.MoneySpin() + spin.set_decimal(20) + type_in(spin, "137,40") + assert spin.lineEdit().text().endswith("₽") + assert spin.value_decimal() == Decimal("137.40") + spin.deleteLater() + + +def test_price_without_currency_also_works(qapp): + spin = w.MoneySpin(currency="") + spin.set_decimal(0) + assert type_in(spin, "99.99") == Decimal("99.99") + spin.deleteLater() + + +# --- количества ----------------------------------------------------------- + + +@pytest.mark.parametrize( + "keys,expected", + [("3", "3.000"), ("0,5", "0.500"), ("0.5", "0.500"), ("1,125", "1.125"), ("1200", "1200.000")], +) +def test_quantity_can_actually_be_typed(qapp, keys, expected): + spin = w.QtySpin() + spin.set_decimal(1) + assert type_in(spin, keys) == Decimal(expected) + spin.deleteLater() + + +def test_typing_over_an_existing_value_replaces_it(qapp): + """Выделил всё, напечатал новое — старое не должно примешиваться.""" + spin = w.MoneySpin() + spin.set_decimal(1999) + assert type_in(spin, "7") == Decimal("7.00") + spin.deleteLater() + + +# --- выделение в таблицах ------------------------------------------------- + + +def rows_for(names): + return [[w.text_item(name)] for name in names] + + +def test_selection_follows_the_object_not_the_row(qapp): + """Таблицы отсортированы: под тем же номером после перерисовки может + оказаться другой объект, и «Удалить» сработает не на том.""" + table = w.table(["Название"]) + w.fill(table, rows_for(["Булка", "Ватрушка"]), keys=["b", "v"]) + w.select_by_key(table, "v") + assert w.selected_key(table) == "v" + + # Появился товар, встающий при сортировке первым. + w.fill(table, rows_for(["Ананас", "Булка", "Ватрушка"]), keys=["a", "b", "v"]) + assert w.selected_key(table) == "v" + table.deleteLater() + + +def test_selection_survives_a_plain_refresh(qapp): + table = w.table(["Название"]) + w.fill(table, rows_for(["Булка", "Ватрушка"]), keys=["b", "v"]) + w.select_by_key(table, "b") + w.fill(table, rows_for(["Булка", "Ватрушка"]), keys=["b", "v"]) + assert w.selected_key(table) == "b" + table.deleteLater() + + +def test_selection_is_dropped_when_the_object_is_gone(qapp): + table = w.table(["Название"]) + w.fill(table, rows_for(["Булка", "Ватрушка"]), keys=["b", "v"]) + w.select_by_key(table, "v") + w.fill(table, rows_for(["Булка"]), keys=["b"]) + assert w.selected_key(table) != "v" + table.deleteLater() + + +# --- правка цены целиком, как это делает пользователь --------------------- + + +def test_price_edit_reaches_the_product(qapp, vault, buns): + """Сквозной путь: набрать цены в диалоге и увидеть их в товаре.""" + from app.ui.page_products import PriceDialog + + product = buns["мак"] + dialog = PriceDialog("₽", product) + assert type_in(dialog.cost, "25,50") == Decimal("25.50") + assert type_in(dialog.retail, "44.90") == Decimal("44.90") + + journal.change_prices( + vault, product.id, dialog.cost.value_decimal(), dialog.retail.value_decimal() + ) + updated = vault.doc.product(product.id) + assert updated.cost_price == Decimal("25.50") + assert updated.retail_price == Decimal("44.90") + assert len(updated.price_history) == 2 + dialog.deleteLater()