Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/currate/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ def get(self, currency: str, date: str) -> Optional[float]:
Returns:
float: Курс валюты или None, если запись не найдена или устарела.
"""
# Удаляем устаревшие записи, чтобы TTL работал даже без прямого доступа к ключам
self.cleanup_expired()
key = (currency, date)

if key not in self._cache:
Expand Down Expand Up @@ -74,6 +76,9 @@ def set(self, currency: str, date: str, rate: float) -> None:
"""
key = (currency, date)

# Перед добавлением чистим устаревшие записи, чтобы не накапливать мусор
self.cleanup_expired()

# Если ключ уже есть - обновляем и перемещаем в конец
if key in self._cache:
self._cache.pop(key)
Expand Down
63 changes: 54 additions & 9 deletions src/currate/currency_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ def convert(
Если успешно - (результат, курс, None),
если ошибка - (None, None, сообщение).
"""
currency = self._normalize_currency(from_currency)

# Валидация валюты
if from_currency not in self.SUPPORTED_CURRENCIES:
if currency is None or currency not in self.SUPPORTED_CURRENCIES:
return None, None, f"Неподдерживаемая валюта: {from_currency}"

# Валидация суммы
Expand All @@ -67,18 +69,18 @@ def convert(
# Попытка получить курс из кэша
rate = None
if self._use_cache and self._cache is not None:
rate = self._cache.get(from_currency, date)
rate = self._cache.get(currency, date)

# Если в кэше нет, загружаем с сайта ЦБ РФ
if rate is None:
try:
rate = get_currency_rate(from_currency, date)
rate = get_currency_rate(currency, date)
if rate is None:
return None, None, "Не удалось получить курс валюты"

# Сохраняем в кэш
if self._use_cache and self._cache is not None:
self._cache.set(from_currency, date, rate)
self._cache.set(currency, date, rate)

except CBRParserError as e:
return None, None, e.get_user_message()
Expand All @@ -102,7 +104,8 @@ def get_rate(
Returns:
Tuple[float | None, str | None]: (курс, сообщение об ошибке).
"""
if currency not in self.SUPPORTED_CURRENCIES:
normalized_currency = self._normalize_currency(currency)
if normalized_currency is None or normalized_currency not in self.SUPPORTED_CURRENCIES:
return None, f"Неподдерживаемая валюта: {currency}"

validation_error = self._validate_date(date)
Expand All @@ -112,16 +115,16 @@ def get_rate(
# Проверяем кэш
rate = None
if self._use_cache and self._cache is not None:
rate = self._cache.get(currency, date)
rate = self._cache.get(normalized_currency, date)

if rate is None:
try:
rate = get_currency_rate(currency, date)
rate = get_currency_rate(normalized_currency, date)
if rate is None:
return None, "Не удалось получить курс валюты"

if self._use_cache and self._cache is not None:
self._cache.set(currency, date, rate)
self._cache.set(normalized_currency, date, rate)

except CBRParserError as e:
return None, e.get_user_message()
Expand Down Expand Up @@ -169,7 +172,8 @@ def format_result(
str: Отформатированная строка результата.
"""
result_in_rub = amount * rate
currency_symbol = "$" if currency == "USD" else "€"
normalized_currency = currency.upper()
currency_symbol = "$" if normalized_currency == "USD" else "€"

# Форматируем: разделитель тысяч - пробел, десятичный разделитель - запятая
result_str = (
Expand All @@ -181,3 +185,44 @@ def format_result(
)

return result_str

@staticmethod
def parse_amount(amount_str: str) -> Optional[float]:
"""
Нормализует строку суммы и преобразует ее в float.

Убирает пробелы/неразрывные пробелы и разделители тысяч (пробел/точка/апостроф),
заменяет запятую на точку. Если точек несколько, оставляет последнюю как
десятичный разделитель.
"""
if amount_str is None:
return None

cleaned = (
amount_str.strip()
.replace('\u00A0', '')
.replace('\u202F', '')
.replace(' ', '')
.replace('_', '')
.replace("'", '')
)
if not cleaned:
return None

cleaned = cleaned.replace(',', '.')
if cleaned.count('.') > 1:
parts = cleaned.split('.')
cleaned = ''.join(parts[:-1]) + '.' + parts[-1]
Comment on lines +212 to +215

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse multi-group amounts without adding phantom decimals

The new parse_amount normalization treats any string with more than one separator as if the last separator were a decimal point (cleaned = ''.join(parts[:-1]) + '.' + parts[-1]), so an amount like "1.234.567"/"1,234,567" is parsed as 1234.567 instead of 1234567. This silently shrinks large integer inputs by three orders of magnitude rather than rejecting them, so conversions for million-scale values entered with standard thousand grouping will be wrong.

Useful? React with 👍 / 👎.


try:
return float(cleaned)
except ValueError:
return None

@staticmethod
def _normalize_currency(currency: str) -> Optional[str]:
"""Возвращает код валюты в верхнем регистре или None, если строка пустая."""
if currency is None:
return None
normalized = currency.strip().upper()
return normalized if normalized else None
51 changes: 42 additions & 9 deletions src/currate/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
Содержит классы для создания и управления GUI на базе Tkinter.
"""

import threading
import tkinter as tk
from typing import Optional
from datetime import datetime
from tkinter import ttk, messagebox

Expand Down Expand Up @@ -149,24 +151,55 @@ def _on_convert(self) -> None:
"""Обработчик нажатия кнопки конвертации."""
# Получаем данные из полей
date = self.date_entry.get()
currency = self.currency_var.get()
amount_str = self.amount_entry.get().replace(',', '.')
currency = self.currency_var.get().strip()
normalized_currency = currency.upper()
amount_str = self.amount_entry.get()

# Валидация суммы
try:
amount = float(amount_str)
except ValueError:
amount = CurrencyConverter.parse_amount(amount_str)
if amount is None:
self._show_error("Некорректное значение суммы")
return

# Выполняем конвертацию
result, rate, error = self.converter.convert(amount, currency, date)
# Пока идет запрос, блокируем кнопку, чтобы не подвесить окно многократными вызовами
self.copy_button.config(state=tk.DISABLED)
self.convert_button.config(state=tk.DISABLED)
self.result_label.config(text="Получаю курс...")

worker = threading.Thread(
target=self._perform_conversion,
args=(amount, normalized_currency, date),
daemon=True
)
worker.start()

def _perform_conversion(self, amount: float, currency: str, date: str) -> None:
"""Выполняет конвертацию в фоновой нити, чтобы не блокировать GUI."""
try:
result, rate, error = self.converter.convert(amount, currency, date)
except Exception as exc: # Перехватываем неожиданные ошибки, чтобы не оставлять кнопку заблокированной
error = f"Не удалось выполнить конвертацию: {exc}"
result, rate = None, None

self.root.after(
0,
lambda: self._finish_conversion(amount, currency, result, rate, error)
)

def _finish_conversion(
self,
amount: float,
currency: str,
result: Optional[float],
rate: Optional[float],
error: Optional[str]
) -> None:
"""Обновляет UI после завершения фонового запроса."""
self.convert_button.config(state=tk.NORMAL)

if error:
self._show_error(error)
return

# Отображаем результат
if result is not None and rate is not None:
formatted_result = self.converter.format_result(amount, rate, currency)
self.result_label.config(text=formatted_result)
Expand Down
43 changes: 43 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""
Утилиты для окружения тестов.

Главная задача файла — не дать pytest упасть, если pytest-cov не установлен,
но в pytest.ini присутствуют покрывающие опции (--cov и др.). Мы регистрируем
заглушки, чтобы тесты могли запускаться «из коробки», даже без дополнений.
"""

import importlib.util
import warnings


def _cov_plugin_available() -> bool:
"""Есть ли установленный pytest-cov (независимо от автозагрузки)."""
return importlib.util.find_spec("pytest_cov") is not None


def pytest_addoption(parser) -> None:
"""
Регистрирует заглушки для опций покрытия, если pytest-cov недоступен.

Это позволяет запускать тесты даже в окружениях без dev-зависимостей.
"""
if _cov_plugin_available():
return

cov_group = parser.getgroup(
"cov",
"coverage reporting (no-op без pytest-cov)"
)
cov_group.addoption("--cov", action="append", default=[])
cov_group.addoption("--cov-report", action="append", default=[])
cov_group.addoption("--cov-fail-under", action="store", type=float, default=None)


def pytest_configure(config) -> None:
"""Выводим предупреждение, если pytest-cov не найден."""
if config.pluginmanager.hasplugin("cov") or _cov_plugin_available():
return
warnings.warn(
"pytest-cov не установлен: опции покрытия из pytest.ini будут пропущены",
RuntimeWarning,
)
17 changes: 17 additions & 0 deletions tests/test_cache_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ def test_cache_cleanup_expired_partial():
assert removed_count >= 0 # Может быть 0, если записи не устарели


def test_cache_cleanup_triggered_on_set():
"""Тест, что устаревшие записи удаляются при добавлении новых."""
cache = CurrencyCache(max_size=10, ttl_hours=1)
cache.set("USD", "01.12.2024", 95.5)

# Перематываем время, чтобы первая запись устарела
with patch('src.currate.cache.datetime') as mock_datetime:
mock_datetime.now.return_value = datetime.now() + timedelta(hours=2)
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

# Добавление новой записи должно почистить устаревшую
cache.set("EUR", "01.12.2024", 105.0)

assert cache.size() == 1
assert cache.get("EUR", "01.12.2024") == 105.0


def test_cache_eviction_lru_behavior():
"""Тест поведения вытеснения (подготовка к LRU)."""
cache = CurrencyCache(max_size=3, ttl_hours=24)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_currency_converter_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ def test_convert_eur_success(mock_get_rate):
assert error is None


@patch('src.currate.currency_converter.get_currency_rate')
def test_convert_accepts_lowercase_currency(mock_get_rate):
"""Тест регистронезависимого кода валюты при конвертации."""
mock_get_rate.return_value = 95.5

converter = CurrencyConverter(use_cache=False)
result, rate, error = converter.convert(100.0, "usd", "01.12.2024")

assert result == 9550.0
assert rate == 95.5
assert error is None
mock_get_rate.assert_called_once_with("USD", "01.12.2024")


@patch('src.currate.currency_converter.get_currency_rate')
def test_convert_with_cache(mock_get_rate):
"""Тест конвертации с использованием кэша."""
Expand Down Expand Up @@ -179,6 +193,19 @@ def test_get_rate_success():
assert error is None


def test_get_rate_accepts_mixed_case():
"""Тест получения курса с любым регистром кода валюты."""
with patch('src.currate.currency_converter.get_currency_rate') as mock_get_rate:
mock_get_rate.return_value = 105.0

converter = CurrencyConverter(use_cache=False)
rate, error = converter.get_rate("eUr", "01.12.2024")

assert rate == 105.0
assert error is None
mock_get_rate.assert_called_once_with("EUR", "01.12.2024")


def test_get_rate_unsupported_currency():
"""Тест получения курса для неподдерживаемой валюты."""
converter = CurrencyConverter(use_cache=False)
Expand All @@ -188,6 +215,28 @@ def test_get_rate_unsupported_currency():
assert "Неподдерживаемая валюта" in error


@pytest.mark.parametrize(
"raw,expected",
[
("1 234,56", 1234.56),
("1.234,56", 1234.56),
("1,234.56", 1234.56),
("2\u202f345", 2345.0), # тонкий пробел
(" 500 ", 500.0),
("1_000", 1000.0),
],
)
def test_parse_amount_normalizes_grouping(raw, expected):
"""Тест нормализации суммы с разделителями тысяч и пробелами."""
assert CurrencyConverter.parse_amount(raw) == expected


@pytest.mark.parametrize("raw", ["", "abc", None]) # type: ignore[list-item]
def test_parse_amount_invalid_values(raw):
"""Тест обработки невалидных строк при парсинге суммы."""
assert CurrencyConverter.parse_amount(raw) is None


def test_validate_date_past():
"""Тест валидации даты в прошлом."""
error = CurrencyConverter._validate_date("01.01.2020")
Expand Down
Loading