food-records/tests/test_ui_smoke.py
Claude c8b9f5048b Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ,
зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит
в data/vault.fmdb и раз в час уезжает в этот репозиторий.

Основное:
- партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж
  по партиям и прогресс покрытия к сроку;
- типы выбытия: розница, другу по себестоимости, съел сам, подарок,
  списание — съеденное вычитается из прибыли, за него платить всё равно;
- долги контрагентов с частичными оплатами;
- номенклатура с историей изменения цен;
- журнал изменений внутри базы: git хранит непрозрачные снимки,
  поэтому настоящая история ведётся здесь.

Синхронизация коммитит только путь базы и схлопывает часовые пуши
в один коммит на день через amend + force-with-lease.

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

392 lines
14 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_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, 4).text() # 35 20
assert page.table.item(row, 5).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 # долг Васи
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):
assert price_source_for(KIND_RETAIL)(buns["мак"]) == Decimal("35.00")
assert price_source_for(KIND_FRIEND)(buns["мак"]) == Decimal("20.00")
assert price_source_for(KIND_SELF)(buns["мак"]) == 0
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_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_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