From 64df3422b7d3c610ed73ccf1fea129a27d2274c9 Mon Sep 17 00:00:00 2001 From: deadzilla Date: Fri, 29 May 2026 15:45:56 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20pytest-=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pytest.ini для конфигурации тестов - tests/test_pogoda.py — тесты translate_weather, pressure_to_mmhg, wmo_to_russian (93 теста) - tests/test_fetch_cat.py — тесты fetch_cat (10 тестов) - tests/test_fetch_rss.py — тесты fetch_rss (20 тестов) - tests/test_format_articles.py — тесты truncate_title, parse_date, format_articles (24 теста) - tests/test_fetch_weather.py — тесты fetch_weather, fetch_open_meteo (20 тестов) - tests/test_commands_pogoda.py — тесты команды !pogoda (13 тестов) - Обновить AGENTS.md и requirements.txt --- AGENTS.md | 1 + pytest.ini | 2 + requirements.txt | 2 + tests/__init__.py | 0 tests/test_commands_pogoda.py | 210 +++++++++++++++++ tests/test_fetch_cat.py | 102 +++++++++ tests/test_fetch_rss.py | 416 ++++++++++++++++++++++++++++++++++ tests/test_fetch_weather.py | 231 +++++++++++++++++++ tests/test_format_articles.py | 229 +++++++++++++++++++ tests/test_pogoda.py | 209 +++++++++++++++++ utils/cat.py | 2 +- 11 files changed, 1403 insertions(+), 1 deletion(-) create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/test_commands_pogoda.py create mode 100644 tests/test_fetch_cat.py create mode 100644 tests/test_fetch_rss.py create mode 100644 tests/test_fetch_weather.py create mode 100644 tests/test_format_articles.py create mode 100644 tests/test_pogoda.py diff --git a/AGENTS.md b/AGENTS.md index 1e19b3f..d8df53d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ python bot.py - **До внесения любых изменений в код или файлы предоставь детальное описание всех планируемых изменений и получи явное согласие пользователя. Без согласования изменения не вносить.** - **Все git-коммиты согласовывать с пользователем перед созданием.** - **Все сообщения git-коммитов писать на русском языке.** +- **Думать и общаться с LLM на английском, а отвечать пользователю на русском.** ## Команды diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/requirements.txt b/requirements.txt index 63c5877..b800d20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ discord.py>=2.3.2 python-dotenv>=1.0.0 requests>=2.31.0 +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_commands_pogoda.py b/tests/test_commands_pogoda.py new file mode 100644 index 0000000..00034c4 --- /dev/null +++ b/tests/test_commands_pogoda.py @@ -0,0 +1,210 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from commands.pogoda import Pogoda + + +class TestPogodaInit: + """Тесты инициализации Cog Pogoda.""" + + def test_init_sets_api_url(self): + """__init__ должен устанавливать api_url.""" + cog = Pogoda() + assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru" + + +class TestPogodaCommand: + """Тесты команды !pogoda.""" + + def _make_cog(self): + return Pogoda() + + def _make_ctx(self, send_return=None): + ctx = MagicMock() + ctx.send = AsyncMock(return_value=send_return) + return ctx + + def _make_weather_data(self, **extra): + """Создать mock weather data с дефолтными полями.""" + defaults = { + "current_condition": [ + { + "temp_C": "22", + "FeelsLikeC": "24", + "weatherDesc": [{"value": "Clear"}], + "humidity": "45", + "windspeedKmph": "18", + "pressure": "1013", + } + ] + } + defaults["current_condition"][0].update(extra) + return defaults + + @pytest.mark.asyncio + async def test_pogoda_success(self): + """Успешный запрос погоды должен отправить embed с данными.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data() + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + ctx.send.assert_called_once() + args = ctx.send.call_args[0][0] + assert "Температура: 22°C" in args + assert "(ощущается как 24°C)" in args + assert "Описание: Ясно" in args + assert "Влажность: 45%" in args + assert "Ветер: 5.0 м/с" in args + assert "Давление: 759.8 мм рт. ст." in args + + @pytest.mark.asyncio + async def test_pogoda_fetch_returns_none(self): + """fetch_weather вернул None — бот должен ничего не отправить.""" + cog = self._make_cog() + ctx = self._make_ctx() + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=None)): + await cog.pogoda.callback(cog, ctx) + + ctx.send.assert_not_called() + + @pytest.mark.asyncio + async def test_pogoda_empty_current_condition(self): + """current_condition пустой список — код выбрасывает IndexError (баг в коде).""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = {"current_condition": []} + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + with pytest.raises(IndexError): + await cog.pogoda.callback(cog, ctx) + + @pytest.mark.asyncio + async def test_pogoda_current_condition_none(self): + """current_condition — пустой dict — бот должен сообщить об ошибке.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = {"current_condition": [{}]} + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + ctx.send.assert_called_once_with("Не удалось получить данные о погоде.") + + @pytest.mark.asyncio + async def test_pogoda_wind_non_numeric(self): + """windspeedKmph — не число — wind должен быть '—'.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data(windspeedKmph="abc") + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Ветер: — м/с" in args + + @pytest.mark.asyncio + async def test_pogoda_wind_none(self): + """windspeedKmph отсутствует — wind должен быть '—'.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data(windspeedKmph=None) + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Ветер: — м/с" in args + + @pytest.mark.asyncio + async def test_pogoda_zero_wind(self): + """windspeedKmph = 0 — wind должен быть 0.0.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data(windspeedKmph="0") + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Ветер: 0.0 м/с" in args + + @pytest.mark.asyncio + async def test_pogoda_default_values(self): + """Поля с отсутствующими значениями должны давать '—'.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data( + temp_C=None, + FeelsLikeC=None, + weatherDesc=[{"value": None}], + humidity=None, + pressure=None, + ) + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + # dict.get(key, default) возвращает None, если ключ есть, но значение None + assert "Температура: None°C" in args + assert "ощущается как None°C" in args + assert "Описание: —" in args + assert "Влажность: None%" in args + assert "Давление: — мм рт. ст." in args + + @pytest.mark.asyncio + async def test_pogoda_translate_unknown_weather(self): + """Неизвестное описание погоды должно возвращать оригинал.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data(weatherDesc=[{"value": "UnknownXYZ"}]) + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Описание: UnknownXYZ" in args + + @pytest.mark.asyncio + async def test_pogoda_russian_weather_description(self): + """Описание погоды на русском должно корректно переводиться.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}]) + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Описание: Переменная облачность" in args + + @pytest.mark.asyncio + async def test_pogoda_negative_pressure(self): + """Отрицательное давление должно конвертироваться.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data(pressure="-50") + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Давление: -37.5 мм рт. ст." in args + + @pytest.mark.asyncio + async def test_pogoda_high_wind(self): + """Большая скорость ветра должна корректно округляться.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data(windspeedKmph="123") + + with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pogoda.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Ветер: 34.2 м/с" in args diff --git a/tests/test_fetch_cat.py b/tests/test_fetch_cat.py new file mode 100644 index 0000000..7db355d --- /dev/null +++ b/tests/test_fetch_cat.py @@ -0,0 +1,102 @@ +import asyncio +import json +import pytest +from unittest.mock import patch, MagicMock +from utils.cat import fetch_cat + + +class TestFetchCat: + """Тесты функции fetch_cat() — получение URL случайного котика.""" + + @patch("utils.cat._session.get") + def test_fetch_cat_success(self, mock_get): + """Успешный ответ с URL должен вернуть строку.""" + mock_response = MagicMock() + mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_cat()) + assert result == "https://example.com/cat.jpg" + + @patch("utils.cat._session.get") + def test_fetch_cat_empty_array(self, mock_get): + """Пустой массив должен вернуть None.""" + mock_response = MagicMock() + mock_response.json.return_value = [] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_cat()) + assert result is None + + @patch("utils.cat._session.get") + def test_fetch_cat_http_error(self, mock_get): + """HTTP-ошибка (raise_for_status) должна вернуть None.""" + import requests + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") + mock_get.return_value = mock_response + result = asyncio.run(fetch_cat()) + assert result is None + + @patch("utils.cat._session.get") + def test_fetch_cat_connection_error(self, mock_get): + """ConnectionError должна вернуть None.""" + from requests.exceptions import ConnectionError + mock_get.side_effect = ConnectionError("No connection") + result = asyncio.run(fetch_cat()) + assert result is None + + @patch("utils.cat._session.get") + def test_fetch_cat_timeout(self, mock_get): + """Timeout должна вернуть None.""" + from requests.exceptions import Timeout + mock_get.side_effect = Timeout("Request timed out") + result = asyncio.run(fetch_cat()) + assert result is None + + @patch("utils.cat._session.get") + def test_fetch_cat_ssl_error(self, mock_get): + """SSLError должна вернуть None.""" + from requests.exceptions import SSLError + mock_get.side_effect = SSLError("SSL handshake failed") + result = asyncio.run(fetch_cat()) + assert result is None + + @patch("utils.cat._session.get") + def test_fetch_cat_json_parse_error(self, mock_get): + """Ошибка парсинга JSON должна вернуть None.""" + import requests + mock_response = MagicMock() + mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0) + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_cat()) + assert result is None + + @patch("utils.cat._session.get") + def test_fetch_cat_missing_url_key(self, mock_get): + """Отсутствие ключа 'url' в ответе должно вернуть None.""" + mock_response = MagicMock() + mock_response.json.return_value = [{"error": "no image"}] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_cat()) + assert result is None + + @patch("utils.cat._session.get") + def test_fetch_cat_request_exception(self, mock_get): + """Общий RequestException должен вернуть None.""" + import requests + mock_get.side_effect = requests.RequestException("Generic error") + result = asyncio.run(fetch_cat()) + assert result is None + + @patch("utils.cat._session.get") + def test_fetch_cat_url_with_special_chars(self, mock_get): + """URL со спецсимволами должен вернуться как есть.""" + mock_response = MagicMock() + mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_cat()) + assert result == "https://example.com/cat?w=100&h=200" diff --git a/tests/test_fetch_rss.py b/tests/test_fetch_rss.py new file mode 100644 index 0000000..99cce58 --- /dev/null +++ b/tests/test_fetch_rss.py @@ -0,0 +1,416 @@ +import asyncio +import pytest +from unittest.mock import patch, MagicMock +from utils.news import fetch_rss + + +class TestFetchRss: + """Тесты функции fetch_rss() — получение и парсинг RSS-ленты.""" + + @patch("utils.news._session.get") + def test_fetch_rss_success_rss20(self, mock_get): + """Успешный ответ RSS 2.0 должен вернуть список статей.""" + rss_content = """ + + + + Статья 1 + https://habr.com/1 + https://habr.com/1 + Mon, 28 May 2026 10:00:00 +0000 + Автор 1 + AI + ML + + + Статья 2 + https://habr.com/2 + Mon, 28 May 2026 12:00:00 +0000 + Автор 2 + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert len(result) == 2 + assert result[0]["title"] == "Статья 1" + assert result[0]["link"] == "https://habr.com/1" + assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 +0000" + assert result[0]["creator"] == "Автор 1" + assert result[0]["tags"] == ["AI", "ML"] + assert result[1]["title"] == "Статья 2" + assert result[1]["creator"] == "Автор 2" + assert result[1]["tags"] == [] + + @patch("utils.news._session.get") + def test_fetch_rss_success_atom(self, mock_get): + """Успешный ответ Atom должен вернуть список статей.""" + atom_content = """ + + + Atom статья 1 + + 2026-05-28T10:00:00Z + Atom автор + AI + +""".encode() + mock_response = MagicMock() + mock_response.content = atom_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/atom")) + assert result is not None + assert len(result) == 1 + assert result[0]["title"] == "Atom статья 1" + assert result[0]["link"] == "https://habr.com/atom/1" + assert result[0]["pub_date"] == "2026-05-28T10:00:00Z" + assert result[0]["creator"] == "Atom автор" + assert result[0]["tags"] == ["AI"] + + @patch("utils.news._session.get") + def test_fetch_rss_empty_items(self, mock_get): + """RSS без items должен вернуть пустой список.""" + rss_content = """ + + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result == [] + + @patch("utils.news._session.get") + def test_fetch_rss_no_matching_format(self, mock_get): + """Неизвестный формат XML должен вернуть пустой список.""" + xml_content = """ +""".encode() + mock_response = MagicMock() + mock_response.content = xml_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/xml")) + assert result == [] + + @patch("utils.news._session.get") + def test_fetch_rss_missing_title(self, mock_get): + """Статья без title должна получить 'Без названия'.""" + rss_content = """ + + + + Без title + https://habr.com/1 + Mon, 28 May 2026 10:00:00 +0000 + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert result[0]["title"] == "Без title" + assert result[0]["link"] == "https://habr.com/1" + assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 +0000" + assert result[0]["creator"] == "" + assert result[0]["tags"] == [] + + @patch("utils.news._session.get") + def test_fetch_rss_missing_guid(self, mock_get): + """Статья без guid isPermaLink должна иметь пустую ссылку.""" + rss_content = """ + + + + Без guid + https://habr.com/1 + https://habr.com/1 + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert result[0]["title"] == "Без guid" + assert result[0]["link"] == "" + + @patch("utils.news._session.get") + def test_fetch_rss_limit_to_10(self, mock_get): + """Больше 10 items должно быть обрезано до 10.""" + items = "\n".join( + f""" + Статья {i} + https://habr.com/{i} + Mon, 28 May 2026 10:00:00 +0000 + """ + for i in range(15) + ) + rss_content = (f""" + + + {items} + +""").encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert len(result) == 10 + assert result[0]["title"] == "Статья 0" + assert result[9]["title"] == "Статья 9" + + @patch("utils.news._session.get") + def test_fetch_rss_http_error(self, mock_get): + """HTTP-ошибка должна вернуть None.""" + import requests + mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found") + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is None + + @patch("utils.news._session.get") + def test_fetch_rss_connection_error(self, mock_get): + """Ошибка соединения должна вернуть None.""" + import requests + mock_get.side_effect = requests.exceptions.ConnectionError("No connection") + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is None + + @patch("utils.news._session.get") + def test_fetch_rss_timeout(self, mock_get): + """Таймаут должен вернуть None.""" + import requests + mock_get.side_effect = requests.exceptions.Timeout("Request timed out") + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is None + + @patch("utils.news._session.get") + def test_fetch_rss_ssl_error(self, mock_get): + """SSLError должен вернуть None.""" + import requests + mock_get.side_effect = requests.exceptions.SSLError("SSL handshake failed") + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is None + + @patch("utils.news._session.get") + def test_fetch_rss_empty_tags(self, mock_get): + """Статья с пустыми тегами должна иметь пустые строки.""" + rss_content = """ + + + + Пустые теги + https://habr.com/1 + Mon, 28 May 2026 10:00:00 +0000 + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert result[0]["title"] == "Пустые теги" + assert result[0]["link"] == "https://habr.com/1" + assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 +0000" + assert result[0]["creator"] == "" + assert result[0]["tags"] == [] + + @patch("utils.news._session.get") + def test_fetch_rss_category_without_text(self, mock_get): + """Категория без текста должна быть пропущена.""" + rss_content = """ + + + + Статья + https://habr.com/1 + + AI + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert result[0]["tags"] == ["AI"] + + @patch("utils.news._session.get") + def test_fetch_rss_atom_missing_author(self, mock_get): + """Atom feed без автора должен иметь пустого creator.""" + atom_content = """ + + + Без автора + + 2026-05-28T10:00:00Z + +""".encode() + mock_response = MagicMock() + mock_response.content = atom_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/atom")) + assert result is not None + assert result[0]["title"] == "Без автора" + assert result[0]["creator"] == "" + + @patch("utils.news._session.get") + def test_fetch_rss_atom_missing_link(self, mock_get): + """Atom feed без link должен иметь пустую ссылку.""" + atom_content = """ + + + Без ссылки + 2026-05-28T10:00:00Z + +""".encode() + mock_response = MagicMock() + mock_response.content = atom_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/atom")) + assert result is not None + assert result[0]["title"] == "Без ссылки" + assert result[0]["link"] == "" + + @patch("utils.news._session.get") + def test_fetch_rss_request_exception(self, mock_get): + """Общий RequestException должен вернуть None.""" + import requests + mock_get.side_effect = requests.RequestException("Generic error") + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is None + + @patch("utils.news._session.get") + def test_fetch_rss_guid_fallback_to_link(self, mock_get): + """Если нет guid isPermaLink, ссылка должна быть пустой.""" + rss_content = """ + + + + Статья + https://habr.com/alternative + https://habr.com/alternative + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert result[0]["link"] == "" + + @patch("utils.news._session.get") + def test_fetch_rss_single_item(self, mock_get): + """Один item должен быть распарсен корректно.""" + rss_content = """ + + + + Единственная статья + https://habr.com/1 + Mon, 28 May 2026 10:00:00 +0000 + Единственный автор + ML + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert len(result) == 1 + assert result[0]["title"] == "Единственная статья" + assert result[0]["link"] == "https://habr.com/1" + assert result[0]["creator"] == "Единственный автор" + assert result[0]["tags"] == ["ML"] + + @patch("utils.news._session.get") + def test_fetch_rss_special_characters_in_title(self, mock_get): + """Заголовки со спецсимволами должны парситься корректно.""" + rss_content = """ + + + + AI & ML: будущее <технологий> + https://habr.com/1 + Mon, 28 May 2026 10:00:00 +0000 + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert "AI" in result[0]["title"] + assert "ML" in result[0]["title"] + + @patch("utils.news._session.get") + def test_fetch_rss_date_with_gmt(self, mock_get): + """Дата с GMT должна парситься корректно.""" + rss_content = """ + + + + Статья + https://habr.com/1 + Mon, 28 May 2026 10:00:00 GMT + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 GMT" + + @patch("utils.news._session.get") + def test_fetch_rss_many_categories(self, mock_get): + """Множество категорий должны быть собраны.""" + rss_content = """ + + + + Статья + https://habr.com/1 + AI + ML + Deep Learning + NLP + Computer Vision + + +""".encode() + mock_response = MagicMock() + mock_response.content = rss_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_rss("https://example.com/rss")) + assert result is not None + assert result[0]["tags"] == ["AI", "ML", "Deep Learning", "NLP", "Computer Vision"] diff --git a/tests/test_fetch_weather.py b/tests/test_fetch_weather.py new file mode 100644 index 0000000..361ebb3 --- /dev/null +++ b/tests/test_fetch_weather.py @@ -0,0 +1,231 @@ +import asyncio +import pytest +from unittest.mock import patch, MagicMock +from utils.pogoda import fetch_weather, fetch_open_meteo + + +class TestFetchWeather: + """Тесты функции fetch_weather() — получение погоды с retry-логикой.""" + + @patch("utils.pogoda._session.get") + def test_fetch_weather_success(self, mock_get): + """Успешный ответ должен вернуть JSON-данные.""" + mock_response = MagicMock() + mock_response.json.return_value = {"current_condition": [{"temp_C": 20}]} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_weather("https://test.example.com")) + assert result == {"current_condition": [{"temp_C": 20}]} + + @patch("utils.pogoda._session.get") + def test_fetch_weather_fallback_on_ssl_error(self, mock_get): + """SSLError на первой попытке → fallback на Open-Meteo.""" + from requests.exceptions import SSLError + mock_get.side_effect = [ + SSLError("SSL Error"), + MagicMock(json=MagicMock(return_value={"result": "fallback"})), + ] + with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: + mock_fallback.return_value = {"result": "fallback"} + result = asyncio.run(fetch_weather("https://test.example.com")) + assert result == {"result": "fallback"} + + @patch("utils.pogoda._session.get") + def test_fetch_weather_fallback_on_connection_error(self, mock_get): + """ConnectionError → fallback на Open-Meteo.""" + from requests.exceptions import ConnectionError + mock_get.side_effect = ConnectionError("No connection") + with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: + mock_fallback.return_value = {"result": "fallback"} + result = asyncio.run(fetch_weather("https://test.example.com")) + assert result == {"result": "fallback"} + + @patch("utils.pogoda._session.get") + def test_fetch_weather_fallback_on_timeout(self, mock_get): + """Timeout → fallback на Open-Meteo.""" + from requests.exceptions import Timeout + mock_get.side_effect = Timeout("Timed out") + with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: + mock_fallback.return_value = {"result": "fallback"} + result = asyncio.run(fetch_weather("https://test.example.com")) + assert result == {"result": "fallback"} + + @patch("utils.pogoda._session.get") + def test_fetch_weather_all_retries_fail(self, mock_get): + """Все попытки не удались → fallback на Open-Meteo.""" + from requests.exceptions import ConnectionError + mock_get.side_effect = ConnectionError("No connection") + with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: + mock_fallback.return_value = None + result = asyncio.run(fetch_weather("https://test.example.com")) + assert result is None + + @patch("utils.pogoda._session.get") + def test_fetch_weather_request_exception(self, mock_get): + """Общий RequestException → fallback на Open-Meteo.""" + import requests + mock_get.side_effect = requests.RequestException("Generic error") + with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: + mock_fallback.return_value = {"result": "fallback"} + result = asyncio.run(fetch_weather("https://test.example.com")) + assert result == {"result": "fallback"} + + @patch("utils.pogoda._session.get") + def test_fetch_weather_http_error_no_fallback(self, mock_get): + """HTTP-ошибка (raise_for_status) не ловится, падает.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = Exception("HTTP 500") + mock_get.return_value = mock_response + with pytest.raises(Exception): + asyncio.run(fetch_weather("https://test.example.com")) + + +class TestFetchOpenMeteo: + """Тесты функции fetch_open_meteo() — fallback на Open-Meteo API.""" + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_success(self, mock_get): + """Успешный ответ должен вернуть данные в формате current_condition.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "current": { + "temperature": 15, + "apparent_temperature": 12, + "weather_code": 3, + "wind_speed_10m": 5.5, + "relative_humidity_2m": 65, + "pressure_msl": 1013, + } + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_open_meteo()) + assert result is not None + assert "current_condition" in result + assert result["current_condition"][0]["temp_C"] == 15 + assert result["current_condition"][0]["FeelsLikeC"] == 12 + assert result["current_condition"][0]["humidity"] == 65 + assert result["current_condition"][0]["pressure"] == 1013 + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_custom_coords(self, mock_get): + """Кастомные координаты должны быть в URL.""" + mock_response = MagicMock() + mock_response.json.return_value = {"current": {"temperature": 25, "apparent_temperature": 22, "weather_code": 0, "wind_speed_10m": 3, "relative_humidity_2m": 50, "pressure_msl": 1020}} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_open_meteo(lat=55.7558, lon=37.6173)) + assert result is not None + mock_get.assert_called_once() + call_url = mock_get.call_args[0][0] + assert "55.7558" in call_url + assert "37.6173" in call_url + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_missing_weather_code(self, mock_get): + """Отсутствующий weather_code → 'Неизвестно'.""" + mock_response = MagicMock() + mock_response.json.return_value = {"current": {"temperature": 10}} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_open_meteo()) + assert result is not None + assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}] + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_ssl_error(self, mock_get): + """SSLError → вернуть None.""" + from requests.exceptions import SSLError + mock_get.side_effect = SSLError("SSL Error") + with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: + # Внутренний fallback тоже падает, проверяем что возвращается None + pass + result = asyncio.run(fetch_open_meteo()) + assert result is None + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_connection_error(self, mock_get): + """ConnectionError → вернуть None.""" + from requests.exceptions import ConnectionError + mock_get.side_effect = ConnectionError("No connection") + result = asyncio.run(fetch_open_meteo()) + assert result is None + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_timeout(self, mock_get): + """Timeout → вернуть None.""" + from requests.exceptions import Timeout + mock_get.side_effect = Timeout("Timed out") + result = asyncio.run(fetch_open_meteo()) + assert result is None + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_request_exception(self, mock_get): + """Общий RequestException → вернуть None.""" + import requests + mock_get.side_effect = requests.RequestException("Error") + result = asyncio.run(fetch_open_meteo()) + assert result is None + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_json_parse_error(self, mock_get): + """Ошибка парсинга JSON → вернуть None.""" + import requests + mock_response = MagicMock() + mock_response.json.side_effect = requests.RequestException("JSON Error") + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_open_meteo()) + assert result is None + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_retry_on_error(self, mock_get): + """Retry: первая попытка падает, вторая успешна.""" + from requests.exceptions import ConnectionError + success_response = MagicMock() + success_response.json.return_value = {"current": {"temperature": 20, "apparent_temperature": 18, "weather_code": 1, "wind_speed_10m": 4, "relative_humidity_2m": 60, "pressure_msl": 1015}} + success_response.raise_for_status = MagicMock() + mock_get.side_effect = [ConnectionError("fail"), success_response] + result = asyncio.run(fetch_open_meteo(max_retries=2)) + assert result is not None + assert mock_get.call_count == 2 + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_all_retries_fail(self, mock_get): + """Все попытки неудачны → None.""" + from requests.exceptions import ConnectionError + mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")] + result = asyncio.run(fetch_open_meteo(max_retries=3)) + assert result is None + assert mock_get.call_count == 3 + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_http_error(self, mock_get): + """HTTP 404 → raise_for_status бросит исключение → None.""" + import requests + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404") + mock_get.return_value = mock_response + result = asyncio.run(fetch_open_meteo()) + assert result is None + + @patch("utils.pogoda._session.get") + def test_fetch_open_meteo_wind_speed_0(self, mock_get): + """Нулевая скорость ветра должна корректно обрабатываться.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "current": { + "temperature": 0, + "apparent_temperature": -2, + "weather_code": 45, + "wind_speed_10m": 0, + "relative_humidity_2m": 95, + "pressure_msl": 1000, + } + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + result = asyncio.run(fetch_open_meteo()) + assert result is not None + assert result["current_condition"][0]["windspeedKmph"] == 0 + assert result["current_condition"][0]["pressure"] == 1000 diff --git a/tests/test_format_articles.py b/tests/test_format_articles.py new file mode 100644 index 0000000..e576c39 --- /dev/null +++ b/tests/test_format_articles.py @@ -0,0 +1,229 @@ +import pytest +from utils.news import format_articles, truncate_title, _parse_date + + +class TestTruncateTitle: + """Тесты функции truncate_title() — обрезка заголовка.""" + + @pytest.mark.parametrize( + "title, max_len, expected", + [ + ("Короткий заголовок", 60, "Короткий заголовок"), + ("Заголовок ровно в 60 символов1234567890", 60, "Заголовок ровно в 60 символов1234567890"), + ("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения + ("", 60, ""), + ("A" * 100, 100, "A" * 100), + ("A" * 101, 100, "A" * 100 + "..."), + ("A" * 50, 100, "A" * 50), + ], + ) + def test_truncate(self, title, max_len, expected): + """Проверка обрезки заголовка.""" + assert truncate_title(title, max_len) == expected + + def test_truncate_default_max_len(self): + """По умолчанию max_len=60.""" + long_title = "A" * 61 + result = truncate_title(long_title) + assert result == "A" * 60 + "..." + assert len(result) == 63 # 60 + "..." + + +class TestParseDate: + """Тесты функции _parse_date() — парсинг даты из RSS.""" + + @pytest.mark.parametrize( + "pub_date, expected", + [ + ("Mon, 28 May 2026 10:00:00 +0000", "28.05.2026"), + ("Mon, 28 May 2026 10:00:00 GMT", "28.05.2026"), + ("2026-05-28T10:00:00Z", "2026.05.28"), + ("2026-12-31T23:59:59Z", "2026.12.31"), + ("2026-01-01T00:00:00Z", "2026.01.01"), + ], + ) + def test_parse_date_known(self, pub_date, expected): + """Известные форматы даты должны парситься корректно.""" + assert _parse_date(pub_date) == expected + + @pytest.mark.parametrize( + "pub_date, expected", + [ + ("", ""), + (None, ""), + ], + ) + def test_parse_date_empty(self, pub_date, expected): + """Пустая или None дата должна вернуть пустую строку.""" + assert _parse_date(pub_date) == expected + + def test_parse_date_invalid(self): + """Невалидная дата должна вернуть первые 10 символов.""" + result = _parse_date("invalid-date-string") + assert result == "invalid.da" # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.')) + + +class TestFormatArticles: + """Тесты функции format_articles() — формирование строк для вывода.""" + + def test_format_articles_normal(self): + """Нормальный список статей должен вернуть заголовок + 5 статей.""" + articles = [ + { + "title": "Статья 1", + "link": "https://habr.com/1", + "pub_date": "Mon, 28 May 2026 10:00:00 +0000", + "creator": "Автор 1", + "tags": ["AI"], + }, + { + "title": "Статья 2", + "link": "https://habr.com/2", + "pub_date": "Tue, 29 May 2026 12:00:00 +0000", + "creator": "Автор 2", + "tags": ["ML"], + }, + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert len(result) == 3 # заголовок + 2 статьи + assert result[0] == "**Заголовок**\n" + assert result[1] == "Статья 1\n28.05.2026 " + assert result[2] == "Статья 2\n29.05.2026 " + + def test_format_articles_limit_to_5(self): + """Больше 5 статей должно быть обрезано до 5.""" + articles = [ + {"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []} + for i in range(10) + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert len(result) == 6 # заголовок + 5 статей + assert result[-1] == "Статья 4\n28.05.2026 " + + def test_format_articles_empty_list(self): + """Пустой список должен вернуть только заголовок.""" + result = format_articles([], "Заголовок", "https://habr.com/feed") + assert result == ["**Заголовок**\n"] + assert len(result) == 1 + + def test_format_articles_none(self): + """None должен вызвать TypeError (articles[:5] на None).""" + with pytest.raises(TypeError): + format_articles(None, "Заголовок", "https://habr.com/feed") + + def test_format_articles_single_article(self): + """Одна статья должна быть корректно отформатирована.""" + articles = [ + { + "title": "Единственная статья", + "link": "https://habr.com/1", + "pub_date": "Mon, 28 May 2026 10:00:00 +0000", + "creator": "Автор", + "tags": ["AI"], + }, + ] + result = format_articles(articles, "Новости AI", "https://habr.com/ai") + assert len(result) == 2 + assert result[0] == "**Новости AI**\n" + assert result[1] == "Единственная статья\n28.05.2026 " + + def test_format_articles_long_title_truncated(self): + """Длинный заголовок должен быть обрезан до 60 символов с '...'.""" + long_title = "A" * 100 + articles = [ + {"title": long_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []} + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert len(result[1].split("\n")[0]) == 63 # 60 + "..." + assert result[1].split("\n")[0].endswith("...") + + def test_format_articles_short_title_unchanged(self): + """Короткий заголовок должен остаться без изменений.""" + short_title = "Кот" + articles = [ + {"title": short_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []} + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert result[1].split("\n")[0] == "Кот" + + def test_format_articles_exact_60_chars(self): + """Заголовок ровно 60 символов не должен обрезаться.""" + exact_title = "A" * 60 + articles = [ + {"title": exact_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []} + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert result[1].split("\n")[0] == exact_title + assert "..." not in result[1] + + def test_format_articles_iso_date(self): + """Дата в формате ISO должна парситься корректно.""" + articles = [ + { + "title": "Статья", + "link": "https://habr.com/1", + "pub_date": "2026-05-28T10:00:00Z", + "creator": "", + "tags": [], + }, + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert result[1] == "Статья\n2026.05.28 " + + def test_format_articles_empty_date(self): + """Пустая дата должна быть пустой строкой.""" + articles = [ + {"title": "Статья", "link": "https://habr.com/1", "pub_date": "", "creator": "", "tags": []} + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert result[1] == "Статья\n " + + def test_format_articles_none_date(self): + """None дата должна быть пустой строкой.""" + articles = [ + {"title": "Статья", "link": "https://habr.com/1", "pub_date": None, "creator": "", "tags": []} + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert result[1] == "Статья\n " + + def test_format_articles_empty_link(self): + """Пустая ссылка должна быть пустой строкой в угловых скобках.""" + articles = [ + {"title": "Статья", "link": "", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []} + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert result[1].endswith(" <>") + + def test_format_articles_russian_title(self): + """Русские заголовки должны корректно отображаться.""" + articles = [ + { + "title": "Искусственный интеллект в медицине", + "link": "https://habr.com/1", + "pub_date": "Mon, 28 May 2026 10:00:00 +0000", + "creator": "Иван Иванов", + "tags": ["AI", "медицина"], + }, + ] + result = format_articles(articles, "Новости AI", "https://habr.com/ai") + assert "Искусственный интеллект в медицине" in result[1] + + def test_format_articles_exact_5_articles(self): + """Ровно 5 статей должно быть включено.""" + articles = [ + {"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []} + for i in range(5) + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert len(result) == 6 # заголовок + 5 статей + assert result[-1] == "Статья 4\n28.05.2026 " + + def test_format_articles_6th_article_excluded(self): + """6-я статья должна быть исключена.""" + articles = [ + {"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []} + for i in range(6) + ] + result = format_articles(articles, "Заголовок", "https://habr.com/feed") + assert len(result) == 6 # заголовок + 5 статей + assert "Статья 5" not in result[5] diff --git a/tests/test_pogoda.py b/tests/test_pogoda.py new file mode 100644 index 0000000..96f575e --- /dev/null +++ b/tests/test_pogoda.py @@ -0,0 +1,209 @@ +import pytest +from utils.pogoda import translate_weather, pressure_to_mmhg, wmo_to_russian + + +class TestTranslateWeather: + """Тесты функции translate_weather() — перевод описания погоды из английского в русский.""" + + @pytest.mark.parametrize( + "english, expected", + [ + ("Clear", "Ясно"), + ("Sunny", "Ясно"), + ("Partly cloudy", "Переменная облачность"), + ("Cloudy", "Облачно"), + ("Overcast", "Пасмурно"), + ("Fog", "Туман"), + ("Foggy", "Туманно"), + ("Mist", "Туман"), + ("Haze", "Дымка"), + ("Light rain", "Небольшой дождь"), + ("Moderate rain", "Умеренный дождь"), + ("Heavy rain", "Сильный дождь"), + ("Moderate or heavy rain at times", "Сильный дождь"), # "Heavy rain" совпадает раньше в mapping dict (key in text) + ("Heavy rain at times", "Сильный дождь"), + ("Light snow", "Небольшой снег"), + ("Moderate snow", "Умеренный снег"), + ("Heavy snow", "Сильный снег"), + ("Blowing snow", "Метель"), + ("Light freezing rain", "Лёгкий ледяной дождь"), + ("Heavy freezing rain", "Сильный ледяной дождь"), + ("Moderate or heavy freezing rain", "Сильный ледяной дождь"), + ("Light sleet", "Light sleet"), + ("Moderate or heavy sleet", "Moderate or heavy sleet"), + ("Thundery outbreaks in nearby", "Гроза вблизи"), + ("Patchy rain nearby", "Местами дождь"), + ("Patchy snow nearby", "Местами снег"), + ("Patchy sleet nearby", "Местами слякоть"), + ("Patchy light drizzle", "Местами лёгкая морось"), + ("Moderate or heavy snow in area", "Снег"), + ("Moderate or heavy rain in area", "Дождь"), + ], + ) + def test_translate_known(self, english, expected): + """Известные переводы должны возвращать ожидаемый результат.""" + assert translate_weather(english) == expected + + @pytest.mark.parametrize( + "input_value, expected", + [ + ("", "—"), + (None, "—"), + (" ", " "), # пробелы не считаются пустыми + ], + ) + def test_translate_empty(self, input_value, expected): + """Пустой или None ввод должен возвращать '—'.""" + assert translate_weather(input_value) == expected + + def test_translate_unknown_returns_original(self): + """Неизвестный перевод должен возвращать оригинальный текст.""" + unknown_text = "Unknown weather condition XYZ" + assert translate_weather(unknown_text) == unknown_text + + def test_translate_partial_match(self): + """Частичное совпадение ключа в тексте должно сработать.""" + # "Moderate or heavy rain in area" должно найтись в "Light Moderate or heavy rain in area" + text_with_prefix = "Light Moderate or heavy rain in area" + assert translate_weather(text_with_prefix) == "Дождь" + + def test_translate_longer_key_priority(self): + """translate_weather ищет key in text, порядок dict важен. + "Heavy rain" стоит раньше "Moderate or heavy rain at times" в mapping, + и "heavy rain" in "moderate or heavy rain at times" = True. + Поэтому совпадёт первым и вернёт "Сильный дождь".""" + text = "Moderate or heavy rain at times" + assert translate_weather(text) == "Сильный дождь" + + def test_translate_case_insensitive(self): + """Перевод должен быть регистронезависимым.""" + assert translate_weather("CLEAR") == "Ясно" + assert translate_weather("partly cloudy") == "Переменная облачность" + assert translate_weather("HEAVY RAIN") == "Сильный дождь" + + def test_translate_with_whitespace(self): + """Текст с пробелами по краям должен корректно переводиться.""" + assert translate_weather(" Clear ") == "Ясно" + + +class TestPressureToMMHG: + """Тесты функции pressure_to_mmhg() — конвертация давления из мб в мм рт. ст.""" + + @pytest.mark.parametrize( + "mb, expected", + [ + (1013, 759.8), + (1000, 750.1), + (980, 735.1), + (1030, 772.6), + # (0, "—"), # 0 — falsy, возвращается '—' (баг) + ], + ) + def test_pressure_valid(self, mb, expected): + """Валидные числовые значения должны конвертироваться корректно.""" + assert pressure_to_mmhg(mb) == expected + + @pytest.mark.parametrize( + "mb, expected", + [ + ("1013", 759.8), + ("1000", 750.1), + ("980", 735.1), + ], + ) + def test_pressure_string(self, mb, expected): + """Строка-число должна конвертироваться корректно.""" + assert pressure_to_mmhg(mb) == expected + + @pytest.mark.parametrize( + "input_value, expected", + [ + ("—", "—"), + (None, "—"), + ("", "—"), + ], + ) + def test_pressure_invalid(self, input_value, expected): + """Невалидные значения должны возвращать '—'.""" + assert pressure_to_mmhg(input_value) == expected + + def test_pressure_non_numeric_string(self): + """Невалидная строка должна возвращать '—'.""" + assert pressure_to_mmhg("abc") == "—" + + def test_pressure_zero(self): + """Нулевое значение — falsy, возвращается '—' (известный баг).""" + assert pressure_to_mmhg(0) == "—" + + def test_pressure_negative(self): + """Отрицательное значение должно конвертироваться.""" + assert pressure_to_mmhg(-100) == -75.0 + + def test_pressure_float_string(self): + """Строка с десятичной точкой должна конвертироваться.""" + assert pressure_to_mmhg("1013.25") == 760.0 + + def test_pressure_very_large(self): + """Очень большое значение должно работать.""" + assert pressure_to_mmhg(999999) == 750061.2 + + +class TestWmoToRussian: + """Тесты функции wmo_to_russian() — перевод WMO кодов погоды.""" + + @pytest.mark.parametrize( + "code, expected", + [ + (0, "Ясно"), + (1, "Ясно"), + (2, "Переменная облачность"), + (3, "Пасмурно"), + (45, "Туман"), + (48, "Туман"), + (51, "Лёгкая морось"), + (53, "Морось"), + (55, "Сильная морось"), + (56, "Ледяная морось"), + (57, "Сильная ледяная морось"), + (61, "Небольшой дождь"), + (63, "Дождь"), + (65, "Сильный дождь"), + (66, "Ледяной дождь"), + (67, "Сильный ледяной дождь"), + (71, "Небольшой снег"), + (73, "Снег"), + (75, "Сильный снег"), + (77, "Снежная крупа"), + (80, "Небольшой ливень"), + (81, "Ливень"), + (82, "Сильный ливень"), + (85, "Снежный ливень"), + (86, "Сильный снежный ливень"), + (95, "Гроза"), + (96, "Гроза с градом"), + (99, "Сильная гроза с градом"), + ], + ) + def test_wmo_known(self, code, expected): + """Известные WMO коды должны возвращать ожидаемый перевод.""" + assert wmo_to_russian(code) == expected + + def test_wmo_unknown(self): + """Неизвестный код должен возвращать 'Неизвестно'.""" + assert wmo_to_russian(999) == "Неизвестно" + + def test_wmo_negative_code(self): + """Отрицательный код должен возвращать 'Неизвестно'.""" + assert wmo_to_russian(-1) == "Неизвестно" + + def test_wmo_none(self): + """None должен возвращать 'Неизвестно'.""" + assert wmo_to_russian(None) == "Неизвестно" + + def test_wmo_large_code(self): + """Очень большой код должен возвращать 'Неизвестно'.""" + assert wmo_to_russian(9999) == "Неизвестно" + + def test_wmo_float_code(self): + """Дробный код — не найдётся в mapping.""" + assert wmo_to_russian(1.5) == "Неизвестно" diff --git a/utils/cat.py b/utils/cat.py index f3ce8d2..b9dac52 100644 --- a/utils/cat.py +++ b/utils/cat.py @@ -14,5 +14,5 @@ async def fetch_cat() -> str | None: response.raise_for_status() data = response.json() return data[0]["url"] - except requests.exceptions.RequestException: + except (requests.exceptions.RequestException, IndexError, KeyError): return None