Цена по фасовке не подставлялась после удаления строки

Обработчики строк запоминали номер строки в момент создания. После
удаления любой строки всё, что было ниже, съезжает вверх, и запомненный
номер начинает указывать на соседа или за пределы таблицы. Строка молча
переставала работать целиком: выбираешь фасовку — цена не меняется,
меняешь товар — не пересобирается список фасовок. Приходилось править
цену руками.

Теперь обработчики привязаны к самому виджету, а строка ищется по нему
в момент вызова. То же исправлено в быстром вводе, где строк больше
и удаляют их чаще.

Заодно найдена мина, которую посадил я сам в разделе статистики:
self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(),
который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным
«object is not callable». Такая же история была с self.layout в сводке.
Оба переименованы, добавлен тест, который обходит все виджеты и проверяет,
что ни один атрибут не затеняет метод Qt.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-08-23 17:50:42 +03:00
parent 38e9614a25
commit 26b401e507
9 changed files with 318 additions and 32 deletions

View File

@ -118,6 +118,12 @@
и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь
пачку с 10 на 12 штук, и уже записанные документы не поедут.
**Себестоимость указывается за базовую единицу**, а не за фасовку. Пачка
печенья 10 шт за 200 ₽ — значит себестоимость 20 ₽ за штуку; в закупке
программа сама подставит 200 ₽, когда выберешь пачку. Чтобы это не приходилось
держать в голове, карточка товара показывает результат прямо под таблицей
фасовок: «В закупку подставится: пачка = 10 шт, себестоимость 200,00 ₽».
### Ввод истории
Кнопка «Быстрый ввод за период» на экране продаж: задаёшь диапазон дат,

View File

@ -39,16 +39,17 @@ class DashboardPage(QWidget):
outer.addWidget(scroll)
self.body = QWidget()
self.layout = QVBoxLayout(self.body)
self.layout.setContentsMargins(20, 18, 20, 18)
self.layout.setSpacing(14)
# Не self.layout: имя занято методом QWidget.layout().
self.body_layout = QVBoxLayout(self.body)
self.body_layout.setContentsMargins(20, 18, 20, 18)
self.body_layout.setSpacing(14)
scroll.setWidget(self.body)
def refresh(self) -> None:
# Сводка целиком собирается заново: цифр немного, а следить за тем,
# какая карточка устарела, вышло бы дороже, чем перерисовать всё.
while self.layout.count():
item = self.layout.takeAt(0)
while self.body_layout.count():
item = self.body_layout.takeAt(0)
widget = item.widget()
if widget is not None:
# setParent(None) обязателен: deleteLater откладывает удаление
@ -61,22 +62,22 @@ class DashboardPage(QWidget):
summary = report.summary
currency = self.ctx.currency
self.layout.addWidget(w.heading("Сводка"))
self.body_layout.addWidget(w.heading("Сводка"))
if report.warnings:
self.layout.addWidget(w.warning_banner(report.warnings))
self.body_layout.addWidget(w.warning_banner(report.warnings))
self.layout.addWidget(self._metrics(summary, currency))
self.body_layout.addWidget(self._metrics(summary, currency))
if summary.next_due is not None:
self.layout.addWidget(self._deadline_card(summary.next_due, currency))
self.body_layout.addWidget(self._deadline_card(summary.next_due, currency))
self.layout.addWidget(w.label("Открытые партии", "h2"))
self.layout.addWidget(self._open_batches(report, currency))
self.body_layout.addWidget(w.label("Открытые партии", "h2"))
self.body_layout.addWidget(self._open_batches(report, currency))
self.layout.addWidget(w.label("Остатки", "h2"))
self.layout.addWidget(self._stock(report, currency))
self.layout.addStretch(1)
self.body_layout.addWidget(w.label("Остатки", "h2"))
self.body_layout.addWidget(self._stock(report, currency))
self.body_layout.addStretch(1)
def _metrics(self, summary, currency: str) -> QWidget:
holder = QWidget()

