Files
AIPLAYER/tests/test_llm.py
T
truetype bd211b6036 Implement phases 3-4 + all modules: decision engine, automapper, character creator, quest system, progression optimizer, and hybrid LLM integration
- Phase 4 (Decision Engine): ActionQueue with 7 priority tiers,
  4 behavior states (Idle/Explore/Combat/Loot), CombatAI with skill
  rotation and flee logic, GoalManager with survival override,
  DecisionEngine state machine integrated into the main game loop

- Phase 3 (Automapper): Complete hybrid SQLite/Memvid architecture
  with WorldMapper orchestrator, room fingerprinting (SHA-256),
  bidirectional exit linking, exploration queue, A* pathfinding,
  HistoricalStorage with JSON fallback, SemanticSearch with keyword
  fallback, SyncManager with configurable triggers

- Character Creator: State machine for automated character creation
  with stat rolling/optimization per class weights, race/class/gender
  selection, reroll logic with configurable thresholds

- Quest System: Quest lifecycle tracking (discover->accept->complete),
  NLP-based objective extraction (kill/fetch/talk/explore), EXP/time
  prioritization, pending kill target tracking

- Progression Optimizer: Grinding spot finder (EXP/hour), equipment
  comparison by weighted stats, skill rotation optimizer (DPS vs
  mana efficiency), session summary statistics

- LLM Integration (hybrid): Async Ollama client with retry/backoff,
  LLMBrain coordinator for quest parsing, social AI, decision advice,
  and ambiguous text parsing fallback. SocialAI for detecting and
  responding to player messages (say/tell/shout/group/trade).
  Graceful degradation when Ollama is unavailable.

All 214 tests passing.
2026-02-08 20:55:53 +01:00

309 lines
10 KiB
Python

"""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 ")