410 lines
14 KiB
Python
410 lines
14 KiB
Python
"""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 # без количества
|