food-market/tests/test_ui_smoke.py
Claude bc868f5af1 Git-операции всегда над папкой данных, а не над корнем приложения
Приложение создавало GitSync от paths.app_root(). Пока база лежала внутри
репозитория с кодом, это работало; после разделения репозиториев — нет.
exe лежит в корне клона с исходниками, поэтому приложение распоряжалось
репозиторием кода: переписало ему origin на food-records и попыталось
отправить туда исходники. Пуш спас только отказ по fast-forward.

Теперь GitSync создаётся в одном месте — MainWindow._git() — и всегда
от paths.data_dir(). Тест проверяет и цель, и то, что других мест
создания GitSync в файле не осталось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:30:42 +03:00

582 lines
21 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 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 test_window_builds_with_all_pages(window):
titles = [title for title, _ in window.pages]
assert titles == ["Сводка", "Закупки", "Продажи", "Долги", "Товары", "Журнал", "Настройки"]
assert window.stack.count() == 7
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 = window.pages[1][1]
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 = window.pages[1][1]
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 = window.pages[2][1]
page.refresh()
assert page.table.rowCount() == 3
def test_sales_filter_narrows_the_list(window):
page = window.pages[2][1]
page.filter.setCurrentIndex(page.filter.findData(KIND_SELF))
page.refresh()
assert page.table.rowCount() == 1
def test_debts_tree_groups_by_person(window):
page = window.pages[3][1]
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 = window.pages[4][1]
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 = window.pages[4][1]
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 = window.pages[0][1]
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 = window.pages[0][1]
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 = window.pages[5][1]
page.refresh()
assert page.table.rowCount() == len(window.vault.doc.journal)
def test_journal_search_filters(window, buns):
page = window.pages[5][1]
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 = window.pages[5][1]
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 = window.pages[5][1]
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_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_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"), "retail_price": Decimal("200.00")}
]
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 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)
# --- настройки ------------------------------------------------------------
def test_settings_shows_database_facts(window):
page = window.pages[6][1]
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 = window.pages[6][1]
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 = window.pages[6][1]
page.refresh()
assert page.conflict_box.isVisibleTo(page) is False
window.conflict_message = "версии разошлись"
page.refresh()
assert page.conflict_box.isVisibleTo(page) is True