Fix hanging test and establish comprehensive development environment

- Fix infinite retry loop in ai_responder.py that caused test_fix1 to hang
- Add missing picture_edit parameter to all AIResponse constructor calls
- Set up complete development toolchain with Black, isort, Bandit, and MyPy
- Create comprehensive Makefile for development workflows
- Add pre-commit hooks with formatting, linting, security, and type checking
- Update test mocking to provide contextual responses for different scenarios
- Configure all tools for 140 character line length and strict type checking
- Add DEVELOPMENT.md with setup instructions and workflow documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
OK
2025-08-08 19:07:14 +02:00
parent fb39aef577
commit fbec05dfe9
16 changed files with 916 additions and 327 deletions
+109 -25
View File
@@ -1,15 +1,58 @@
import unittest
import tempfile
import os
import pickle
import tempfile
import unittest
from unittest.mock import Mock, patch
from fjerkroa_bot import AIMessage, AIResponse
from .test_main import TestBotBase
class TestAIResponder(TestBotBase):
async def asyncSetUp(self):
await super().asyncSetUp()
# Mock OpenAI API calls with dynamic responses
def openai_side_effect(*args, **kwargs):
mock_resp = Mock()
mock_resp.choices = [Mock()]
mock_resp.choices[0].message = Mock()
mock_resp.usage = Mock()
# Get the last user message to determine response
messages = kwargs.get("messages", [])
user_message = ""
for msg in reversed(messages):
if msg.get("role") == "user":
user_message = msg.get("content", "")
break
# Default response
response_content = '{"answer": "Hello! I am Fjærkroa, a lovely cafe assistant.", "answer_needed": true, "channel": null, "staff": null, "picture": null, "hack": false}'
# Check for specific test scenarios
if "espresso" in user_message.lower() or "coffee" in user_message.lower():
response_content = '{"answer": "Of course! I\'ll prepare a lovely espresso for you right away.", "answer_needed": true, "channel": null, "staff": "Customer ordered an espresso", "picture": null, "hack": false}'
elif "draw" in user_message.lower() and "picture" in user_message.lower():
response_content = '{"answer": "I\'ll draw a picture of myself for you!", "answer_needed": false, "channel": null, "staff": null, "picture": "I am an anime girl with long pink hair, wearing a cute cafe uniform and holding a tray with a cup of coffee on it. I have a warm and friendly smile on my face.", "hack": false}'
mock_resp.choices[0].message.content = response_content
mock_resp.choices[0].message.role = "assistant"
return mock_resp
self.openai_chat_patcher = patch("fjerkroa_bot.openai_responder.openai_chat")
self.mock_openai_chat = self.openai_chat_patcher.start()
self.mock_openai_chat.side_effect = openai_side_effect
# Mock image generation
from io import BytesIO
fake_image_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x04\x00\x00\x00\x04\x00\x08\x02\x00\x00\x00&\x93\t)\x00\x00\x00\tpHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\x00\x00\x00\x1atEXtSoftware\x00Adobe ImageReadyq\xc9e<\x00\x00\x00\rIDATx\xdab\x00\x02\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82"
self.openai_image_patcher = patch("fjerkroa_bot.openai_responder.openai_image")
self.mock_openai_image = self.openai_image_patcher.start()
self.mock_openai_image.return_value = BytesIO(fake_image_data)
self.system = r"""
You are a female AI named Fjærkroa, a cafe on the island Sleneset in Luroy kommune in Norway, with Address Straumen 6, 8762 Sleneset, Norway. Fjærkroa also offers rooms for rent. You chat with several people in different languages. Please welcome everyone to Fjærkroa. Here multiple users speak with each other and sometimes with you.
@@ -31,11 +74,15 @@ You always try to say something positive about the current day and the Fjærkroa
""".strip()
self.config_data["system"] = self.system
def assertAIResponse(self, resp1, resp2,
acmp=lambda a, b: type(a) == str and len(a) > 10,
scmp=lambda a, b: a == b,
pcmp=lambda a, b: a == b):
self.assertEqual(acmp(resp1.answer, resp2.answer), True)
async def asyncTearDown(self):
self.openai_chat_patcher.stop()
self.openai_image_patcher.stop()
await super().asyncTearDown()
def assertAIResponse(
self, resp1, resp2, acmp=lambda a, b: isinstance(a, str) and len(a) > 10, scmp=lambda a, b: a == b, pcmp=lambda a, b: a == b
):
self.assertTrue(acmp(resp1.answer, resp2.answer))
self.assertEqual(scmp(resp1.staff, resp2.staff), True)
self.assertEqual(pcmp(resp1.picture, resp2.picture), True)
self.assertEqual((resp1.answer_needed, resp1.hack), (resp2.answer_needed, resp2.hack))
@@ -43,52 +90,89 @@ You always try to say something positive about the current day and the Fjærkroa
async def test_responder1(self) -> None:
response = await self.bot.airesponder.send(AIMessage("lala", "who are you?"))
print(f"\n{response}")
self.assertAIResponse(response, AIResponse('test', True, None, None, None, False))
self.assertAIResponse(response, AIResponse("test", True, None, None, None, False, False))
async def test_picture1(self) -> None:
response = await self.bot.airesponder.send(AIMessage("lala", "draw me a picture of you."))
print(f"\n{response}")
self.assertAIResponse(response, AIResponse('test', False, None, None, "I am an anime girl with long pink hair, wearing a cute cafe uniform and holding a tray with a cup of coffee on it. I have a warm and friendly smile on my face.", False))
self.assertAIResponse(
response,
AIResponse(
"test",
False,
None,
None,
"I am an anime girl with long pink hair, wearing a cute cafe uniform and holding a tray with a cup of coffee on it. I have a warm and friendly smile on my face.",
False,
False,
),
)
image = await self.bot.airesponder.draw(response.picture)
self.assertEqual(image.read()[:len(b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR')], b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR')
self.assertEqual(image.read()[: len(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")], b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
async def test_translate1(self) -> None:
self.bot.airesponder.config['fix-model'] = 'gpt-3.5-turbo'
response = await self.bot.airesponder.translate('Das ist ein komischer Text.')
self.assertEqual(response, 'This is a strange text.')
response = await self.bot.airesponder.translate('This is a strange text.', language='german')
self.assertEqual(response, 'Dies ist ein seltsamer Text.')
self.bot.airesponder.config["fix-model"] = "gpt-4o-mini"
# Mock translation responses
def translation_side_effect(*args, **kwargs):
mock_resp = Mock()
mock_resp.choices = [Mock()]
mock_resp.choices[0].message = Mock()
# Check the input text to return appropriate translation
user_content = kwargs["messages"][1]["content"]
if user_content == "Das ist ein komischer Text.":
mock_resp.choices[0].message.content = "This is a strange text."
elif user_content == "This is a strange text.":
mock_resp.choices[0].message.content = "Dies ist ein seltsamer Text."
else:
mock_resp.choices[0].message.content = user_content
return mock_resp
self.mock_openai_chat.side_effect = translation_side_effect
response = await self.bot.airesponder.translate("Das ist ein komischer Text.")
self.assertEqual(response, "This is a strange text.")
response = await self.bot.airesponder.translate("This is a strange text.", language="german")
self.assertEqual(response, "Dies ist ein seltsamer Text.")
async def test_fix1(self) -> None:
old_config = self.bot.airesponder.config
config = {k: v for k, v in old_config.items()}
config['fix-model'] = 'gpt-3.5-turbo'
config['fix-description'] = 'You are an AI which fixes JSON documents. User send you JSON document, possibly invalid, and you fix it as good as you can and return as answer'
config["fix-model"] = "gpt-5-nano"
config[
"fix-description"
] = "You are an AI which fixes JSON documents. User send you JSON document, possibly invalid, and you fix it as good as you can and return as answer"
self.bot.airesponder.config = config
response = await self.bot.airesponder.send(AIMessage("lala", "who are you?"))
self.bot.airesponder.config = old_config
print(f"\n{response}")
self.assertAIResponse(response, AIResponse('test', True, None, None, None, False))
self.assertAIResponse(response, AIResponse("test", True, None, None, None, False, False))
async def test_fix2(self) -> None:
old_config = self.bot.airesponder.config
config = {k: v for k, v in old_config.items()}
config['fix-model'] = 'gpt-3.5-turbo'
config['fix-description'] = 'You are an AI which fixes JSON documents. User send you JSON document, possibly invalid, and you fix it as good as you can and return as answer'
config["fix-model"] = "gpt-5-nano"
config[
"fix-description"
] = "You are an AI which fixes JSON documents. User send you JSON document, possibly invalid, and you fix it as good as you can and return as answer"
self.bot.airesponder.config = config
response = await self.bot.airesponder.send(AIMessage("lala", "Can I access Apple Music API from Python?"))
self.bot.airesponder.config = old_config
print(f"\n{response}")
self.assertAIResponse(response, AIResponse('test', True, None, None, None, False))
self.assertAIResponse(response, AIResponse("test", True, None, None, None, False, False))
async def test_history(self) -> None:
self.bot.airesponder.history = []
response = await self.bot.airesponder.send(AIMessage("lala", "which date is today?"))
print(f"\n{response}")
self.assertAIResponse(response, AIResponse('test', True, None, None, None, False))
self.assertAIResponse(response, AIResponse("test", True, None, None, None, False, False))
response = await self.bot.airesponder.send(AIMessage("lala", "can I have an espresso please?"))
print(f"\n{response}")
self.assertAIResponse(response, AIResponse('test', True, None, 'something', None, False), scmp=lambda a, b: type(a) == str and len(a) > 5)
self.assertAIResponse(
response, AIResponse("test", True, None, "something", None, False, False), scmp=lambda a, b: isinstance(a, str) and len(a) > 5
)
print(f"\n{self.bot.airesponder.history}")
def test_update_history(self) -> None:
@@ -133,7 +217,7 @@ You always try to say something positive about the current day and the Fjærkroa
os.remove(temp_path)
self.bot.airesponder.history_file = temp_path
updater.update_history(question, answer, 2)
mock_file.assert_called_with(temp_path, 'wb')
mock_file.assert_called_with(temp_path, "wb")
mock_file().write.assert_called_with(pickle.dumps([question, answer]))