Добавить 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:
deadzilla 2026-05-29 15:45:56 +05:00
parent 6ae8442b57
commit 4b9bb7e97a
11 changed files with 1403 additions and 1 deletions

View File

@ -19,6 +19,7 @@ python bot.py
- **До внесения любых изменений в код или файлы предоставь детальное описание всех планируемых изменений и получи явное согласие пользователя. Без согласования изменения не вносить.** - **До внесения любых изменений в код или файлы предоставь детальное описание всех планируемых изменений и получи явное согласие пользователя. Без согласования изменения не вносить.**
- **Все git-коммиты согласовывать с пользователем перед созданием.** - **Все git-коммиты согласовывать с пользователем перед созданием.**
- **Все сообщения git-коммитов писать на русском языке.** - **Все сообщения git-коммитов писать на русском языке.**
- **Думать и общаться с LLM на английском, а отвечать пользователю на русском.**
## Команды ## Команды

2
pytest.ini Normal file
View File

@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

View File

@ -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
View File

View 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
View 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
View 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 &amp; ML: будущее &lt;технологий&gt;</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
View 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

View 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
View 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) == "Неизвестно"

View File

@ -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