tests/test_llm_parser.py: 13 Tests für _call_ollama, _call_openai_compatible und parse_tracklist (Retry-Logik, Markdown-Block, Track-Artist, Mock) cli: neuer check-Befehl zeigt Tags und Cover-Status aller Audiodateien; ♪ markiert Dateien mit eingebettetem Cover BEDIENUNGSANLEITUNG: neuer Abschnitt 7 (check-Befehl), Cover-Konvention (frontcover.jpg/backcover.jpg, Embedding, 500px) in Schritt 3 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
136 lines
5.5 KiB
Python
136 lines
5.5 KiB
Python
"""Tests für den LLM-Parser (HTTP-Calls via Mock)."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from musiksammlung.llm_parser import _call_ollama, _call_openai_compatible, parse_tracklist
|
|
|
|
VALID_JSON = (
|
|
'{"artist":"Karajan","album":"Beethoven","year":1963,'
|
|
'"discs":[{"disc_number":1,"tracks":[{"track_number":1,"title":"Allegro"}]}]}'
|
|
)
|
|
|
|
VALID_JSON_WITH_TRACK_ARTIST = (
|
|
'{"artist":"Various Artists","album":"Sampler","year":null,'
|
|
'"discs":[{"disc_number":1,"tracks":['
|
|
'{"track_number":1,"title":"Song A","artist":"Artist X"},'
|
|
'{"track_number":2,"title":"Song B","artist":"Artist Y"}'
|
|
']}]}'
|
|
)
|
|
|
|
|
|
def _mock_response(content: str) -> MagicMock:
|
|
"""Erstellt eine Mock-httpx-Response mit dem gegebenen Content."""
|
|
resp = MagicMock()
|
|
resp.raise_for_status = MagicMock()
|
|
resp.json.return_value = {"message": {"content": content}}
|
|
return resp
|
|
|
|
|
|
def _mock_openai_response(content: str) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.raise_for_status = MagicMock()
|
|
resp.json.return_value = {"choices": [{"message": {"content": content}}]}
|
|
return resp
|
|
|
|
|
|
class TestCallOllama:
|
|
def test_returns_message_content(self) -> None:
|
|
with patch("musiksammlung.llm_parser.httpx.post") as mock_post:
|
|
mock_post.return_value = _mock_response("some response")
|
|
result = _call_ollama("text", "gemma3:12b", "http://localhost:11434")
|
|
assert result == "some response"
|
|
|
|
def test_sends_correct_url(self) -> None:
|
|
with patch("musiksammlung.llm_parser.httpx.post") as mock_post:
|
|
mock_post.return_value = _mock_response("")
|
|
_call_ollama("text", "gemma3:12b", "http://localhost:11434")
|
|
called_url = mock_post.call_args[0][0]
|
|
assert called_url == "http://localhost:11434/api/chat"
|
|
|
|
def test_sends_model_and_text(self) -> None:
|
|
with patch("musiksammlung.llm_parser.httpx.post") as mock_post:
|
|
mock_post.return_value = _mock_response("")
|
|
_call_ollama("mein text", "gemma3:12b", "http://localhost:11434")
|
|
payload = mock_post.call_args[1]["json"]
|
|
assert payload["model"] == "gemma3:12b"
|
|
assert payload["messages"][1]["content"] == "mein text"
|
|
|
|
|
|
class TestCallOpenaiCompatible:
|
|
def test_returns_message_content(self) -> None:
|
|
with patch("musiksammlung.llm_parser.httpx.post") as mock_post:
|
|
mock_post.return_value = _mock_openai_response("openai reply")
|
|
result = _call_openai_compatible("text", "gpt-4", "http://api.example.com")
|
|
assert result == "openai reply"
|
|
|
|
def test_sends_bearer_token_if_api_key(self) -> None:
|
|
with patch("musiksammlung.llm_parser.httpx.post") as mock_post:
|
|
mock_post.return_value = _mock_openai_response("")
|
|
_call_openai_compatible("t", "m", "http://x", api_key="secret")
|
|
headers = mock_post.call_args[1]["headers"]
|
|
assert headers["Authorization"] == "Bearer secret"
|
|
|
|
def test_no_auth_header_without_api_key(self) -> None:
|
|
with patch("musiksammlung.llm_parser.httpx.post") as mock_post:
|
|
mock_post.return_value = _mock_openai_response("")
|
|
_call_openai_compatible("t", "m", "http://x")
|
|
headers = mock_post.call_args[1]["headers"]
|
|
assert "Authorization" not in headers
|
|
|
|
|
|
class TestParseTracklist:
|
|
def test_successful_parse_ollama(self) -> None:
|
|
with patch("musiksammlung.llm_parser._call_ollama", return_value=VALID_JSON):
|
|
album = parse_tracklist("tracklist text")
|
|
assert album.artist == "Karajan"
|
|
assert album.album == "Beethoven"
|
|
assert album.year == 1963
|
|
assert len(album.discs[0].tracks) == 1
|
|
|
|
def test_successful_parse_openai(self) -> None:
|
|
with patch(
|
|
"musiksammlung.llm_parser._call_openai_compatible", return_value=VALID_JSON
|
|
):
|
|
album = parse_tracklist("text", backend="openai")
|
|
assert album.artist == "Karajan"
|
|
|
|
def test_retries_on_invalid_json(self) -> None:
|
|
responses = iter(["definitely not json", VALID_JSON])
|
|
with patch(
|
|
"musiksammlung.llm_parser._call_ollama", side_effect=responses
|
|
):
|
|
album = parse_tracklist("text", max_retries=2)
|
|
assert album.artist == "Karajan"
|
|
|
|
def test_raises_after_max_retries_exceeded(self) -> None:
|
|
with patch(
|
|
"musiksammlung.llm_parser._call_ollama", return_value="no json here"
|
|
):
|
|
with pytest.raises(ValueError, match="kein valides JSON"):
|
|
parse_tracklist("text", max_retries=1)
|
|
|
|
def test_json_in_markdown_block(self) -> None:
|
|
wrapped = f"```json\n{VALID_JSON}\n```"
|
|
with patch("musiksammlung.llm_parser._call_ollama", return_value=wrapped):
|
|
album = parse_tracklist("text")
|
|
assert album.artist == "Karajan"
|
|
|
|
def test_track_artist_field_parsed(self) -> None:
|
|
with patch(
|
|
"musiksammlung.llm_parser._call_ollama",
|
|
return_value=VALID_JSON_WITH_TRACK_ARTIST,
|
|
):
|
|
album = parse_tracklist("text")
|
|
assert album.discs[0].tracks[0].artist == "Artist X"
|
|
assert album.discs[0].tracks[1].artist == "Artist Y"
|
|
|
|
def test_missing_year_defaults_to_none(self) -> None:
|
|
json_no_year = (
|
|
'{"artist":"A","album":"B","year":null,'
|
|
'"discs":[{"disc_number":1,"tracks":[{"track_number":1,"title":"T"}]}]}'
|
|
)
|
|
with patch("musiksammlung.llm_parser._call_ollama", return_value=json_no_year):
|
|
album = parse_tracklist("text")
|
|
assert album.year is None
|