View File

@ -32,10 +32,12 @@ class PacksEditor(QWidget):
COL_NAME, COL_SIZE, COL_PRICE = range(3)
def __init__(self, currency: str, base_unit: str, packs=(), parent=None):
def __init__(self, currency: str, base_unit: str, packs=(), cost_source=None, parent=None):
super().__init__(parent)
self.currency = currency
self.base_unit = base_unit
# Откуда брать себестоимость базовой единицы для подсказки.
self.cost_source = cost_source or (lambda: m.ZERO)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@ -60,14 +62,42 @@ class PacksEditor(QWidget):
None,
)
)
# Себестоимость фасовки нигде не вводится — она выводится из цены
# базовой единицы. Показываем результат, иначе непонятно, откуда
# берётся сумма, которую программа подставит в закупку.
self.derived = w.label("", "dim")
self.derived.setWordWrap(True)
layout.addWidget(self.derived)
for pack in packs:
self.add_pack(pack.name, pack.size, pack.retail_price)
self.refresh_hint()
def refresh_hint(self) -> None:
cost = m.money(self.cost_source())
parts = []
for pack in self.packs():
parts.append(
f"{pack['name']} = {m.fmt_qty(pack['size'])} {self.base_unit}, "
f"себестоимость {m.fmt_money(cost * pack['size'], self.currency)}"
)
if not parts:
self.derived.setText("")
return
self.derived.setText(
"В закупку подставится: " + "; ".join(parts)
+ ". Себестоимость фасовки считается от цены за одну "
f"{self.base_unit} — её и указывай выше."
)
def set_base_unit(self, unit: str) -> None:
self.base_unit = unit or "шт"
self.table.setHorizontalHeaderItem(
self.COL_SIZE, QTableWidgetItem(f"Сколько {self.base_unit}")
)
self.refresh_hint()
def add_pack(self, name: str = "", size=1, price=0) -> None:
r = self.table.rowCount()
@ -75,20 +105,24 @@ class PacksEditor(QWidget):
name_edit = QLineEdit(name)
name_edit.setPlaceholderText("пачка")
name_edit.textChanged.connect(self.refresh_hint)
self.table.setCellWidget(r, self.COL_NAME, name_edit)
size_spin = w.QtySpin()
size_spin.set_decimal(size)
size_spin.valueChanged.connect(self.refresh_hint)
self.table.setCellWidget(r, self.COL_SIZE, size_spin)
price_spin = w.MoneySpin(self.currency)
price_spin.set_decimal(price)
self.table.setCellWidget(r, self.COL_PRICE, price_spin)
self.refresh_hint()
def remove_current(self) -> None:
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
self.refresh_hint()
def packs(self) -> list[dict]:
result = []
@ -152,9 +186,16 @@ class ProductDialog(QDialog):
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
self.packs = PacksEditor(
currency, self.unit.text() or "шт", product.packs if product else ()
currency,
self.unit.text() or "шт",
product.packs if product else (),
# У существующего товара цена правится отдельным диалогом, у нового
# берём её прямо из поля, чтобы подсказка считалась на лету.
cost_source=(lambda: product.cost_price) if product else self.cost.value_decimal,
)
self.unit.textChanged.connect(self.packs.set_base_unit)
if product is None:
self.cost.valueChanged.connect(lambda _: self.packs.refresh_hint())
layout.addWidget(self.packs)
if product is not None:

View File

