"""Tests for the LLM module (client, brain, social AI). All tests use mocked HTTP responses — no Ollama server required. """ import asyncio import json import pytest from unittest.mock import AsyncMock, patch, MagicMock from modules.llm.client import ( LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES, ) from modules.llm.brain import LLMBrain from modules.llm.social_ai import SocialAI # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_mock_response(text="", tokens=10, success=True): return LLMResponse( text=text, model="test", tokens_used=tokens, latency_ms=50.0, success=success, ) def _make_json_response(data, tokens=10): return LLMResponse( text=json.dumps(data), model="test", tokens_used=tokens, latency_ms=50.0, success=True, ) # --------------------------------------------------------------------------- # LLMClient # --------------------------------------------------------------------------- class TestLLMConfig: def test_defaults(self): cfg = LLMConfig() assert cfg.model == "llama3.2" assert cfg.temperature == 0.3 assert cfg.base_url == "http://localhost:11434" class TestLLMResponse: def test_as_json_valid(self): r = LLMResponse(text='{"key": "value"}', success=True) data = r.as_json() assert data == {"key": "value"} def test_as_json_invalid(self): r = LLMResponse(text="not json", success=True) assert r.as_json() is None def test_as_json_empty(self): r = LLMResponse(text="", success=True) assert r.as_json() is None class TestRenderTemplate: def test_quest_parse_template(self): result = render_template("quest_parse", text="Kill 5 goblins") assert "Kill 5 goblins" in result assert "JSON" in result def test_social_reply_template(self): result = render_template( "social_reply", message="Hello!", character_name="Grok", character_class="warrior", ) assert "Grok" in result assert "Hello!" in result def test_decide_action_template(self): result = render_template( "decide_action", room_title="Town Square", hp=100, max_hp=100, mp=50, max_mp=50, exits="north, south", npcs="guard", items="potion", combat_state="idle", goal="explore", ) assert "Town Square" in result def test_missing_variable(self): # Should not crash, just leave placeholder result = render_template("quest_parse") assert isinstance(result, str) def test_custom_template(self): result = render_template("Hello {name}!", name="World") assert result == "Hello World!" class TestLLMClient: def test_client_creation(self): client = LLMClient(LLMConfig(model="test-model")) assert client.config.model == "test-model" @pytest.mark.asyncio async def test_generate_unavailable(self): """When server is down, should return error response.""" client = LLMClient(LLMConfig( base_url="http://localhost:99999", max_retries=0, timeout_seconds=1, )) resp = await client.generate("test") assert resp.success is False assert resp.error is not None @pytest.mark.asyncio async def test_is_available_offline(self): client = LLMClient(LLMConfig(base_url="http://localhost:99999")) available = await client.is_available() assert available is False # --------------------------------------------------------------------------- # LLMBrain # --------------------------------------------------------------------------- class TestLLMBrain: def test_creation(self): brain = LLMBrain(LLMConfig()) assert brain.is_available is False assert brain._query_count == 0 @pytest.mark.asyncio async def test_initialize_offline(self): brain = LLMBrain(LLMConfig(base_url="http://localhost:99999")) result = await brain.initialize() assert result is False assert brain.is_available is False @pytest.mark.asyncio async def test_parse_quest_offline_returns_none(self): brain = LLMBrain() brain._available = False result = await brain.parse_quest("Kill the dragon") assert result is None @pytest.mark.asyncio async def test_parse_quest_with_mock(self): brain = LLMBrain() brain._available = True mock_resp = _make_json_response([ {"type": "kill", "target": "dragon", "count": 1, "location": "cave"} ]) brain.client.ask = AsyncMock(return_value=mock_resp) result = await brain.parse_quest("Slay the dragon in the cave") assert result is not None assert len(result) == 1 assert result[0]["type"] == "kill" @pytest.mark.asyncio async def test_social_reply_offline_returns_none(self): brain = LLMBrain() brain._available = False result = await brain.social_reply("Hello!") assert result is None @pytest.mark.asyncio async def test_social_reply_with_mock(self): brain = LLMBrain() brain._available = True brain.client.ask = AsyncMock( return_value=_make_mock_response("Greetings, fellow adventurer!") ) result = await brain.social_reply("Hello!") assert result is not None assert "adventurer" in result.lower() @pytest.mark.asyncio async def test_suggest_action_offline(self): brain = LLMBrain() brain._available = False from core.game_state import GameState gs = GameState() result = await brain.suggest_action(gs) assert result is None @pytest.mark.asyncio async def test_suggest_action_with_mock(self): brain = LLMBrain() brain._available = True brain.client.ask = AsyncMock( return_value=_make_mock_response("north") ) from core.game_state import GameState, Room gs = GameState() gs.current_room = Room(title="Town Square", exits={"north": 0}) result = await brain.suggest_action(gs) assert result == "north" @pytest.mark.asyncio async def test_parse_ambiguous_offline(self): brain = LLMBrain() brain._available = False result = await brain.parse_ambiguous("Something happens") assert result is None @pytest.mark.asyncio async def test_evaluate_npc_with_mock(self): brain = LLMBrain() brain._available = True brain.client.ask = AsyncMock( return_value=_make_json_response( {"hostile": True, "confidence": 0.9, "reason": "It's a goblin"} ) ) result = await brain.evaluate_npc("goblin", "A dark cave") assert result is not None assert result["hostile"] is True def test_stats(self): brain = LLMBrain() stats = brain.stats() assert stats["queries"] == 0 assert stats["available"] is False # --------------------------------------------------------------------------- # SocialAI # --------------------------------------------------------------------------- class TestSocialAI: def _make_social_ai(self): client = LLMClient(LLMConfig()) return SocialAI(client, character_name="Grok", character_class="warrior") def test_detect_say(self): ai = self._make_social_ai() event = ai.detect_social_event("Gandalf says: Hello there!") assert event is not None assert event["type"] == "say" assert event["speaker"] == "Gandalf" def test_detect_tell(self): ai = self._make_social_ai() event = ai.detect_social_event("Legolas tells you: Follow me") assert event is not None assert event["type"] == "tell" assert event["speaker"] == "Legolas" def test_detect_shout(self): ai = self._make_social_ai() event = ai.detect_social_event("Gimli shouts: For glory!") assert event is not None assert event["type"] == "shout" def test_detect_group_invite(self): ai = self._make_social_ai() event = ai.detect_social_event("Aragorn invites you to join a party") assert event is not None assert event["type"] == "group_invite" def test_detect_no_social(self): ai = self._make_social_ai() event = ai.detect_social_event("The wind howls through the trees.") assert event is None @pytest.mark.asyncio async def test_respond_say(self): ai = self._make_social_ai() ai.client.ask = AsyncMock( return_value=_make_mock_response("Well met, friend!") ) event = {"type": "say", "speaker": "Gandalf", "message": "Hello"} result = await ai.respond(event) assert result is not None assert result.startswith("say ") @pytest.mark.asyncio async def test_respond_tell(self): ai = self._make_social_ai() ai.client.ask = AsyncMock( return_value=_make_mock_response("On my way!") ) event = {"type": "tell", "speaker": "Legolas", "message": "Come here"} result = await ai.respond(event) assert result is not None assert result.startswith("tell Legolas") @pytest.mark.asyncio async def test_respond_group_invite(self): ai = self._make_social_ai() event = {"type": "group_invite", "speaker": "Aragorn", "message": ""} result = await ai.respond(event) assert result == "group accept" @pytest.mark.asyncio async def test_generate_emote(self): ai = self._make_social_ai() ai.client.generate = AsyncMock( return_value=_make_mock_response("sharpens his sword quietly") ) result = await ai.generate_emote("resting at the inn") assert result is not None assert result.startswith("emote ")