"""Дымовые тесты интерфейса: окно собирается, экраны рисуются, формы отдают данные. Гоняются в 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, 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