@ -69,13 +69,16 @@ class BucketTab(QWidget):
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
self.metric = QComboBox()
# Не self.metric: у QWidget есть метод metric(), который Qt зовёт
# при смене стиля. Атрибут его затеняет, и приложение падает
# с невнятным «object is not callable».
self.metric_box = QComboBox()
for title, attribute, color in METRICS:
self.metric.addItem(title, (attribute, color))
self.metric.currentIndexChanged.connect(self._draw_chart)
self.metric_box.addItem(title, (attribute, color))
self.metric_box.currentIndexChanged.connect(self._draw_chart)
self.headline = w.label("", "h2")
layout.addWidget(w.row(w.label("На графике:"), self.metric, None, self.headline))
layout.addWidget(w.row(w.label("На графике:"), self.metric_box, None, self.headline))
self.chart = BarChart()
layout.addWidget(self.chart)
@ -125,7 +128,7 @@ class BucketTab(QWidget):
]
def _draw_chart(self) -> None:
attribute, color = self.metric.currentData()
attribute, color = self.metric_box.currentData()
shown = self.buckets[-CHART_LIMIT:]
currency = self.ctx.currency
@ -134,7 +137,7 @@ class BucketTab(QWidget):
Bar(
label=self._short(bucket),
value=getattr(bucket, attribute),
tooltip=f"{bucket.label}\n{self.metric.currentText()}: "
tooltip=f"{bucket.label}\n{self.metric_box.currentText()}: "
f"{m.fmt_money(getattr(bucket, attribute), currency)}",
)
for bucket in shown
@ -148,7 +151,7 @@ class BucketTab(QWidget):
# Про срез говорим вслух: молча показанная часть выглядела бы как всё.
tail = f" · на графике последние {len(shown)} из {len(self.buckets)}" if hidden else ""
self.headline.setText(
f"{self.metric.currentText()} за всё время: {m.fmt_money(total, currency)}{tail}"
f"{self.metric_box.currentText()} за всё время: {m.fmt_money(total, currency)}{tail}"
)
def _short(self, bucket: stats.Bucket) -> str:

View File

@ -206,7 +206,9 @@ class QuickSalesDialog(QDialog):
for value in SALE_KINDS:
kind.addItem(SALE_KIND_LABELS[value], value)
kind.setCurrentIndex(SALE_KINDS.index(state["kind"]))
kind.currentIndexChanged.connect(lambda _, rr=r: self._on_kind_changed(rr))
kind.currentIndexChanged.connect(
lambda _, wid=kind: self._on_kind_changed(w.row_of(self.table, wid))
)
self.table.setCellWidget(r, COL_KIND, kind)
who = QComboBox()
@ -222,26 +224,36 @@ class QuickSalesDialog(QDialog):
product = w.ProductCombo(self.products)
if state["product_id"]:
product.select_product(state["product_id"])
product.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr))
product.currentIndexChanged.connect(
lambda _, wid=product: self._on_product_changed(w.row_of(self.table, wid))
)
self.table.setCellWidget(r, COL_PRODUCT, product)
qty = w.QtySpin()
qty.set_decimal(state["qty"])
qty.valueChanged.connect(lambda _, rr=r: self._on_amount_changed(rr))
qty.valueChanged.connect(
lambda _, wid=qty: self._on_amount_changed(w.row_of(self.table, wid))
)
self.table.setCellWidget(r, COL_QTY, qty)
uom = QComboBox()
self.table.setCellWidget(r, COL_UOM, uom)
self._fill_uoms(r, product.current_product_id(), state["uom"])
uom.currentIndexChanged.connect(lambda _, rr=r: self._on_uom_changed(rr))
uom.currentIndexChanged.connect(
lambda _, wid=uom: self._on_uom_changed(w.row_of(self.table, wid))
)
price = w.MoneySpin(self.ctx.currency)
self.table.setCellWidget(r, COL_PRICE, price)
price.valueChanged.connect(lambda _, rr=r: self._on_amount_changed(rr))
price.valueChanged.connect(
lambda _, wid=price: self._on_amount_changed(w.row_of(self.table, wid))
)
paid = w.MoneySpin(self.ctx.currency)
paid.setProperty(TOUCHED, False)
paid.valueChanged.connect(lambda _, rr=r: self._on_paid_edited(rr))
paid.valueChanged.connect(
lambda _, wid=paid: self._on_paid_edited(w.row_of(self.table, wid))
)
self.table.setCellWidget(r, COL_PAID, paid)
tip = w.MoneySpin(self.ctx.currency)
@ -299,6 +311,8 @@ class QuickSalesDialog(QDialog):
combo.setEnabled(len(names) > 1)
def _apply_price(self, r: int) -> None:
if r < 0:
return
product = self._product(self.table.cellWidget(r, COL_PRODUCT).current_product_id())
if product is None:
return
@ -308,15 +322,21 @@ class QuickSalesDialog(QDialog):
self.table.cellWidget(r, COL_PRICE).set_decimal(price_source_for(kind)(product, uom))
def _on_product_changed(self, r: int) -> None:
if r < 0:
return
self._fill_uoms(r, self.table.cellWidget(r, COL_PRODUCT).current_product_id())
self._apply_price(r)
self._on_amount_changed(r)
def _on_uom_changed(self, r: int) -> None:
if r < 0:
return
self._apply_price(r)
self._on_amount_changed(r)
def _on_kind_changed(self, r: int) -> None:
if r < 0:
return
kind = self.table.cellWidget(r, COL_KIND).currentData()
consumption = kind in CONSUMPTION_KINDS
@ -332,6 +352,8 @@ class QuickSalesDialog(QDialog):
self._on_amount_changed(r)
def _on_amount_changed(self, r: int) -> None:
if r < 0:
return
paid = self.table.cellWidget(r, COL_PAID)
# Полная оплата — самый частый случай. Если её правили руками, больше
# не трогаем: значит, там долг.
@ -342,6 +364,8 @@ class QuickSalesDialog(QDialog):
self._recalc()
def _on_paid_edited(self, r: int) -> None:
if r < 0:
return
self.table.cellWidget(r, COL_PAID).setProperty(TOUCHED, True)
self._recalc()

