133 lines
5.7 KiB
Python
133 lines
5.7 KiB
Python
"""Unit coverage for the FDB-004 defect-fix requirements (ENV-12..17)."""
|
|
|
|
import threading
|
|
import unittest
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
|
|
import httpx
|
|
import openai
|
|
from discord import Message, TextChannel
|
|
|
|
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
|
|
from fjerkroa_bot.openai_responder import OpenAIResponder, openai_chat
|
|
|
|
from .test_bdd_envelope import FakeModelResponder, envelope
|
|
from .test_main import TestBotBase
|
|
|
|
|
|
def make_entry(channel: str, text: str = "x"):
|
|
import json
|
|
|
|
return {"role": "user", "content": json.dumps({"message": text, "channel": channel})}
|
|
|
|
|
|
class TestSendBackoff(unittest.IsolatedAsyncioTestCase):
|
|
async def test_send_sleeps_between_retries(self):
|
|
"""ENV-12: failed attempts are separated by exponential-backoff sleeps (D1)."""
|
|
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
|
|
with patch("fjerkroa_bot.ai_responder.asyncio.sleep", new_callable=AsyncMock) as sleep:
|
|
with self.assertRaises(RuntimeError):
|
|
await responder.send(AIMessage("alice", "hei", "chat"))
|
|
self.assertGreaterEqual(sleep.await_count, 2)
|
|
for call in sleep.await_args_list:
|
|
self.assertGreater(call.args[0], 0)
|
|
|
|
|
|
class TestReactionClear(TestBotBase):
|
|
async def test_on_reaction_clear_discord_signature(self):
|
|
"""ENV-13: on_reaction_clear(message, reactions) memoizes the clearing (D7)."""
|
|
message = MagicMock(spec=Message)
|
|
message.content = "Some message text"
|
|
message.author.name = "alice"
|
|
message.channel = MagicMock(spec=TextChannel)
|
|
self.bot.airesponder.memoize = AsyncMock()
|
|
await self.bot.on_reaction_clear(message, [Mock()])
|
|
self.bot.airesponder.memoize.assert_awaited_once()
|
|
|
|
|
|
class TestUpdateMemory(unittest.TestCase):
|
|
def test_update_memory_uses_argument(self):
|
|
"""ENV-14: update_memory persists the passed value, not stale state (D12)."""
|
|
responder = AIResponder({"system": "s", "history-limit": 5}, "chat")
|
|
responder.update_memory("NEW MEMORY")
|
|
self.assertEqual(responder.memory, "NEW MEMORY")
|
|
|
|
|
|
class TestRetryModel(unittest.IsolatedAsyncioTestCase):
|
|
async def test_retry_model_used_after_rate_limit(self):
|
|
"""ENV-15: the attempt after a rate limit uses retry-model, then switches back (D2)."""
|
|
config = {
|
|
"openai-token": "test",
|
|
"model": "main-model",
|
|
"retry-model": "fallback-model",
|
|
"system": "s",
|
|
"history-limit": 5,
|
|
}
|
|
responder = OpenAIResponder(config, "chat")
|
|
rate_limit = openai.RateLimitError(
|
|
"rate limited", response=httpx.Response(429, request=httpx.Request("POST", "http://test")), body=None
|
|
)
|
|
|
|
def ok_result():
|
|
message = Mock(content=envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None)
|
|
return Mock(choices=[Mock(message=message)], usage="usage")
|
|
|
|
with (
|
|
patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock,
|
|
patch("asyncio.sleep", new_callable=AsyncMock),
|
|
):
|
|
chat_mock.side_effect = [rate_limit, ok_result(), ok_result()]
|
|
messages = [{"role": "user", "content": "hi"}]
|
|
first, _ = await responder.chat(list(messages), 10)
|
|
self.assertIsNone(first)
|
|
second, _ = await responder.chat(list(messages), 10)
|
|
self.assertIsNotNone(second)
|
|
third, _ = await responder.chat(list(messages), 10)
|
|
self.assertIsNotNone(third)
|
|
self.assertEqual(chat_mock.await_args_list[0].kwargs["model"], "main-model")
|
|
self.assertEqual(chat_mock.await_args_list[1].kwargs["model"], "fallback-model")
|
|
self.assertEqual(chat_mock.await_args_list[2].kwargs["model"], "main-model")
|
|
|
|
|
|
class TestNoDiskCache(unittest.IsolatedAsyncioTestCase):
|
|
async def test_chat_hits_client_every_time(self):
|
|
"""ENV-16: identical chat calls reach the client every time — no pickle cache (D3)."""
|
|
client = Mock()
|
|
client.chat.completions.create = AsyncMock(return_value="RESPONSE")
|
|
kwargs = {"model": "m", "messages": [{"role": "user", "content": "hi"}]}
|
|
await openai_chat(client, **kwargs)
|
|
await openai_chat(client, **kwargs)
|
|
self.assertEqual(client.chat.completions.create.await_count, 2)
|
|
|
|
|
|
class TestIgdbOffEventLoop(unittest.IsolatedAsyncioTestCase):
|
|
async def test_igdb_search_runs_in_worker_thread(self):
|
|
"""ENV-17: IGDB lookups run in a worker thread, results unchanged (D5)."""
|
|
responder = OpenAIResponder({"openai-token": "test", "model": "m", "system": "s", "history-limit": 5}, "chat")
|
|
responder.igdb = Mock()
|
|
calling_thread = {}
|
|
|
|
def capture_search(query, limit):
|
|
calling_thread["thread"] = threading.current_thread()
|
|
return [{"name": "Zelda"}]
|
|
|
|
responder.igdb.search_games = capture_search
|
|
result = await responder._execute_igdb_function("search_games", {"query": "zelda"})
|
|
self.assertEqual(result, {"games": [{"name": "Zelda"}]})
|
|
self.assertIsNot(calling_thread["thread"], threading.main_thread())
|
|
|
|
|
|
class TestShrinkTolerance(unittest.TestCase):
|
|
def test_shrink_tolerates_non_json_entries(self):
|
|
"""ENV-11: non-JSON history entries do not crash shrinking (D10 rewrite)."""
|
|
responder = AIResponder({"system": "s", "history-limit": 4}, "chat")
|
|
responder.history = [
|
|
{"role": "user", "content": "plain, not json"},
|
|
make_entry("a", "a0"),
|
|
make_entry("a", "a1"),
|
|
make_entry("a", "a2"),
|
|
make_entry("a", "a3"),
|
|
]
|
|
responder.shrink_history_by_one()
|
|
self.assertEqual(len(responder.history), 4)
|