fix defects D1-D12 batch 1, structured envelope, saf gates + ops kill-switches
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user