View File

@ -331,6 +331,21 @@ def select_by_key(widget: QTableWidget, key) -> bool:
return False
def row_of(table: QTableWidget, widget) -> int:
"""Найти строку, в которой виджет находится **сейчас**.
Обработчики строк нельзя привязывать к номеру, запомненному при создании:
после удаления строки всё, что было ниже, съезжает вверх, и запомненный
номер начинает указывать на соседа или вообще за пределы таблицы.
Молча ломается вся строка от подстановки цены до пересчёта суммы.
"""
for r in range(table.rowCount()):
for c in range(table.columnCount()):
if table.cellWidget(r, c) is widget:
return r
return -1
def selected_key(widget: QTableWidget):
row_index = widget.currentRow()
if row_index < 0:
@ -408,7 +423,11 @@ class LinesEditor(QWidget):
combo = ProductCombo(self.products)
if product_id:
combo.select_product(product_id)
combo.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr))
# Привязываемся к виджету, а не к номеру строки: номер устаревает
# после удаления строки, виджет — нет.
combo.currentIndexChanged.connect(
lambda _, wid=combo: self._on_product_changed(row_of(self.table, wid))
)
self.table.setCellWidget(r, self.COL_PRODUCT, combo)
qty_spin = QtySpin()
@ -419,7 +438,9 @@ class LinesEditor(QWidget):
uom_combo = QComboBox()
self.table.setCellWidget(r, self.COL_UOM, uom_combo)
self._fill_uoms(r, combo.current_product_id(), uom)
uom_combo.currentIndexChanged.connect(lambda _, rr=r: self._on_uom_changed(rr))
uom_combo.currentIndexChanged.connect(
lambda _, wid=uom_combo: self._on_uom_changed(row_of(self.table, wid))
)
price_spin = MoneySpin(self.currency)
price_spin.set_decimal(
@ -506,6 +527,8 @@ class LinesEditor(QWidget):
return self.price_source(product, uom) if product else m.ZERO
def _apply_suggested_price(self, r: int) -> None:
if r < 0:
return
combo = self.table.cellWidget(r, self.COL_PRODUCT)
price_widget = self.table.cellWidget(r, self.COL_PRICE)
if combo and price_widget:
@ -514,6 +537,8 @@ class LinesEditor(QWidget):
)
def _on_product_changed(self, r: int) -> None:
if r < 0:
return
combo = self.table.cellWidget(r, self.COL_PRODUCT)
# У нового товара свои фасовки, поэтому список пересобирается целиком.
self._fill_uoms(r, combo.current_product_id() if combo else None)
@ -521,6 +546,8 @@ class LinesEditor(QWidget):
self._recalc()
def _on_uom_changed(self, r: int) -> None:
if r < 0:
return
self._apply_suggested_price(r)
self._recalc()

