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

Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы
из вводимого текста, чтобы принимались разделители разрядов, — но у поля
цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс,
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)
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)

View File

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

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