Починен ввод цены и выделение в таблицах

Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы
из вводимого текста, чтобы принимались разделители разрядов, — но у поля
цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс,
Qt отвергало символ, и в поле оставалась только первая цифра: набираешь
«25,50», получаешь 2.

Теперь текст на каждом нажатии не трогается вообще: сначала пробуем как
есть, и только если Qt отверг именно из-за точки — повторяем с запятой.

Второе: форма продажи подключала сигнал редактора строк раньше, чем
создавала поле оплаты. Qt глотает исключения в слотах, поэтому вместо
падения оплата просто молча не подставлялась.

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

Тесты: настоящие нажатия клавиш через QTest вместо проверок validate(),
которые этот баг пропустили, и перехват sys.excepthook, чтобы
проглоченные Qt исключения считались провалом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-08-22 23:13:45 +03:00
parent bc868f5af1
commit 4dd9a071c0
4 changed files with 385 additions and 8 deletions

View File

@ -78,6 +78,13 @@ class SaleDialog(QDialog):
form.addRow("Заметка", self.note) form.addRow("Заметка", self.note)
layout.addLayout(form) layout.addLayout(form)
# Поля оплаты создаются ниже, но редактор строк начинает слать сигналы
# сразу при заполнении. Без этих заглушек первый же сигнал приходит
# в обработчик, для которого self.paid ещё не существует, — Qt съедает
# исключение, и подстановка оплаты молча не работает.
self.paid = None
self.paid_hint = None
layout.addWidget(w.label("Что ушло", "h2")) layout.addWidget(w.label("Что ушло", "h2"))
products = [p for p in ctx.vault.doc.products if not p.archived] products = [p for p in ctx.vault.doc.products if not p.archived]
self.lines = w.LinesEditor( self.lines = w.LinesEditor(
@ -92,7 +99,6 @@ class SaleDialog(QDialog):
# Оплату задаём только при создании: у существующей продажи платежи # Оплату задаём только при создании: у существующей продажи платежи
# живут своей жизнью и правятся на экране долгов. # живут своей жизнью и правятся на экране долгов.
self.paid = None
if sale is None: if sale is None:
pay_form = QFormLayout() pay_form = QFormLayout()
self.paid = w.MoneySpin(ctx.currency) self.paid = w.MoneySpin(ctx.currency)

View File

@ -10,7 +10,7 @@ from datetime import date
from decimal import Decimal from decimal import Decimal
from PySide6.QtCore import QDate, Qt, Signal from PySide6.QtCore import QDate, Qt, Signal
from PySide6.QtGui import QColor, QFont from PySide6.QtGui import QColor, QFont, QValidator
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QCompleter, QCompleter,
@ -47,11 +47,17 @@ class DecimalSpin(QDoubleSpinBox):
""" """
def validate(self, text: str, pos: int): 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: def fixup(self, text: str) -> str:
return super().fixup(text.replace(".", ",")) 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: def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None:
"""Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки.""" """Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки.
Выделение восстанавливается по идентификатору, а не по номеру строки.
Таблицы отсортированы, и после перерисовки под тем же номером оказывается
уже другой объект: правишь цену одного товара, а «Удалить» нажимается
на соседнем.
"""
previously = selected_key(widget)
was_sorting = widget.isSortingEnabled() was_sorting = widget.isSortingEnabled()
widget.setSortingEnabled(False) widget.setSortingEnabled(False)
widget.setRowCount(len(rows)) 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]) cells[0].setData(Qt.UserRole, keys[r])
widget.setSortingEnabled(was_sorting) 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): def selected_key(widget: QTableWidget):
row_index = widget.currentRow() row_index = widget.currentRow()

185
tests/test_dialogs.py Normal file
View File

@ -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()

160
tests/test_input.py Normal file
View File

@ -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()