food-market/tests/test_ui_smoke.py
food-market 950ccc33b3 Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.

Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.

Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:46:55 +03:00

1109 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Дымовые тесты интерфейса: окно собирается, экраны рисуются, формы отдают данные.
Гоняются в offscreen-режиме, поэтому идут и в консоли, и без монитора.
Диалоги не показываются через exec() — вместо этого проверяется их содержимое
и то, что из него получается.
"""
import os
from datetime import date, timedelta
from decimal import Decimal
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication # noqa: E402
from app import journal # noqa: E402
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF # noqa: E402
from app.ui import theme # noqa: E402
from app.ui.main_window import MainWindow # noqa: E402
from app.ui.page_batches import BatchDialog # noqa: E402
from app.ui.page_journal import JournalPage, describe # noqa: E402
from app.ui.page_products import PriceDialog, ProductDialog # noqa: E402
from app.ui.page_sales import SaleDialog, price_source_for # noqa: E402
@pytest.fixture(scope="session")
def qapp():
app = QApplication.instance() or QApplication([])
theme.apply(app)
return app
@pytest.fixture
def window(qapp, vault, buns, today, in_two_weeks):
"""Окно на живых данных: партия, розница, друг в долг, съеденное."""
batch = journal.create_batch(
vault,
today,
in_two_weeks,
[
{"product_id": buns["повидло"].id, "qty": 5, "unit_cost": 30},
{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20},
{"product_id": buns["корица"].id, "qty": 20, "unit_cost": 25},
],
)
vasya = journal.create_counterparty(vault, "Вася")
journal.create_sale(
vault, today, KIND_RETAIL, [{"product_id": buns["повидло"].id, "qty": 3, "unit_price": 50}]
)
journal.create_sale(
vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}],
counterparty_id=vasya.id, paid_amount=0,
)
journal.create_sale(
vault, today, KIND_SELF, [{"product_id": buns["корица"].id, "qty": 2, "unit_price": 0}]
)
win = MainWindow(vault)
win.batch = batch
win.vasya = vasya
yield win
win.autosave_timer.stop()
win.sync_timer.stop()
win.close()
win.deleteLater()
# --- окно целиком ---------------------------------------------------------
def page_of(window, title: str):
"""Найти экран по названию.
Раньше тесты адресовали экраны по номеру в списке, и любой новый раздел
в боковом меню ломал половину проверок.
"""
return next(page for name, page in window.pages if name == title)
def test_window_builds_with_all_pages(window):
titles = [title for title, _ in window.pages]
assert titles == [
"Сводка", "Закупки", "Продажи", "Долги",
"Товары", "Статистика", "Журнал", "Настройки",
]
assert window.stack.count() == len(titles)
def test_every_page_refreshes_without_errors(window):
for index in range(len(window.pages)):
window.sidebar.setCurrentRow(index)
window.pages[index][1].refresh()
def test_changed_rebuilds_the_report(window, buns):
before = window.report.summary.owed_to_bakery
journal.add_bakery_payment(window.vault, 100, batch_id=window.batch.id)
window.changed()
assert window.report.summary.owed_to_bakery == before - 100
def test_autosave_writes_only_after_a_change(window):
window.save_now()
stamp = window.vault.path.stat().st_mtime_ns
window._autosave()
assert window.vault.path.stat().st_mtime_ns == stamp # файл не трогали
journal.create_product(window.vault, "Булка с изюмом", cost_price=22, retail_price=38)
assert window.vault.revision != window._saved_revision
window._autosave()
assert window.vault.revision == window._saved_revision
def test_sync_is_skipped_without_a_remote(window):
"""Без адреса репозитория фоновый тик не должен ничего делать."""
window.sync_now(quiet=True)
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
# --- таблицы наполняются --------------------------------------------------
def test_batches_table_shows_the_batch(window):
page = page_of(window, "Закупки")
page.refresh()
assert page.table.rowCount() == 1
assert page.table.item(0, 0).text() == "1"
assert "850" in page.table.item(0, 4).text()
def test_batch_details_show_coverage(window):
page = page_of(window, "Закупки")
page.refresh()
page.table.selectRow(0)
page._show_details()
assert "Партия" in page.details_title.text()
assert page.progress.value() == 17 # 150 из 850
assert page.contents.rowCount() == 3
def test_sales_table_lists_every_kind(window):
page = page_of(window, "Продажи")
page.refresh()
assert page.table.rowCount() == 3
def test_sales_filter_narrows_the_list(window):
page = page_of(window, "Продажи")
page.filter.setCurrentIndex(page.filter.findData(KIND_SELF))
page.refresh()
assert page.table.rowCount() == 1
def test_debts_tree_groups_by_person(window):
page = page_of(window, "Долги")
page.refresh()
assert page.tree.topLevelItemCount() == 1
person = page.tree.topLevelItem(0)
assert person.text(0) == "Вася"
assert "100" in person.text(4)
assert person.childCount() == 1
def test_products_table_shows_stock_and_margin(window):
page = page_of(window, "Товары")
page.refresh()
names = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
assert "Булка с маком" in names
row = names.index("Булка с маком")
assert "15" in page.table.item(row, 5).text() # наценка: 35 20
assert page.table.item(row, 6).text() == "5" # 10 закуплено 5 отдано
def test_archived_products_are_hidden_by_default(window, buns):
page = page_of(window, "Товары")
journal.update_product(window.vault, buns["мак"].id, archived=True)
window.changed()
visible = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
assert "Булка с маком" not in visible
page.show_archived.setChecked(True)
visible = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
assert "Булка с маком" in visible
# --- сводка ---------------------------------------------------------------
def test_dashboard_renders_metrics_and_deadline(window):
from PySide6.QtWidgets import QLabel, QProgressBar
page = page_of(window, "Сводка")
page.refresh()
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
assert "Долг пекарне" in texts
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
assert bars[0].value() == 17 # собрано 150 из 850
def test_dashboard_shows_a_warning_when_oversold(window, buns):
page = page_of(window, "Сводка")
journal.create_sale(
window.vault, window.report.today, KIND_RETAIL,
[{"product_id": buns["повидло"].id, "qty": 99, "unit_price": 50}],
)
window.changed()
assert any("больше" in warning for warning in window.report.warnings)
# --- журнал ---------------------------------------------------------------
def test_journal_page_lists_entries(window):
page = page_of(window, "Журнал")
page.refresh()
assert page.table.rowCount() == len(window.vault.doc.journal)
def test_journal_search_filters(window, buns):
page = page_of(window, "Журнал")
journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40)
window.changed()
page.search.setText("цены изменены")
page.refresh()
assert page.table.rowCount() == 1
assert "20,00 ₽ → 25,00 ₽" in page.table.item(0, 4).text()
def test_journal_search_matches_what_is_displayed(window, buns):
"""Формат на экране и то, что ищется, должны совпадать до пробела."""
page = page_of(window, "Журнал")
journal.change_prices(window.vault, buns["мак"].id, cost_price=1500, retail_price=2000)
window.changed()
page.search.setText("1 500,00")
page.refresh()
assert page.table.rowCount() == 1
def test_journal_entity_filter(window):
page = page_of(window, "Журнал")
page.entity.setCurrentIndex(page.entity.findData("batch"))
page.refresh()
assert page.table.rowCount() == 1
def test_describe_renders_a_transition():
from app.models import Change, JournalEntry
entry = JournalEntry(
id="j_1", ts="", host="", action="product.price_change",
entity="product", entity_id="p_1", label="Булка",
changes=[Change("себестоимость", "20,00 ₽", "25,00 ₽")],
)
assert describe(entry) == "себестоимость: 20,00 ₽ → 25,00 ₽"
# --- формы ----------------------------------------------------------------
def test_batch_dialog_round_trips_lines(window):
dialog = BatchDialog(window, window.batch)
lines = dialog.lines.lines("unit_cost")
assert len(lines) == 3
assert dialog.lines.total() == Decimal("850.00")
assert lines[0]["unit_cost"] == Decimal("30.00")
dialog.deleteLater()
def test_batch_dialog_defaults_the_deadline_from_settings(window):
window.vault.doc.settings.default_period_days = 21
dialog = BatchDialog(window)
assert (dialog.due.get_date() - dialog.date.get_date()).days == 21
dialog.deleteLater()
def test_batch_dialog_deadline_follows_the_purchase_date(window):
dialog = BatchDialog(window)
dialog.date.set_date(dialog.date.get_date() + timedelta(days=5))
days = window.vault.doc.settings.default_period_days
assert (dialog.due.get_date() - dialog.date.get_date()).days == days
dialog.deleteLater()
def test_sale_dialog_substitutes_price_by_kind(window, buns):
"""Ради этого и заведены типы: другу — себестоимость, себе — ноль."""
dialog = SaleDialog(window)
dialog.lines.table.cellWidget(0, 0).select_product(buns["мак"].id)
dialog.kind.setCurrentIndex(0) # розница
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("35.00")
dialog.kind.setCurrentIndex(1) # другу по себестоимости
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("20.00")
dialog.kind.setCurrentIndex(2) # съел сам
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("0.00")
dialog.deleteLater()
def test_sale_dialog_disables_payment_for_consumption(window):
dialog = SaleDialog(window)
dialog.kind.setCurrentIndex(2) # съел сам
assert dialog.paid.isEnabled() is False
assert dialog.paid.value_decimal() == 0
dialog.deleteLater()
def test_sale_dialog_prefills_full_payment(window, buns):
dialog = SaleDialog(window)
dialog.lines.table.cellWidget(0, 0).select_product(buns["мак"].id)
dialog.lines.table.cellWidget(0, 1).set_decimal(4)
assert dialog.paid.value_decimal() == Decimal("140.00") # 4 × 35
assert "полностью" in dialog.paid_hint.text()
dialog.deleteLater()
def test_price_source_for_each_kind(buns):
bun = buns["мак"]
assert price_source_for(KIND_RETAIL)(bun, "шт") == Decimal("35.00")
assert price_source_for(KIND_FRIEND)(bun, "шт") == Decimal("20.00")
assert price_source_for(KIND_SELF)(bun, "шт") == 0
def test_price_source_uses_the_pack_price(vault, buns):
"""Пачка стоит своих денег, а не десяти поштучных цен."""
from app import journal as j
cookies = j.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
j.set_packs(vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
cookies = vault.doc.product(cookies.id)
assert price_source_for(KIND_RETAIL)(cookies, "шт") == Decimal("30.00")
assert price_source_for(KIND_RETAIL)(cookies, "пачка") == Decimal("200.00")
assert price_source_for(KIND_FRIEND)(cookies, "пачка") == Decimal("200.00") # 10 × 20
def test_price_dialog_shows_history(window, buns):
journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40)
dialog = PriceDialog("", window.vault.doc.product(buns["мак"].id))
assert dialog.cost.value_decimal() == Decimal("25.00")
dialog.deleteLater()
def test_product_dialog_hides_prices_when_editing(window, buns):
"""Цены правятся только через историю — иначе она бы врала."""
editing = ProductDialog("", window.vault.doc.product(buns["мак"].id))
assert not hasattr(editing, "cost")
editing.deleteLater()
creating = ProductDialog("")
assert hasattr(creating, "cost")
creating.deleteLater()
def test_lines_editor_switches_price_with_the_pack(window):
"""Выбрал пачку — цена и подпись меняются, товар остаётся тем же."""
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.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 uom.isEnabled() is True
assert editor.lines("unit_price")[0]["unit_price"] == Decimal("30.00")
uom.setCurrentText("пачка")
line = editor.lines("unit_price")[0]
assert line["uom"] == "пачка"
assert line["unit_price"] == Decimal("200.00")
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
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id)
uom = editor.table.cellWidget(0, editor.COL_UOM)
assert uom.currentText() == "шт"
assert uom.isEnabled() is False
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
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 dialog.packs.packs() == [
{
"name": "пачка",
"size": Decimal("10.000"),
"cost_price": Decimal("0.00"),
"retail_price": Decimal("200.00"),
}
]
dialog.deleteLater()
def test_pack_keeps_its_own_purchase_price(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=25, retail_price=25)
j.set_packs(
window.vault,
cookies.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
window.changed()
cookies = window.vault.doc.product(cookies.id)
assert cookies.cost_for("коробка") == Decimal("300.00") # а не 25 × 20
assert cookies.cost_for("шт") == Decimal("25.00")
dialog = BatchDialog(window)
editor = dialog.lines
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(cookies.id)
editor.table.cellWidget(0, editor.COL_UOM).setCurrentText("коробка")
line = editor.lines("unit_cost")[0]
assert line["uom"] == "коробка"
assert line["unit_cost"] == Decimal("300.00")
# 20 штук по 15 — себестоимость базовой единицы падает вместе с ценой.
j.create_batch(
window.vault,
date.today(),
date.today(),
[{"product_id": cookies.id, "qty": 1, "uom": "коробка", "unit_cost": 300}],
)
batch = window.vault.doc.batches[-1]
assert batch.lines[0].base_unit_cost == Decimal("15")
assert batch.cost_total == Decimal("300.00")
dialog.deleteLater()
def test_pack_hint_spells_out_the_price_per_unit(window):
"""«Коробка из 20, они по 15» — цифру 15 надо показать, а не держать в уме."""
from app.ui.page_products import pack_hint
text = pack_hint(
{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500},
"шт",
base_cost=25,
base_retail=25,
currency="",
)
assert "коробка = 20 шт" in text
assert "закупка 300,00 ₽ (15,00 ₽ за шт)" in text
assert "продажа 500,00 ₽ (25,00 ₽ за шт)" in text
# Ноль в поле — цена считается от базовой, и об этом сказано прямо.
derived = pack_hint(
{"name": "коробка", "size": 20, "cost_price": 0, "retail_price": 0},
"шт",
base_cost=25,
base_retail=25,
currency="",
)
assert "закупка 500,00 ₽ (25,00 ₽ за шт, от цены товара)" in derived
def test_packs_editor_round_trips_both_prices(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=25, retail_price=25)
j.set_packs(
window.vault,
cookies.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
dialog = PD("", window.vault.doc.product(cookies.id))
assert dialog.packs.packs() == [
{
"name": "коробка",
"size": Decimal("20.000"),
"cost_price": Decimal("300.00"),
"retail_price": Decimal("500.00"),
}
]
assert "15,00 ₽ за шт" in dialog.packs.derived.text()
dialog.deleteLater()
def test_lines_editor_add_and_remove(window, buns):
dialog = BatchDialog(window)
editor = dialog.lines
start = editor.table.rowCount()
editor.add_line(buns["мак"].id, 7, 20)
assert len(editor.lines("unit_cost")) == start + 1
editor.table.setCurrentCell(editor.table.rowCount() - 1, 0)
editor.remove_current()
assert len(editor.lines("unit_cost")) == start
dialog.deleteLater()
# --- пекарня простила остаток ---------------------------------------------
def test_payment_dialog_offers_to_write_off_the_remainder(window):
"""Надо было 850, отдал 800 — полсотни предлагается списать."""
from app.ui.page_batches import PaymentDialog
batch = window.vault.doc.batches[0]
dialog = PaymentDialog(window, batch, suggested=850)
dialog.amount.set_decimal(800)
assert dialog.remainder == Decimal("50.00")
assert dialog.wants_write_off() is False
assert "останется долг" in dialog.hint.text().lower()
dialog.write_off.setChecked(True)
assert dialog.wants_write_off() is True
assert "скидку пекарни" in dialog.hint.text()
dialog.deleteLater()
def test_full_payment_leaves_nothing_to_write_off(window):
from app.ui.page_batches import PaymentDialog
batch = window.vault.doc.batches[0]
dialog = PaymentDialog(window, batch, suggested=850)
dialog.write_off.setChecked(True)
assert dialog.remainder == 0
assert dialog.wants_write_off() is False # списывать нечего
assert "закроется полностью" in dialog.hint.text()
dialog.deleteLater()
def test_write_off_closes_the_batch_end_to_end(window):
from app import ledger
from app import journal as j
batch = window.vault.doc.batches[0]
j.add_bakery_payment(window.vault, 800, batch_id=batch.id)
j.write_off_batch(window.vault, batch.id, 50)
window.changed()
report = window.report.batch_report(batch.id)
assert report.paid_to_bakery == Decimal("800.00")
assert report.discount == Decimal("50.00")
assert report.remaining_to_bakery == 0
assert report.status == ledger.STATUS_SETTLED
assert window.report.summary.owed_to_bakery == 0
def test_batches_table_shows_the_discount(window):
from app import journal as j
batch = window.vault.doc.batches[0]
j.write_off_batch(window.vault, batch.id, 50)
window.changed()
page = page_of(window, "Закупки")
page.refresh()
assert "50,00" in page.table.item(0, 6).text()
page.table.selectRow(0)
page._show_details()
assert "пекарня простила" in page.details_line.text()
def test_dashboard_mentions_forgiven_money(window):
from PySide6.QtWidgets import QLabel
from app import journal as j
batch = window.vault.doc.batches[0]
j.write_off_batch(window.vault, batch.id, 50)
window.changed()
page = page_of(window, "Сводка")
page.refresh()
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
assert "прощено 50,00 ₽" in texts.lower()
def test_stats_periods_show_the_discount(window):
from app import journal as j
batch = window.vault.doc.batches[0]
j.write_off_batch(window.vault, batch.id, 50)
window.changed()
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(0)
page.refresh()
tab = page.sections[0][1]
assert "50,00" in tab.table.item(0, tab.COLUMNS.index("Прощено")).text()
# --- статистика -----------------------------------------------------------
def test_stats_page_has_every_section(window):
page = page_of(window, "Статистика")
titles = [page.tabs.tabText(i) for i in range(page.tabs.count())]
assert titles == ["Периоды", "Недели", "Месяцы", "Годы", "Товары", "Люди"]
def test_stats_sections_all_render(window):
page = page_of(window, "Статистика")
for index in range(page.tabs.count()):
page.tabs.setCurrentIndex(index)
page.refresh()
assert page.sections[index][1].table.rowCount() >= 0
def test_stats_periods_show_the_batch(window):
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(0)
page.refresh()
tab = page.sections[0][1]
assert tab.table.rowCount() == 1
assert tab.table.item(0, 0).text() == "№1"
assert "850" in tab.table.item(0, 4).text() # долг по партии
assert "Периодов: 1" in tab.headline.text()
def test_stats_months_carry_a_totals_row(window):
"""Последняя строка — итог, иначе цифры приходится складывать в уме."""
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(2)
page.refresh()
tab = page.sections[2][1]
assert tab.table.rowCount() == len(tab.buckets) + 1
assert tab.table.item(tab.table.rowCount() - 1, 0).text() == "Итого"
def test_stats_metric_switch_redraws_the_chart(window):
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(2)
page.refresh()
tab = page.sections[2][1]
tab.metric_box.setCurrentIndex(1) # Выручка
assert "Выручка за всё время" in tab.headline.text()
assert len(tab.chart.bars) == len(tab.buckets)
def test_stats_products_rank_by_margin(window):
"""Таблица и график должны отвечать одинаково: сортировка по заработку."""
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(4)
page.refresh()
tab = page.sections[4][1]
names = [tab.table.item(r, 0).text() for r in range(tab.table.rowCount())]
assert names[0] == "Булка с повидлом" # единственная с наценкой
assert names[0] == tab.chart.bars[0].label
def test_stats_people_table_matches_its_chart(window):
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(5)
page.refresh()
tab = page.sections[5][1]
names = [tab.table.item(r, 0).text() for r in range(tab.table.rowCount())]
assert names == [bar.label for bar in tab.chart.bars]
def test_stats_people_show_the_debt(window):
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(5)
page.refresh()
tab = page.sections[5][1]
rows = {tab.table.item(r, 0).text(): r for r in range(tab.table.rowCount())}
assert "100,00 ₽" in tab.table.item(rows["Вася"], 5).text()
assert "должны в сумме: 100,00 ₽" in tab.headline.text()
def test_stats_survive_an_empty_database(qapp, vault):
"""Пустая база не должна ронять ни один разрез."""
from app.ui.main_window import MainWindow as MW
win = MW(vault)
win.autosave_timer.stop()
win.sync_timer.stop()
page = next(p for name, p in win.pages if name == "Статистика")
for index in range(page.tabs.count()):
page.tabs.setCurrentIndex(index)
page.refresh()
assert page.sections[index][1].table.rowCount() == 0
win.close()
win.deleteLater()
def test_chart_handles_losses(qapp):
"""Убыток — нормальная величина, столбик должен уходить вниз от нуля."""
from decimal import Decimal as D
from app.ui.chart import Bar, BarChart
chart = BarChart()
chart.set_bars([Bar("янв", D("100")), Bar("фев", D("-40")), Bar("мар", D("0"))])
chart.resize(400, 200)
chart.grab() # отрисовка не должна падать
assert len(chart.bars) == 3
chart.deleteLater()
def test_chart_handles_all_zeroes(qapp):
from decimal import Decimal as D
from app.ui.chart import Bar, BarChart
chart = BarChart()
chart.set_bars([Bar("янв", D("0")), Bar("фев", D("0"))])
chart.resize(400, 200)
chart.grab()
chart.deleteLater()
def test_empty_chart_says_so(qapp):
from app.ui.chart import BarChart
chart = BarChart()
chart.resize(400, 200)
chart.grab()
assert chart.bars == []
chart.deleteLater()
# --- чаевые ---------------------------------------------------------------
def test_overpayment_moves_into_tips(window, buns):
"""Булка 70, дали 100 — разница должна уехать в чаевые сама."""
dialog = SaleDialog(window)
editor = dialog.lines
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id)
editor.table.cellWidget(0, editor.COL_QTY).set_decimal(2)
dialog.paid.set_decimal(100)
dialog._absorb_overpayment()
assert dialog.paid.value_decimal() == Decimal("70.00")
assert dialog.tip.value_decimal() == Decimal("30.00")
assert "чаевые" in dialog.paid_hint.text().lower()
dialog.deleteLater()
def test_underpayment_is_left_as_debt(window, buns):
"""Недоплата — это долг, а не отрицательные чаевые."""
dialog = SaleDialog(window)
editor = dialog.lines
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id)
editor.table.cellWidget(0, editor.COL_QTY).set_decimal(2)
dialog.paid.set_decimal(40)
dialog._absorb_overpayment()
assert dialog.paid.value_decimal() == Decimal("40.00")
assert dialog.tip.value_decimal() == 0
assert "долг" in dialog.paid_hint.text().lower()
dialog.deleteLater()
def test_consumption_disables_tips(window, buns):
dialog = SaleDialog(window)
dialog.kind.setCurrentIndex(2) # съел сам
assert dialog.tip.isEnabled() is False
assert dialog.tip.value_decimal() == 0
dialog.deleteLater()
def test_existing_sale_can_have_its_tip_edited(window, buns):
"""Сдачу могли оставить и после того, как продажу занесли."""
sale = window.vault.doc.sales[0]
journal.set_sale_tip(window.vault, sale.id, 15)
window.changed()
dialog = SaleDialog(window, sale)
assert dialog.tip.value_decimal() == Decimal("15.00")
assert dialog.paid is None # платежи правятся на экране долгов
dialog.deleteLater()
def test_sales_table_shows_tips(window, buns):
sale = window.vault.doc.sales[0]
journal.set_sale_tip(window.vault, sale.id, 25)
window.changed()
page = page_of(window, "Продажи")
page.refresh()
tips = [page.table.item(r, 6).text() for r in range(page.table.rowCount())]
assert any("25,00" in t for t in tips)
# У продаж без чаевых колонка пустая, а не «0,00 ₽».
assert "" in tips
def test_dashboard_mentions_tips_only_when_there_are_any(window, buns):
from PySide6.QtWidgets import QLabel
page = page_of(window, "Сводка")
page.refresh()
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
assert "чаевые" not in texts.lower()
journal.set_sale_tip(window.vault, window.vault.doc.sales[0].id, 40)
window.changed()
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
assert "чаевые 40,00 ₽" in texts.lower()
# --- дробные количества ---------------------------------------------------
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 = page_of(window, "Продажи")
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)
# --- настройки ------------------------------------------------------------
def test_settings_shows_database_facts(window):
page = page_of(window, "Настройки")
page.refresh()
assert "товаров 3" in page.db_info.text()
assert "закупок 1" in page.db_info.text()
def test_settings_saves_git_without_wiping_the_token(window):
"""Пустое поле токена означает «не трогать», а не «стереть»."""
page = page_of(window, "Настройки")
journal.update_settings(window.vault, git={"token": "glpat-keepme"})
page.refresh()
page.remote.setText("https://gt.ser.gay/kizya/food-market.git")
page.token.clear()
page.save_git()
assert window.vault.doc.settings.git.token == "glpat-keepme"
assert window.vault.doc.settings.git.remote_url == "https://gt.ser.gay/kizya/food-market.git"
def test_conflict_block_is_hidden_until_there_is_one(window):
page = page_of(window, "Настройки")
page.refresh()
assert page.conflict_box.isVisibleTo(page) is False
window.conflict_message = "версии разошлись"
page.refresh()
assert page.conflict_box.isVisibleTo(page) is True