Compare commits
2 Commits
efce552e2b
...
bc868f5af1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc868f5af1 | ||
|
|
1a97fee872 |
14
README.md
14
README.md
@ -32,6 +32,20 @@
|
||||
- **Журнал** — что менялось в базе, когда и с какой машины.
|
||||
- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн.
|
||||
|
||||
### Дробные количества
|
||||
|
||||
Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра
|
||||
или полбулки учитываются как есть. Единица измерения у товара произвольная —
|
||||
`шт`, `л`, `кг`.
|
||||
|
||||
Поля ввода принимают и запятую, и точку: на цифровой клавиатуре точка, а
|
||||
интерфейс русский и ждёт запятую, так что точка молча заменяется на запятую.
|
||||
Без этого символ просто не появлялся бы в поле.
|
||||
|
||||
Общее количество остатка по всем товарам сразу нигде не показывается —
|
||||
складывать литры с килограммами бессмысленно. В сводке остаток выражен деньгами
|
||||
и числом позиций, а разбивка по товарам есть в таблице «Остатки».
|
||||
|
||||
### Фасовки
|
||||
|
||||
Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — это **один
|
||||
|
||||
@ -139,8 +139,12 @@ class Summary:
|
||||
cash_collected: Decimal
|
||||
gross_margin: Decimal
|
||||
consumed_cost: Decimal
|
||||
# Осторожно: сумма по всем товарам сразу. Осмысленна, только когда единица
|
||||
# измерения у всех одна. Литры с килограммами складывать нельзя, поэтому
|
||||
# в сводке показывается не она, а stock_cost и stock_positions.
|
||||
stock_qty: Decimal
|
||||
stock_cost: Decimal
|
||||
stock_positions: int
|
||||
overdue_count: int
|
||||
next_due: BatchReport | None
|
||||
|
||||
@ -476,6 +480,7 @@ def _summary(
|
||||
consumed_cost=m.money(consumed),
|
||||
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)),
|
||||
stock_cost=m.money(sum((r.stock_cost for r in batch_reports), m.ZERO)),
|
||||
stock_positions=len({pid for r in batch_reports for pid in r.stock}),
|
||||
overdue_count=sum(1 for r in batch_reports if r.status == STATUS_OVERDUE),
|
||||
next_due=next_due,
|
||||
)
|
||||
|
||||
@ -168,6 +168,16 @@ class MainWindow(QMainWindow):
|
||||
def sync_status_text(self) -> str:
|
||||
return self._sync_text
|
||||
|
||||
def _git(self) -> GitSync:
|
||||
"""Git всегда работает над папкой данных, а не над корнем приложения.
|
||||
|
||||
Единственная точка создания GitSync: если exe лежит внутри клона
|
||||
с исходниками, ошибка в этом месте заставила бы приложение
|
||||
распоряжаться репозиторием кода — переписать ему origin и запушить
|
||||
исходники в репозиторий данных.
|
||||
"""
|
||||
return GitSync(paths.data_dir(), paths.vault_rel_posix(), self.vault.doc.settings.git)
|
||||
|
||||
# --- сохранение ---
|
||||
|
||||
def _autosave(self) -> None:
|
||||
@ -214,7 +224,7 @@ class MainWindow(QMainWindow):
|
||||
self._sync_text = "синхронизация…"
|
||||
self._update_status()
|
||||
|
||||
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
|
||||
sync = self._git()
|
||||
self._sync_task = SyncTask(sync, paths.remote_copy_path(), self)
|
||||
self._sync_task.done.connect(lambda *args: self._on_sync_done(*args, quiet=quiet))
|
||||
self._sync_task.start()
|
||||
@ -267,8 +277,7 @@ class MainWindow(QMainWindow):
|
||||
# --- разрешение конфликта ---
|
||||
|
||||
def adopt_remote_version(self, _copy_path: Path) -> None:
|
||||
git = self.vault.doc.settings.git
|
||||
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
|
||||
sync = self._git()
|
||||
try:
|
||||
sync.reset_to_remote()
|
||||
self.vault.reload()
|
||||
@ -289,9 +298,8 @@ class MainWindow(QMainWindow):
|
||||
self.changed()
|
||||
|
||||
def force_push_local(self) -> None:
|
||||
git = self.vault.doc.settings.git
|
||||
self.save_now()
|
||||
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
|
||||
sync = self._git()
|
||||
try:
|
||||
sync.force_push()
|
||||
except GitError as exc:
|
||||
@ -325,7 +333,7 @@ class MainWindow(QMainWindow):
|
||||
# Последний пуш на выходе, с потолком по времени: висеть на закрытии
|
||||
# из-за пропавшей сети приложение не должно.
|
||||
self.status_label.setText("Отправляю базу на сервер…")
|
||||
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
|
||||
sync = self._git()
|
||||
task = SyncTask(sync, paths.remote_copy_path(), self)
|
||||
task.start()
|
||||
task.wait(CLOSE_SYNC_WAIT_MS)
|
||||
|
||||
@ -99,9 +99,12 @@ class DashboardPage(QWidget):
|
||||
theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT,
|
||||
),
|
||||
(
|
||||
# Показываем деньги, а не количество: у сока литры, у сыра
|
||||
# килограммы, у булок штуки — общая сумма штук бессмысленна.
|
||||
# Разбивка по товарам есть ниже в «Остатках».
|
||||
"Остаток на складе",
|
||||
m.fmt_qty(summary.stock_qty),
|
||||
f"по себестоимости {m.fmt_money(summary.stock_cost, currency)}",
|
||||
m.fmt_money(summary.stock_cost, currency),
|
||||
f"позиций: {summary.stock_positions}" if summary.stock_positions else "пусто",
|
||||
theme.TEXT,
|
||||
),
|
||||
]
|
||||
|
||||
@ -37,7 +37,27 @@ MAX_MONEY = 99_999_999.0
|
||||
# --- поля ввода -----------------------------------------------------------
|
||||
|
||||
|
||||
class MoneySpin(QDoubleSpinBox):
|
||||
class DecimalSpin(QDoubleSpinBox):
|
||||
"""Числовое поле, принимающее и точку, и запятую.
|
||||
|
||||
Интерфейс русский, поэтому разделитель целой и дробной части — запятая.
|
||||
Но на цифровой клавиатуре клавиша даёт точку, и стандартный валидатор
|
||||
Qt её молча отвергает: символ просто не появляется в поле, и человек
|
||||
решает, что дробные значения вводить нельзя.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
def fixup(self, text: str) -> str:
|
||||
return super().fixup(text.replace(".", ","))
|
||||
|
||||
|
||||
class MoneySpin(DecimalSpin):
|
||||
def __init__(self, currency: str = "₽", parent=None):
|
||||
super().__init__(parent)
|
||||
self.setDecimals(2)
|
||||
@ -53,14 +73,18 @@ class MoneySpin(QDoubleSpinBox):
|
||||
self.setValue(float(m.money(value)))
|
||||
|
||||
|
||||
class QtySpin(QDoubleSpinBox):
|
||||
class QtySpin(DecimalSpin):
|
||||
"""Количество.
|
||||
|
||||
Три знака после запятой: товар может быть и штучным, и на розлив —
|
||||
пол-литра сока это 0,5.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setDecimals(3)
|
||||
self.setRange(0, 1_000_000)
|
||||
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
# Дробные количества нужны для веса, но булки штучные — прячем нули.
|
||||
self.setStepType(QDoubleSpinBox.DefaultStepType)
|
||||
|
||||
def textFromValue(self, value: float) -> str:
|
||||
return m.fmt_qty(value)
|
||||
|
||||
@ -420,6 +420,142 @@ def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
|
||||
assert report.summary.stock_qty == 0
|
||||
|
||||
|
||||
# --- Дробные количества: розлив и вес ------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def juice(vault):
|
||||
"""Сок: берётся литрами, продаётся в том числе по пол-литра."""
|
||||
return journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120)
|
||||
|
||||
|
||||
def test_fractional_sale_leaves_fractional_stock(vault, juice, today, in_two_weeks):
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}]
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.5", 120)])
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
batch = report.batches[0]
|
||||
assert batch.qty_sold == Decimal("0.500")
|
||||
assert batch.qty_left == Decimal("9.500")
|
||||
assert batch.cash_collected == Decimal("60.00") # 0,5 × 120
|
||||
assert report.summary.gross_margin == Decimal("20.00") # 0,5 × (120 − 80)
|
||||
|
||||
|
||||
def test_fractional_purchase(vault, juice, today, in_two_weeks):
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": "2.5", "unit_cost": 80}]
|
||||
)
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batches[0].qty_total == Decimal("2.500")
|
||||
assert report.batches[0].cost_total == Decimal("200.00") # 2,5 × 80
|
||||
|
||||
|
||||
def test_many_fractional_sales_add_up_exactly(vault, juice, today, in_two_weeks):
|
||||
"""Дробные не должны копить погрешность — потому и Decimal, а не float."""
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}]
|
||||
)
|
||||
for _ in range(20):
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.1", 120)])
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batches[0].qty_sold == Decimal("2.000")
|
||||
assert report.batches[0].qty_left == Decimal("8.000")
|
||||
assert report.batches[0].cash_collected == Decimal("240.00")
|
||||
|
||||
|
||||
def test_fractional_pack_size(vault, juice, today, in_two_weeks):
|
||||
"""Бутылка 1,5 л — фасовка дробного размера."""
|
||||
journal.set_packs(vault, juice.id, [{"name": "бутылка", "size": "1.5", "retail_price": 170}])
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks,
|
||||
[{"product_id": juice.id, "qty": 4, "uom": "бутылка", "unit_cost": 120}],
|
||||
)
|
||||
journal.create_sale(
|
||||
vault, today, KIND_RETAIL,
|
||||
[{"product_id": juice.id, "qty": 1, "uom": "бутылка", "unit_price": 170}],
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.5", 120)])
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
batch = report.batches[0]
|
||||
assert batch.qty_total == Decimal("6.000") # 4 бутылки × 1,5 л
|
||||
assert batch.qty_sold == Decimal("2.000") # 1,5 + 0,5
|
||||
assert batch.qty_left == Decimal("4.000")
|
||||
assert batch.cash_collected == Decimal("230.00") # 170 + 60
|
||||
|
||||
|
||||
def test_fractional_stock_value(vault, juice, today, in_two_weeks):
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}]
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.25", 120)])
|
||||
|
||||
summary = ledger.build(vault.doc, today).summary
|
||||
assert summary.stock_qty == Decimal("9.750")
|
||||
assert summary.stock_cost == Decimal("780.00") # 9,75 × 80
|
||||
|
||||
|
||||
def test_fractional_self_consumption(vault, juice, today, in_two_weeks):
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 5, "unit_cost": 80}]
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_SELF, [line(juice, "0.5", 0)])
|
||||
|
||||
summary = ledger.build(vault.doc, today).summary
|
||||
assert summary.consumed_cost == Decimal("40.00") # 0,5 × 80
|
||||
assert summary.profit == Decimal("-40.00")
|
||||
|
||||
|
||||
def test_fractional_sale_spills_across_batches(vault, juice, today):
|
||||
journal.create_batch(
|
||||
vault, today - timedelta(days=5), today + timedelta(days=9),
|
||||
[{"product_id": juice.id, "qty": "1.5", "unit_cost": 80}],
|
||||
)
|
||||
second = journal.create_batch(
|
||||
vault, today, today + timedelta(days=14),
|
||||
[{"product_id": juice.id, "qty": "2.5", "unit_cost": 80}],
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "2.25", 120)])
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batches[0].qty_sold == Decimal("1.500")
|
||||
assert report.batch_report(second.id).qty_sold == Decimal("0.750")
|
||||
assert report.batch_report(second.id).qty_left == Decimal("1.750")
|
||||
|
||||
|
||||
def test_summary_counts_positions_because_units_differ(vault, juice, buns, today, in_two_weeks):
|
||||
"""Литры, килограммы и штуки в одну сумму не складываются.
|
||||
|
||||
Сводка поэтому показывает деньги и число позиций, а не общее количество.
|
||||
"""
|
||||
cheese = journal.create_product(vault, "Сыр", unit="кг", cost_price=600, retail_price=900)
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks,
|
||||
[
|
||||
{"product_id": juice.id, "qty": 10, "unit_cost": 80},
|
||||
{"product_id": cheese.id, "qty": "3.5", "unit_cost": 600},
|
||||
buy(buns["мак"], 20, 20),
|
||||
],
|
||||
)
|
||||
|
||||
summary = ledger.build(vault.doc, today).summary
|
||||
assert summary.stock_positions == 3
|
||||
assert summary.stock_cost == Decimal("3300.00") # 800 + 2100 + 400
|
||||
|
||||
|
||||
def test_fractional_shortfall_is_reported(vault, juice, today, in_two_weeks):
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": "0.5", "unit_cost": 80}]
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.75", 120)])
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert [s.qty for s in report.shortfalls] == [Decimal("0.250")]
|
||||
|
||||
|
||||
def test_product_without_packs_behaves_exactly_as_before(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)])
|
||||
|
||||
@ -245,6 +245,27 @@ def test_same_person_twice_creates_one_counterparty(dialog, ctx, buns):
|
||||
assert len(ctx.vault.doc.counterparties) == 1
|
||||
|
||||
|
||||
def test_fractional_quantities_in_bulk_entry(ctx, qapp):
|
||||
"""Розлив вносится историей так же, как штучный товар."""
|
||||
juice = journal.create_product(ctx.vault, "Сок", unit="л", cost_price=80, retail_price=120)
|
||||
d = QuickSalesDialog(ctx)
|
||||
|
||||
for row, qty in enumerate(["0.5", "1.5", "0.25"]):
|
||||
if row:
|
||||
d.add_row()
|
||||
fill(d, row, juice.id, qty)
|
||||
|
||||
rows = d.collect()
|
||||
assert [r["line"]["qty"] for r in rows] == [
|
||||
Decimal("0.500"), Decimal("1.500"), Decimal("0.250")
|
||||
]
|
||||
assert "270,00 ₽" in d.total_label.text() # (0,5 + 1,5 + 0,25) × 120
|
||||
|
||||
write_sales(ctx.vault, rows)
|
||||
assert sum(s.total for s in ctx.vault.doc.sales) == Decimal("270.00")
|
||||
d.deleteLater()
|
||||
|
||||
|
||||
def test_partial_payment_becomes_a_debt(dialog, ctx, buns):
|
||||
fill(dialog, 0, buns["мак"].id, 4, paid=100) # всего 140
|
||||
write_sales(ctx.vault, dialog.collect())
|
||||
|
||||
@ -107,6 +107,32 @@ def test_sync_is_skipped_without_a_remote(window):
|
||||
assert window._syncing is False
|
||||
|
||||
|
||||
def test_git_always_targets_the_data_folder(window):
|
||||
"""Приложение не должно распоряжаться репозиторием с исходниками.
|
||||
|
||||
exe обычно лежит в корне клона с кодом, и папка data — внутри него.
|
||||
Если git начать с корня приложения, оно перепишет исходникам origin
|
||||
и запушит их в репозиторий данных. Ровно это однажды и произошло.
|
||||
"""
|
||||
from app import paths
|
||||
|
||||
sync = window._git()
|
||||
assert sync.repo_dir == paths.data_dir()
|
||||
assert sync.repo_dir != paths.app_root()
|
||||
assert sync.rel_path == "vault.fmdb"
|
||||
assert "/" not in sync.rel_path # база лежит в корне своего репозитория
|
||||
|
||||
|
||||
def test_no_other_place_builds_git_by_hand():
|
||||
"""Единственная точка создания GitSync — MainWindow._git()."""
|
||||
from pathlib import Path
|
||||
|
||||
source = Path("app/ui/main_window.py").read_text(encoding="utf-8")
|
||||
# Одно вхождение — внутри самого _git(); остальные зовут его.
|
||||
assert source.count("GitSync(") == 1
|
||||
assert "paths.app_root()" not in source
|
||||
|
||||
|
||||
# --- таблицы наполняются --------------------------------------------------
|
||||
|
||||
|
||||
@ -191,6 +217,9 @@ def test_dashboard_renders_metrics_and_deadline(window):
|
||||
assert "850,00 ₽" in texts # вся себестоимость партии ещё не возвращена
|
||||
assert "Мне должны" in texts
|
||||
assert "100,00 ₽" in texts # долг Васи
|
||||
# Остаток показан деньгами и позициями, а не суммой разных единиц.
|
||||
assert "610,00 ₽" in texts
|
||||
assert "позиций: 3" in texts
|
||||
|
||||
bars = page.body.findChildren(QProgressBar)
|
||||
assert len(bars) == 1
|
||||
@ -420,6 +449,104 @@ def test_lines_editor_add_and_remove(window, buns):
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
# --- дробные количества ---------------------------------------------------
|
||||
|
||||
|
||||
def type_into(spin, text: str):
|
||||
"""Набрать текст в поле так, как это делает пользователь."""
|
||||
spin.clear()
|
||||
spin.lineEdit().setText(text)
|
||||
spin.interpretText()
|
||||
return spin
|
||||
|
||||
|
||||
def test_dot_is_accepted_as_decimal_separator(qapp):
|
||||
"""На цифровой клавиатуре точка, а локаль русская — ждёт запятую.
|
||||
|
||||
Без нормализации символ молча не появлялся бы в поле, и выглядело бы это
|
||||
так, будто дробные значения вводить нельзя вообще.
|
||||
"""
|
||||
from PySide6.QtGui import QValidator
|
||||
|
||||
from app.ui.widgets import MoneySpin, QtySpin
|
||||
|
||||
for spin in (QtySpin(), MoneySpin()):
|
||||
for text in ("0,5", "0.5", "12.75", "12,75"):
|
||||
state, fixed, _ = spin.validate(text, len(text))
|
||||
assert state != QValidator.Invalid, f"{type(spin).__name__} отверг {text!r}"
|
||||
assert "." not in fixed
|
||||
spin.deleteLater()
|
||||
|
||||
|
||||
def test_both_separators_give_the_same_value(qapp):
|
||||
from app.ui.widgets import QtySpin
|
||||
|
||||
assert type_into(QtySpin(), "0.5").value_decimal() == Decimal("0.500")
|
||||
assert type_into(QtySpin(), "0,5").value_decimal() == Decimal("0.500")
|
||||
|
||||
|
||||
def test_group_separators_are_tolerated(qapp):
|
||||
from app.ui.widgets import MoneySpin
|
||||
|
||||
assert type_into(MoneySpin(), "1 200,50").value_decimal() == Decimal("1200.50")
|
||||
|
||||
|
||||
def test_letters_are_still_rejected(qapp):
|
||||
from PySide6.QtGui import QValidator
|
||||
|
||||
from app.ui.widgets import QtySpin
|
||||
|
||||
state, _, _ = QtySpin().validate("abc", 3)
|
||||
assert state == QValidator.Invalid
|
||||
|
||||
|
||||
def test_fractional_line_round_trips_through_the_form(window):
|
||||
"""Пол-литра сока, введённые в форме, должны дойти до документа."""
|
||||
from app import journal as j
|
||||
|
||||
juice = j.create_product(window.vault, "Сок", unit="л", cost_price=80, retail_price=120)
|
||||
window.changed()
|
||||
|
||||
dialog = SaleDialog(window)
|
||||
editor = dialog.lines
|
||||
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(juice.id)
|
||||
type_into(editor.table.cellWidget(0, editor.COL_QTY), "0.5")
|
||||
|
||||
line = editor.lines("unit_price")[0]
|
||||
assert line["qty"] == Decimal("0.500")
|
||||
assert editor.total() == Decimal("60.00") # 0,5 × 120
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_fractional_pack_size_round_trips(window):
|
||||
from app import journal as j
|
||||
from app.ui.page_products import ProductDialog as PD
|
||||
|
||||
juice = j.create_product(window.vault, "Сок", unit="л", cost_price=80, retail_price=120)
|
||||
j.set_packs(window.vault, juice.id, [{"name": "бутылка", "size": "1.5", "retail_price": 170}])
|
||||
|
||||
dialog = PD("₽", window.vault.doc.product(juice.id))
|
||||
assert dialog.packs.packs()[0]["size"] == Decimal("1.500")
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_fractional_quantity_is_displayed_without_trailing_zeros(window):
|
||||
from app import journal as j
|
||||
from app.models import KIND_RETAIL as RETAIL
|
||||
|
||||
juice = j.create_product(window.vault, "Сок", unit="л", cost_price=80, retail_price=120)
|
||||
j.create_sale(
|
||||
window.vault, window.report.today, RETAIL,
|
||||
[{"product_id": juice.id, "qty": "0.5", "unit_price": 120}],
|
||||
)
|
||||
window.changed()
|
||||
|
||||
page = window.pages[2][1]
|
||||
page.refresh()
|
||||
texts = [page.table.item(r, 3).text() for r in range(page.table.rowCount())]
|
||||
assert any("Сок × 0,5 л" in t for t in texts)
|
||||
|
||||
|
||||
# --- настройки ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user