"""UI без браузера: streamlit.testing.v1.AppTest гоняет main.py и страницы в этом же процессе. БД — временный SQLite (фикстура session_local), MOEX/ЦБ — фейки на фикстурах; сеть не нужна.""" from __future__ import annotations from datetime import date from pathlib import Path import pytest from streamlit.testing.v1 import AppTest from app.integrations.cbr import parse_dynamic from app.services import fx_rates, market_data, securities from tests.unit.fakes import FakeCbr ROOT = Path(__file__).resolve().parents[2] USER = "analyst@rshb.ru" TIMEOUT = 60 def _logged_in(at: AppTest) -> AppTest: """Профиль для mock-провайдера (LOCATION=local), как если бы его ввели в форме.""" at.session_state["local_user_email"] = USER at.session_state["local_username"] = "analyst" at.session_state["local_full_name"] = "Аналитик ДРРК" return at def _securities_page() -> None: from app.ui.pages import securities as page page.render() def _market_page() -> None: from app.ui.pages import market_data as page page.render() @pytest.fixture(autouse=True) def _local_mode(monkeypatch): monkeypatch.setenv("LOCATION", "local") monkeypatch.delenv("OPENBAO__SETTINGS__LOCATION", raising=False) @pytest.fixture def fake_moex_from_settings(monkeypatch, fixture_moex): """Сервисы создают клиента через MoexClient.from_settings() — подменяем.""" class _Factory: @staticmethod def from_settings(): return fixture_moex monkeypatch.setattr(securities, "MoexClient", _Factory) monkeypatch.setattr(market_data, "MoexClient", _Factory) return fixture_moex def test_main_without_profile_shows_local_login_form(session_local) -> None: at = AppTest.from_file(str(ROOT / "main.py"), default_timeout=TIMEOUT).run() assert not at.exception assert [w.label for w in at.text_input] == ["Email", "Username", "Full name"] def test_main_renders_default_page_for_logged_in_user(session_local) -> None: at = _logged_in(AppTest.from_file(str(ROOT / "main.py"), default_timeout=TIMEOUT)) at.run() assert not at.exception assert [h.value for h in at.header] == ["Бумаги"] assert at.session_state["user_profile"]["email"] == USER assert any("Бумаг пока нет" in i.value for i in at.info) def test_securities_page_lookup_preview_and_save( session_local, fake_moex_from_settings ) -> None: at = _logged_in(AppTest.from_function(_securities_page, default_timeout=TIMEOUT)) at.session_state["user_profile"] = {"email": USER} at.run() at.text_input[0].input("ru000a106169") at.button[0].click().run() # «Найти на Мосбирже» (submit формы) assert not at.exception assert not at.error, [e.value for e in at.error] assert any("РЭО-02" in m.value for m in at.markdown) save = next(b for b in at.button if b.label == "Сохранить") save.click().run() assert not at.exception assert not at.error, [e.value for e in at.error] table = at.dataframe[0].value assert list(table["ISIN"]) == ["RU000A106169"] assert list(table["Наименование"]) == ["РЭО-02"] assert list(table["Валюта"]) == ["RUB"] def test_securities_page_unknown_isin_shows_error( session_local, fake_moex_from_settings ) -> None: at = AppTest.from_function(_securities_page, default_timeout=TIMEOUT) at.session_state["user_profile"] = {"email": USER} at.run() at.text_input[0].input("RU000A999999") at.button[0].click().run() assert not at.exception assert any("не найдена на Мосбирже" in e.value for e in at.error) def test_market_page_update_buttons_and_tables( session_local, fake_moex_from_settings, fixtures_dir: Path, monkeypatch ) -> None: from app.db.base import run_db run_db(securities.add_by_isin("RU000A106169", USER, moex=fake_moex_from_settings)) cbr = FakeCbr(parse_dynamic((fixtures_dir / "cbr_dynamic_R01375.xml").read_bytes())) class _CbrFactory: @staticmethod def from_settings(): return cbr monkeypatch.setattr(fx_rates, "CbrClient", _CbrFactory) at = AppTest.from_function(_market_page, default_timeout=TIMEOUT) at.session_state["user_profile"] = {"email": USER} at.run() at.date_input[0].set_value(date(2026, 10, 8)).run() assert not at.exception assert any("котировок в базе нет" in i.value for i in at.info) next(b for b in at.button if b.label == "Обновить котировки").click().run() assert not at.exception assert any("Обновлено: 1" in s.value for s in at.success) quotes = at.dataframe[0].value assert list(quotes["Чистая цена, %"]) == ["101.42"] assert list(quotes["Источник цены"]) == ["WAPRICE"] next(b for b in at.button if b.label == "Обновить курс CNY").click().run() assert not at.exception assert any("Сохранено курсов CNY: 6" in s.value for s in at.success) rates = at.dataframe[1].value assert list(rates["За 1 CNY"])[0] == "12.7516" # --- загрузка сделок QUIK --------------------------------------------------- # AppTest не умеет загружать файлы в file_uploader, поэтому кладём результат # разбора в session_state так же, как это делает on_change загрузчика. def _quik_page() -> None: from app.ui.pages import quik_import as page page.render() def _quik_app(upload) -> AppTest: # noqa: ANN001 at = AppTest.from_function(_quik_page, default_timeout=TIMEOUT) at.session_state["user_profile"] = {"email": USER} if upload is not None: at.session_state["quik_upload"] = upload return at.run() def _fixture_upload(fixtures_dir: Path, comments: list[str] | None = None): from dataclasses import replace from app.imports.quik import parse_quik_xlsx from app.ui.pages.quik_import import QuikUpload result = parse_quik_xlsx( (fixtures_dir / "quik_sample_300326_anon.xlsx").read_bytes() ) if comments is not None: result.trades = [ replace(t, comment=c) for t, c in zip(result.trades, comments, strict=True) ] return QuikUpload("сделки_300326.xlsx", result) def _add_fixture_security(session_local) -> None: from app.db.base import run_db from app.db.models import Security async def _add() -> None: async with session_local() as session: session.add( Security( isin="RU000A10EVH2", secid="RU000A10EVH2", name="ЕврХол3P06", currency_id=1, created_by=USER, updated_by=USER, ) ) await session.commit() run_db(_add()) def test_quik_page_without_file(session_local) -> None: at = _quik_app(None) assert not at.exception assert [h.value for h in at.header] == ["Загрузка сделок QUIK"] assert not at.info and not at.button def test_quik_page_format_error_is_shown(session_local) -> None: from app.ui.pages.quik_import import QuikUpload at = _quik_app(QuikUpload("old.xls", None, "Не удалось открыть файл как .xlsx.")) assert not at.exception assert [e.value for e in at.error] == ["Не удалось открыть файл как .xlsx."] def test_quik_page_preview_of_real_fixture(session_local, fixtures_dir: Path) -> None: _add_fixture_security(session_local) at = _quik_app(_fixture_upload(fixtures_dir)) assert not at.exception assert not at.error, [e.value for e in at.error] assert at.selectbox[0].value == "SALES" # по комментарию /pp assert [m.value for m in at.metric] == ["6", "0", "0", "6"] rows = at.dataframe[0].value assert list(rows["Строка"]) == [2, 3, 4, 5, 6, 7] assert set(rows["ISIN"]) == {"RU000A10EVH2"} assert list(rows["Комиссия ТС"])[0] == "8217.6" totals = at.dataframe[1].value assert list(totals["Операция"]) == ["BUY", "SELL"] assert list(totals["Кол-во"]) == ["1098000", "1080000"] assert any("Файл проверен: 6 строк в SALES" in s.value for s in at.success) assert at.button[0].label == "Загрузить в SALES: 6 стр." assert not at.button[0].disabled def test_quik_page_saves_operations(session_local, fixtures_dir: Path) -> None: from sqlalchemy import func, select from app.db.base import run_db from app.db.models import ImportBatch, Operation _add_fixture_security(session_local) at = _quik_app(_fixture_upload(fixtures_dir)) at.button[0].click().run() assert not at.exception assert not at.error, [e.value for e in at.error] # После st.rerun() AppTest оставляет в дереве элементы прерванного прогона, # поэтому проверяем первый элемент страницы, а не весь список. assert at.success[0].value == ( "«сделки_300326.xlsx»: загружено в SALES 6 стр., исключено 0." ) assert "quik_upload" not in at.session_state # файл сброшен assert at.session_state["quik_file_nonce"] == 1 # поле загрузки — новое async def _counts() -> tuple[int, int]: async with session_local() as session: return ( await session.scalar(select(func.count(ImportBatch.id))), await session.scalar(select(func.count(Operation.id))), ) assert run_db(_counts()) == (1, 6) def test_quik_page_explains_why_save_is_disabled( session_local, fixtures_dir: Path ) -> None: at = _quik_app(_fixture_upload(fixtures_dir)) # бумаги нет в справочнике assert not at.exception assert at.button[0].disabled assert any( "Загрузить нельзя: нет в справочнике бумаг: ЕврХол3P06" in c.value for c in at.caption ) def test_quik_page_other_portfolio_and_unknown_rows( session_local, fixtures_dir: Path ) -> None: _add_fixture_security(session_local) upload = _fixture_upload(fixtures_dir, ["/pp", "/pp", "/uw", "/trde", "", "/pp"]) at = _quik_app(upload) assert not at.exception assert any("Строки без портфеля: 2" in s.value for s in at.subheader) assert any("Ждут решения строк без портфеля: 2" in i.value for i in at.info) assert not at.success at.radio[0].set_value("Исключить все").run() assert not at.exception assert any("Пойдёт в SALES: 3" in s.value for s in at.subheader) assert any("исключено 2" in s.value for s in at.success) at.radio[0].set_value("Отнести все к SALES").run() assert any("Пойдёт в SALES: 5" in s.value for s in at.subheader) at.selectbox[0].set_value("UW").run() assert not at.exception assert any("Пойдёт в UW: 1" in s.value for s in at.subheader) def test_quik_page_rejects_same_file_same_portfolio( session_local, fixtures_dir: Path ) -> None: from datetime import date from app.db.base import run_db from app.repositories.import_batches import ImportBatchRepository _add_fixture_security(session_local) upload = _fixture_upload(fixtures_dir) async def _loaded() -> None: async with session_local() as session: await ImportBatchRepository(session).add( source="QUIK", portfolio_id=2, # SALES file_name="сделки_300326.xlsx", content_sha256=upload.result.content_hash, date_from=date(2026, 3, 30), date_to=date(2026, 3, 30), rows_total=6, loaded_rows=[1, 2, 3, 4, 5, 6], rows_excluded=0, user=USER, ) await session.commit() run_db(_loaded()) at = _quik_app(upload) assert not at.exception assert any("Этот файл уже загружен в SALES" in e.value for e in at.error) assert [m.value for m in at.metric] == ["6", "0", "6", "0"] assert not at.success # --- ручные операции ------------------------------------------------------- def _manual_page() -> None: from app.ui.pages import manual_operations as page page.render() def _manual_app() -> AppTest: at = AppTest.from_function(_manual_page, default_timeout=TIMEOUT) at.session_state["user_profile"] = {"email": USER} return at.run() def test_manual_page_without_securities(session_local) -> None: at = _manual_app() assert not at.exception assert any("Бумаг пока нет" in i.value for i in at.info) assert any("Ручных операций пока нет" in c.value for c in at.caption) def test_manual_page_adds_coupon(session_local) -> None: _add_fixture_security(session_local) at = _manual_app() assert at.selectbox[0].value == "CPN" at.text_input[0].input("125 000,50") at.text_area[0].input("купон №3") at.button[0].click().run() assert not at.exception assert not at.error, [e.value for e in at.error] assert any("Сохранено: купон, RU000A10EVH2" in s.value for s in at.success) rows = at.dataframe[0].value assert list(rows["Операция"]) == ["Купон"] assert list(rows["Сумма"]) == ["125000.5"] assert list(rows["Валюта"]) == ["RUB"] assert list(rows["Кто ввёл"]) == [USER] def test_manual_page_validation_and_uwfee_portfolio(session_local) -> None: _add_fixture_security(session_local) at = _manual_app() at.button[0].click().run() # купон без суммы assert [e.value for e in at.error] == ["Укажите сумму."] at.selectbox[0].set_value("UWFEE").run() assert not at.exception assert at.selectbox[1].options == ["UW — Андеррайтинг"] assert len(at.text_input) == 1 # без количества