Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""Дымовые тесты интерфейса: окно собирается, экраны рисуются, формы отдают данные.
|
||
|
||
Гоняются в 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
|