690b903f85
* chore: add pytest-asyncio in auto mode * test: migrate channel and stream tests to native async Convert run_async() wrapper tests to plain 'async def test_*' under pytest-asyncio auto mode. collect_events() in stream_v3_fakes becomes a coroutine awaited at every call site. * test: migrate command and model/middleware tests to native async Convert run_async() wrappers (import, alias, and fixture forms) to plain 'async def test_*'. Multi-call tests merge onto one loop as sequential awaits; none asserted on loop identity. * test: migrate TUI, notifier, gateway, and session tests to native async TUI/notifier/gateway files convert run_async wrappers to plain async tests. test_sessions.py's unittest.TestCase classes move to unittest.IsolatedAsyncioTestCase (pytest-asyncio does not await async methods on plain TestCase; converting blindly would have made ~70 tests silently vacuous). Its setUpClass keeps a one-shot asyncio.run() since IsolatedAsyncioTestCase has no async class-level hook. TestLoadingWidget in test_tui_widgets.py drops its TestCase base for the same reason. * test: replace direct asyncio.run() calls with native async tests Convert tests that called asyncio.run() (directly or via a local _run helper) to plain 'async def test_*'; delete the local helpers. * test: drop undeclared anyio markers and delete run_async helper The @pytest.mark.anyio tests relied on anyio being a transitive dep of httpx; auto-mode pytest-asyncio collects them natively. run_async() and its fixture are unreferenced after the migration, so remove them — pytest-asyncio's per-test loop teardown covers the pending-task cancellation the helper existed for (verified: full suite runs with no 'Event loop is closed' errors or destroyed-task warnings).
182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
"""Tests for EvoScientist.llm.ollama_discovery.
|
|
|
|
Covers both the sync ``validate_ollama_connection`` (used by the onboarding
|
|
wizard) and the async ``discover_ollama_models`` (used by the /model
|
|
picker). The async variant must never raise — the picker's UX depends on
|
|
a silent empty-list fallback when the daemon is down or misbehaving.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from EvoScientist.llm.ollama_discovery import (
|
|
discover_ollama_models,
|
|
validate_ollama_connection,
|
|
)
|
|
|
|
|
|
class TestValidateOllamaConnection:
|
|
"""Sync probe — guards onboard's existing contract."""
|
|
|
|
def test_empty_base_url_returns_skipped(self):
|
|
ok, msg, names = validate_ollama_connection("")
|
|
assert ok is True
|
|
assert "Skipped" in msg
|
|
assert names == []
|
|
|
|
def test_200_with_models_returns_names(self):
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {
|
|
"models": [{"name": "llama3.3:latest"}, {"name": "qwen3:8b"}]
|
|
}
|
|
with patch("httpx.get", return_value=resp):
|
|
ok, msg, names = validate_ollama_connection("http://localhost:11434")
|
|
assert ok is True
|
|
assert "Connected" in msg
|
|
assert names == ["llama3.3:latest", "qwen3:8b"]
|
|
|
|
def test_200_with_no_models_still_ok(self):
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {"models": []}
|
|
with patch("httpx.get", return_value=resp):
|
|
ok, msg, names = validate_ollama_connection("http://localhost:11434")
|
|
assert ok is True
|
|
assert "no models pulled" in msg
|
|
assert names == []
|
|
|
|
def test_non_200_returns_error(self):
|
|
resp = MagicMock()
|
|
resp.status_code = 500
|
|
with patch("httpx.get", return_value=resp):
|
|
ok, msg, names = validate_ollama_connection("http://localhost:11434")
|
|
assert ok is False
|
|
assert "500" in msg
|
|
assert names == []
|
|
|
|
def test_connect_error_returns_error(self):
|
|
with patch("httpx.get", side_effect=httpx.ConnectError("refused")):
|
|
ok, msg, names = validate_ollama_connection("http://localhost:11434")
|
|
assert ok is False
|
|
assert "Cannot reach Ollama" in msg
|
|
assert names == []
|
|
|
|
|
|
class TestDiscoverOllamaModels:
|
|
"""Async probe — contract: never raise, return list[str]."""
|
|
|
|
async def test_empty_base_url_returns_empty_without_http(self):
|
|
# No HTTP call should be made for an empty base_url — verified by
|
|
# the fact that no mock is set up and the test completes.
|
|
names = await discover_ollama_models("")
|
|
assert names == []
|
|
|
|
async def test_none_base_url_returns_empty(self):
|
|
names = await discover_ollama_models(None)
|
|
assert names == []
|
|
|
|
async def test_200_returns_names(self):
|
|
async def fake_get(self, url):
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json = MagicMock(
|
|
return_value={
|
|
"models": [{"name": "llama3.3:latest"}, {"name": "qwen3:8b"}]
|
|
}
|
|
)
|
|
return resp
|
|
|
|
with patch.object(httpx.AsyncClient, "get", fake_get):
|
|
names = await discover_ollama_models("http://localhost:11434")
|
|
assert names == ["llama3.3:latest", "qwen3:8b"]
|
|
|
|
async def test_strips_entries_without_name(self):
|
|
async def fake_get(self, url):
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json = MagicMock(
|
|
return_value={
|
|
"models": [
|
|
{"name": "llama3.3"},
|
|
{"name": ""}, # dropped
|
|
{}, # dropped
|
|
]
|
|
}
|
|
)
|
|
return resp
|
|
|
|
with patch.object(httpx.AsyncClient, "get", fake_get):
|
|
names = await discover_ollama_models("http://localhost:11434")
|
|
assert names == ["llama3.3"]
|
|
|
|
async def test_timeout_returns_empty(self):
|
|
async def fake_get(self, url):
|
|
raise httpx.TimeoutException("timed out")
|
|
|
|
with patch.object(httpx.AsyncClient, "get", fake_get):
|
|
names = await discover_ollama_models("http://localhost:11434")
|
|
assert names == []
|
|
|
|
async def test_connect_error_returns_empty(self):
|
|
async def fake_get(self, url):
|
|
raise httpx.ConnectError("refused")
|
|
|
|
with patch.object(httpx.AsyncClient, "get", fake_get):
|
|
names = await discover_ollama_models("http://localhost:11434")
|
|
assert names == []
|
|
|
|
async def test_non_200_returns_empty(self):
|
|
async def fake_get(self, url):
|
|
resp = MagicMock()
|
|
resp.status_code = 500
|
|
return resp
|
|
|
|
with patch.object(httpx.AsyncClient, "get", fake_get):
|
|
names = await discover_ollama_models("http://localhost:11434")
|
|
assert names == []
|
|
|
|
async def test_malformed_json_returns_empty(self):
|
|
async def fake_get(self, url):
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json = MagicMock(side_effect=ValueError("bad json"))
|
|
return resp
|
|
|
|
with patch.object(httpx.AsyncClient, "get", fake_get):
|
|
names = await discover_ollama_models("http://localhost:11434")
|
|
assert names == []
|
|
|
|
async def test_missing_models_key_returns_empty(self):
|
|
async def fake_get(self, url):
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json = MagicMock(return_value={"unexpected": "shape"})
|
|
return resp
|
|
|
|
with patch.object(httpx.AsyncClient, "get", fake_get):
|
|
names = await discover_ollama_models("http://localhost:11434")
|
|
assert names == []
|
|
|
|
async def test_trailing_slash_stripped_from_url(self):
|
|
called = {}
|
|
|
|
async def fake_get(self, url):
|
|
called["url"] = url
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json = MagicMock(return_value={"models": []})
|
|
return resp
|
|
|
|
with patch.object(httpx.AsyncClient, "get", fake_get):
|
|
await discover_ollama_models("http://localhost:11434/")
|
|
assert called["url"] == "http://localhost:11434/api/tags"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|