diff --git a/src/currate/cache.py b/src/currate/cache.py index 31052dc..bdf5875 100644 --- a/src/currate/cache.py +++ b/src/currate/cache.py @@ -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: @@ -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) diff --git a/src/currate/currency_converter.py b/src/currate/currency_converter.py index c23080b..f8ab75c 100644 --- a/src/currate/currency_converter.py +++ b/src/currate/currency_converter.py @@ -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}" # Валидация суммы @@ -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() @@ -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) @@ -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() @@ -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 = ( @@ -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] + + 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 diff --git a/src/currate/gui.py b/src/currate/gui.py index d23c40a..c49e4f6 100644 --- a/src/currate/gui.py +++ b/src/currate/gui.py @@ -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 @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4b807b4 --- /dev/null +++ b/tests/conftest.py @@ -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, + ) diff --git a/tests/test_cache_extended.py b/tests/test_cache_extended.py index f57a903..ad5af34 100644 --- a/tests/test_cache_extended.py +++ b/tests/test_cache_extended.py @@ -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) diff --git a/tests/test_currency_converter_extended.py b/tests/test_currency_converter_extended.py index 165528f..ea2e0e5 100644 --- a/tests/test_currency_converter_extended.py +++ b/tests/test_currency_converter_extended.py @@ -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): """Тест конвертации с использованием кэша.""" @@ -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) @@ -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")