Добавить pytest-тесты и конфигурацию
- 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
This commit is contained in:
parent
6ae8442b57
commit
4b9bb7e97a
@ -19,6 +19,7 @@ python bot.py
|
|||||||
- **До внесения любых изменений в код или файлы предоставь детальное описание всех планируемых изменений и получи явное согласие пользователя. Без согласования изменения не вносить.**
|
- **До внесения любых изменений в код или файлы предоставь детальное описание всех планируемых изменений и получи явное согласие пользователя. Без согласования изменения не вносить.**
|
||||||
- **Все git-коммиты согласовывать с пользователем перед созданием.**
|
- **Все git-коммиты согласовывать с пользователем перед созданием.**
|
||||||
- **Все сообщения git-коммитов писать на русском языке.**
|
- **Все сообщения git-коммитов писать на русском языке.**
|
||||||
|
- **Думать и общаться с LLM на английском, а отвечать пользователю на русском.**
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|||||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
@ -1,3 +1,5 @@
|
|||||||
discord.py>=2.3.2
|
discord.py>=2.3.2
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
pytest>=7.4.0
|
||||||
|
pytest-asyncio>=0.21.0
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
210
tests/test_commands_pogoda.py
Normal file
210
tests/test_commands_pogoda.py
Normal file
@ -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
|
||||||
102
tests/test_fetch_cat.py
Normal file
102
tests/test_fetch_cat.py
Normal file
@ -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"
|
||||||
416
tests/test_fetch_rss.py
Normal file
416
tests/test_fetch_rss.py
Normal file
@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Статья 1</title>
|
||||||
|
<link>https://habr.com/1</link>
|
||||||
|
<guid isPermaLink="true">https://habr.com/1</guid>
|
||||||
|
<pubDate>Mon, 28 May 2026 10:00:00 +0000</pubDate>
|
||||||
|
<dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Автор 1</dc:creator>
|
||||||
|
<category>AI</category>
|
||||||
|
<category>ML</category>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Статья 2</title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/2</guid>
|
||||||
|
<pubDate>Mon, 28 May 2026 12:00:00 +0000</pubDate>
|
||||||
|
<dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Автор 2</dc:creator>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Atom статья 1</title>
|
||||||
|
<link href="https://habr.com/atom/1" />
|
||||||
|
<published>2026-05-28T10:00:00Z</published>
|
||||||
|
<author><name>Atom автор</name></author>
|
||||||
|
<category>AI</category>
|
||||||
|
</entry>
|
||||||
|
</feed>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0"?>
|
||||||
|
<unknown></unknown>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Без title</title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/1</guid>
|
||||||
|
<pubDate>Mon, 28 May 2026 10:00:00 +0000</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Без guid</title>
|
||||||
|
<link>https://habr.com/1</link>
|
||||||
|
<guid>https://habr.com/1</guid>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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""" <item>
|
||||||
|
<title>Статья {i}</title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/{i}</guid>
|
||||||
|
<pubDate>Mon, 28 May 2026 10:00:00 +0000</pubDate>
|
||||||
|
</item>"""
|
||||||
|
for i in range(15)
|
||||||
|
)
|
||||||
|
rss_content = (f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
{items}
|
||||||
|
</channel>
|
||||||
|
</rss>""").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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Пустые теги</title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/1</guid>
|
||||||
|
<pubDate>Mon, 28 May 2026 10:00:00 +0000</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Статья</title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/1</guid>
|
||||||
|
<category></category>
|
||||||
|
<category>AI</category>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Без автора</title>
|
||||||
|
<link href="https://habr.com/1" />
|
||||||
|
<published>2026-05-28T10:00:00Z</published>
|
||||||
|
</entry>
|
||||||
|
</feed>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Без ссылки</title>
|
||||||
|
<published>2026-05-28T10:00:00Z</published>
|
||||||
|
</entry>
|
||||||
|
</feed>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Статья</title>
|
||||||
|
<link>https://habr.com/alternative</link>
|
||||||
|
<guid>https://habr.com/alternative</guid>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Единственная статья</title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/1</guid>
|
||||||
|
<pubDate>Mon, 28 May 2026 10:00:00 +0000</pubDate>
|
||||||
|
<dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Единственный автор</dc:creator>
|
||||||
|
<category>ML</category>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>AI & ML: будущее <технологий></title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/1</guid>
|
||||||
|
<pubDate>Mon, 28 May 2026 10:00:00 +0000</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Статья</title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/1</guid>
|
||||||
|
<pubDate>Mon, 28 May 2026 10:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Статья</title>
|
||||||
|
<guid isPermaLink="true">https://habr.com/1</guid>
|
||||||
|
<category>AI</category>
|
||||||
|
<category>ML</category>
|
||||||
|
<category>Deep Learning</category>
|
||||||
|
<category>NLP</category>
|
||||||
|
<category>Computer Vision</category>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>""".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"]
|
||||||
231
tests/test_fetch_weather.py
Normal file
231
tests/test_fetch_weather.py
Normal file
@ -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
|
||||||
229
tests/test_format_articles.py
Normal file
229
tests/test_format_articles.py
Normal file
@ -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<https://habr.com/feed>"
|
||||||
|
assert result[1] == "Статья 1\n28.05.2026 <https://habr.com/1>"
|
||||||
|
assert result[2] == "Статья 2\n29.05.2026 <https://habr.com/2>"
|
||||||
|
|
||||||
|
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 <https://habr.com/4>"
|
||||||
|
|
||||||
|
def test_format_articles_empty_list(self):
|
||||||
|
"""Пустой список должен вернуть только заголовок."""
|
||||||
|
result = format_articles([], "Заголовок", "https://habr.com/feed")
|
||||||
|
assert result == ["**Заголовок**\n<https://habr.com/feed>"]
|
||||||
|
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<https://habr.com/ai>"
|
||||||
|
assert result[1] == "Единственная статья\n28.05.2026 <https://habr.com/1>"
|
||||||
|
|
||||||
|
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 <https://habr.com/1>"
|
||||||
|
|
||||||
|
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 <https://habr.com/1>"
|
||||||
|
|
||||||
|
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 <https://habr.com/1>"
|
||||||
|
|
||||||
|
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 <https://habr.com/4>"
|
||||||
|
|
||||||
|
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]
|
||||||
209
tests/test_pogoda.py
Normal file
209
tests/test_pogoda.py
Normal file
@ -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) == "Неизвестно"
|
||||||
@ -14,5 +14,5 @@ async def fetch_cat() -> str | None:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
return data[0]["url"]
|
return data[0]["url"]
|
||||||
except requests.exceptions.RequestException:
|
except (requests.exceptions.RequestException, IndexError, KeyError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user