Починен ввод цены и выделение в таблицах
Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы из вводимого текста, чтобы принимались разделители разрядов, — но у поля цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс, 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:
parent
bc868f5af1
commit
4dd9a071c0
@ -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)
|
||||||
|
|||||||
@ -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
185
tests/test_dialogs.py
Normal 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
160
tests/test_input.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user