"""Unit coverage for SPEC-004 image generation (IMG-01..05).""" import base64 import unittest from unittest.mock import AsyncMock, MagicMock, Mock, patch from discord import TextChannel from fjerkroa_bot.ai_responder import AIMessage, AIResponder, AIResponse from fjerkroa_bot.openai_responder import OpenAIResponder from .test_bdd_envelope import FakeModelResponder, envelope from .test_spec_ops import OpsBase RESPONDER_CONFIG = {"openai-token": "t", "model": "m", "system": "s", "history-limit": 5} def image_api_result(count): return Mock(data=[Mock(b64_json=base64.b64encode(f"png{i}".encode()).decode()) for i in range(count)]) class TestBase64Generation(unittest.IsolatedAsyncioTestCase): async def test_draw_returns_decoded_buffers_and_meters(self): """IMG-01: count images decoded from b64_json, each metered in the ledger.""" responder = OpenAIResponder(RESPONDER_CONFIG, "chat") with patch("fjerkroa_bot.openai_responder.openai_image", new_callable=AsyncMock) as image_mock: image_mock.return_value = image_api_result(2) buffers = await responder.draw_openai("en katt på brygga", 2) self.assertEqual([buf.read() for buf in buffers], [b"png0", b"png1"]) self.assertEqual(responder.ledger.images_today(), 2) self.assertEqual(image_mock.await_args.kwargs["n"], 2) self.assertEqual(image_mock.await_args.kwargs["model"], "gpt-image-2") self.assertNotIn("response_format", image_mock.await_args.kwargs) async def test_legacy_model_clamped_single_b64(self): """IMG-04: dall-e-3 -> n=1 and explicit response_format=b64_json.""" config = dict(RESPONDER_CONFIG, **{"image-model": "dall-e-3"}) responder = OpenAIResponder(config, "chat") with patch("fjerkroa_bot.openai_responder.openai_image", new_callable=AsyncMock) as image_mock: image_mock.return_value = image_api_result(1) buffers = await responder.draw_openai("a cat", 3) self.assertEqual(len(buffers), 1) self.assertEqual(image_mock.await_args.kwargs["n"], 1) self.assertEqual(image_mock.await_args.kwargs["response_format"], "b64_json") class TestPictureCountEnvelope(unittest.IsolatedAsyncioTestCase): async def clamp(self, raw): responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat") payload = {"answer": "ok", "answer_needed": True, "channel": "chat", "picture": "katt"} if raw is not None: payload["picture_count"] = raw return await responder.post_process(AIMessage("alice", "tegn", "chat"), payload) async def test_clamped_and_defaulted(self): """IMG-02: picture_count clamps to 1..4, defaults to 1 when absent.""" self.assertEqual((await self.clamp(3)).picture_count, 3) self.assertEqual((await self.clamp(9)).picture_count, 4) self.assertEqual((await self.clamp(0)).picture_count, 1) self.assertEqual((await self.clamp(None)).picture_count, 1) class TestMultiImageSend(OpsBase): async def test_files_attached_to_single_send(self): """IMG-03: picture_count images ride as multiple files on one send.""" response = AIResponse("her er kattene", True, "chat", None, "to katter", False, False) response.picture_count = 2 import io self.bot.airesponder.draw = AsyncMock(return_value=[io.BytesIO(b"a"), io.BytesIO(b"b")]) channel = MagicMock(spec=TextChannel) channel.send = AsyncMock() await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True) self.bot.airesponder.draw.assert_awaited_once_with("to katter", 2) files = channel.send.await_args.kwargs["files"] self.assertEqual(len(files), 2) class TestNoTranslateStep(unittest.IsolatedAsyncioTestCase): async def test_translate_is_gone_prompt_untouched(self): """IMG-05: no translate() anywhere; the picture prompt survives verbatim.""" responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat") self.assertFalse(hasattr(responder, "translate")) self.assertFalse(hasattr(AIResponder, "translate")) responder.scripted.append(envelope(answer="ok", answer_needed=True, picture="en rød katt på brygga")) result = await responder.send(AIMessage("alice", "tegn en katt", "chat")) self.assertEqual(result.picture, "en rød katt på brygga")