View File

@ -169,6 +169,54 @@ def test_payment_dialog_is_quiet(ctx, silent):
dialog.deleteLater()
def test_no_attribute_shadows_a_qt_method():
"""Атрибут с именем метода Qt — мина замедленного действия.
`self.metric = QComboBox()` на QWidget затеняет QWidget.metric(), который
Qt зовёт при смене стиля и в некоторых путях отрисовки. Падает это не
там, где написано, и с невнятным «object is not callable».
"""
import ast
import pathlib
from PySide6.QtWidgets import QDialog, QWidget
reserved = {n for n in dir(QWidget) if not n.startswith("_")}
reserved |= {n for n in dir(QDialog) if not n.startswith("_")}
offenders = []
for path in sorted(pathlib.Path("app/ui").rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if (
isinstance(target, ast.Attribute)
and isinstance(target.value, ast.Name)
and target.value.id == "self"
and target.attr in reserved
):
offenders.append(f"{path}:{node.lineno} self.{target.attr}")
assert offenders == [], "Атрибуты затеняют методы Qt: " + "; ".join(offenders)
def test_switching_style_does_not_break_pages(ctx, silent, qapp):
"""Смена стиля перебирает все виджеты и ловит затенённые методы Qt."""
from app.ui.page_dashboard import DashboardPage
from app.ui.page_stats import StatsPage
pages = [DashboardPage(ctx), StatsPage(ctx)]
for page in pages:
page.refresh()
qapp.setStyle("Fusion")
assert silent == []
for page in pages:
page.deleteLater()
def test_every_page_refreshes_quietly(ctx, silent):
from app.ui.page_batches import BatchesPage
from app.ui.page_dashboard import DashboardPage

View File

@ -266,6 +266,43 @@ def test_fractional_quantities_in_bulk_entry(ctx, qapp):
d.deleteLater()
def test_row_keeps_working_after_an_earlier_row_is_deleted(ctx, qapp):
"""Строки не должны держаться за свой номер — он устаревает при удалении."""
from app.ui.quick_sales import COL_PRICE, COL_UOM
cookies = journal.create_product(ctx.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
journal.set_packs(ctx.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
d = QuickSalesDialog(ctx)
d.add_row()
fill(d, 1, cookies.id, 1)
d.table.setCurrentCell(0, 0)
d.remove_row()
assert d.table.rowCount() == 1
d.table.cellWidget(0, COL_UOM).setCurrentText("пачка")
assert d.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("200.00")
assert d.collect()[0]["line"]["uom"] == "пачка"
d.deleteLater()
def test_kind_switch_still_works_after_deletion(ctx, qapp, buns):
from app.models import SALE_KINDS
from app.ui.quick_sales import COL_KIND, COL_PRICE
d = QuickSalesDialog(ctx)
d.add_row()
fill(d, 1, buns["мак"].id, 1)
d.table.setCurrentCell(0, 0)
d.remove_row()
d.table.cellWidget(0, COL_KIND).setCurrentIndex(SALE_KINDS.index(KIND_FRIEND))
assert d.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("20.00")
d.deleteLater()
def test_tips_in_bulk_entry(dialog, ctx, buns):
from app.ui.quick_sales import COL_TIP

View File

@ -422,6 +422,74 @@ def test_lines_editor_switches_price_with_the_pack(window):
dialog.deleteLater()
def test_pack_price_still_applies_after_deleting_a_row(window, buns):
"""Обработчики строк не должны держаться за номер строки.
После удаления строки всё, что было ниже, съезжает вверх. Запомненный
номер начинал указывать на соседа или за пределы таблицы, и строка
молча переставала работать: выбираешь фасовку, а цена не меняется.
"""
from app import journal as j
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
window.changed()
dialog = BatchDialog(window)
editor = dialog.lines
editor.add_line()
editor.table.cellWidget(1, editor.COL_PRODUCT).select_product(cookies.id)
# Удаляем первую строку — печенье переезжает на её место.
editor.table.setCurrentCell(0, 0)
editor.remove_current()
assert editor.table.rowCount() == 1
editor.table.cellWidget(0, editor.COL_UOM).setCurrentText("пачка")
line = editor.lines("unit_cost")[0]
assert line["uom"] == "пачка"
assert line["unit_cost"] == Decimal("200.00") # 20 за штуку × 10
dialog.deleteLater()
def test_product_change_still_refills_packs_after_deleting_a_row(window, buns):
from app import journal as j
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
window.changed()
dialog = SaleDialog(window)
editor = dialog.lines
editor.add_line()
editor.table.setCurrentCell(0, 0)
editor.remove_current()
# Строка переехала наверх — выбор товара обязан пересобрать её фасовки.
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(cookies.id)
uom = editor.table.cellWidget(0, editor.COL_UOM)
assert [uom.itemText(i) for i in range(uom.count())] == ["шт", "пачка"]
assert editor.lines("unit_price")[0]["unit_price"] == Decimal("30.00")
dialog.deleteLater()
def test_row_of_finds_the_current_position(qapp):
from PySide6.QtWidgets import QLineEdit, QTableWidget
from app.ui import widgets as wd
table = QTableWidget(3, 1)
fields = [QLineEdit() for _ in range(3)]
for index, field in enumerate(fields):
table.setCellWidget(index, 0, field)
assert wd.row_of(table, fields[2]) == 2
table.removeRow(0)
assert wd.row_of(table, fields[2]) == 1 # съехал вверх
assert wd.row_of(table, fields[0]) == -1 # удалён
table.deleteLater()
def test_pack_column_is_disabled_without_packs(window, buns):
dialog = SaleDialog(window)
editor = dialog.lines
@ -433,6 +501,37 @@ def test_pack_column_is_disabled_without_packs(window, buns):
dialog.deleteLater()
def test_packs_editor_shows_the_derived_cost(window):
"""Себестоимость фасовки нигде не вводится — надо показать, откуда она.
Иначе легко указать в карточке цену за пачку вместо цены за штуку,
и подставляемая в закупку сумма окажется больше в размер фасовки.
"""
from app import journal as j
from app.ui.page_products import ProductDialog as PD
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
dialog = PD("", window.vault.doc.product(cookies.id))
assert "пачка = 10 шт" in dialog.packs.derived.text()
assert "200,00 ₽" in dialog.packs.derived.text() # 20 за штуку × 10
dialog.deleteLater()
def test_derived_cost_follows_the_price_field(window):
from app.ui.page_products import ProductDialog as PD
dialog = PD("")
dialog.cost.set_decimal(20)
dialog.packs.add_pack("пачка", 10, 200)
assert "200,00 ₽" in dialog.packs.derived.text()
dialog.cost.set_decimal(25)
assert "250,00 ₽" in dialog.packs.derived.text()
dialog.deleteLater()
def test_packs_editor_round_trip(window):
from app import journal as j
from app.ui.page_products import ProductDialog as PD
@ -507,7 +606,7 @@ def test_stats_metric_switch_redraws_the_chart(window):
page.refresh()
tab = page.sections[2][1]
tab.metric.setCurrentIndex(1) # Выручка
tab.metric_box.setCurrentIndex(1) # Выручка
assert "Выручка за всё время" in tab.headline.text()
assert len(tab.chart.bars) == len(tab.buckets)