"""Unit coverage for SPEC-010 human behavior (BEH-01..08) + ENV-20, SAF-10, OPS-11.""" import hashlib import tempfile import unittest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch from discord import DMChannel, TextChannel from fjerkroa_bot.ai_responder import AIMessage, AIResponse from fjerkroa_bot.discord_bot import quiet_hours_active, split_answer from fjerkroa_bot.openai_responder import OpenAIResponder from fjerkroa_bot.persistence import PersistentStore from .test_bdd_envelope import FakeModelResponder, envelope from .test_spec_ops import OpsBase class ClassifierGateBase(OpsBase): def gate_setup(self, verdict): self.bot.config["classifier-model"] = "gpt-5.6-luna" self.bot.airesponder.classify = AsyncMock(return_value=verdict) self.bot.respond = AsyncMock() class TestClassifierGate(ClassifierGateBase): async def test_no_reply_means_no_model_call(self): """BEH-01: classifier reply=false on a non-direct message -> respond() never runs.""" self.gate_setup({"reply": False, "factual": False, "emoji": None}) await self.bot.on_message(self.public_msg("just chatting with bob")) self.bot.airesponder.classify.assert_awaited_once() self.bot.respond.assert_not_awaited() async def test_reply_true_passes_through_with_factual_flag(self): """BEH-01: reply=true proceeds; factual flag is forwarded.""" self.gate_setup({"reply": True, "factual": True, "emoji": None}) await self.bot.on_message(self.public_msg("når har dere åpent?")) self.bot.respond.assert_awaited_once() self.assertTrue(self.bot.respond.await_args.kwargs.get("factual")) async def test_direct_message_bypasses_gate(self): """BEH-02: DMs never touch the classifier.""" self.gate_setup({"reply": False, "factual": False, "emoji": None}) message = self.public_msg("hei bot") message.channel = MagicMock(spec=DMChannel) message.channel.recipient = None await self.bot.on_message(message) self.bot.airesponder.classify.assert_not_awaited() self.bot.respond.assert_awaited_once() async def test_classifier_failure_fails_open(self): """BEH-03: classify() -> None falls through to the main model.""" self.gate_setup(None) await self.bot.on_message(self.public_msg("hello?")) self.bot.respond.assert_awaited_once() async def test_reaction_instead_of_reply(self): """BEH-07: reply=false + emoji -> reaction on the message, no model call.""" self.gate_setup({"reply": False, "factual": False, "emoji": "👍"}) message = self.public_msg("gg everyone") message.add_reaction = AsyncMock() await self.bot.on_message(message) message.add_reaction.assert_awaited_once_with("👍") self.bot.respond.assert_not_awaited() class TestIgnoredChannels(ClassifierGateBase): def ignored_msg(self, channel_name): message = self.public_msg("hello there") message.channel.name = channel_name message.add_reaction = AsyncMock() return message async def test_pattern_match_suppresses_reaction_and_reply(self): """BEH-09: fnmatch pattern hit -> no classifier call, no emoji, no reply.""" self.gate_setup({"reply": False, "factual": False, "emoji": "👍"}) self.bot.config["ignore-channels"] = ["todo*"] message = self.ignored_msg("todo-lists") await self.bot.on_message(message) self.bot.airesponder.classify.assert_not_awaited() message.add_reaction.assert_not_awaited() self.bot.respond.assert_not_awaited() async def test_exact_name_still_matches(self): """BEH-09: plain names keep working as exact matches.""" self.gate_setup({"reply": True, "factual": False, "emoji": None}) self.bot.config["ignore-channels"] = ["blengon"] await self.bot.on_message(self.ignored_msg("blengon")) self.bot.respond.assert_not_awaited() async def test_non_matching_channel_passes(self): """BEH-09: unmatched channels reach the responder as before.""" self.gate_setup({"reply": True, "factual": False, "emoji": None}) self.bot.config["ignore-channels"] = ["todo*"] await self.bot.on_message(self.ignored_msg("chat")) self.bot.respond.assert_awaited_once() async def test_dm_never_ignored(self): """BEH-09: a DM whose recipient name matches a pattern is still answered.""" self.gate_setup({"reply": True, "factual": False, "emoji": None}) self.bot.config["ignore-channels"] = ["todo*"] message = self.public_msg("hei bot") message.channel = MagicMock(spec=DMChannel) message.channel.recipient = MagicMock() message.channel.recipient.name = "todo-fan" await self.bot.on_message(message) self.bot.respond.assert_awaited_once() def test_channel_by_name_honors_patterns(self): """BEH-09: channel_by_name resolution skips pattern-ignored channels.""" self.bot.config["ignore-channels"] = ["todo*"] fallback = MagicMock(spec=TextChannel) self.assertIs(self.bot.channel_by_name("todo-lists", fallback), fallback) class TestFactualModel(unittest.IsolatedAsyncioTestCase): async def _model_used(self, config, factual): from .test_spec_structured import ok_result responder = OpenAIResponder(dict({"openai-token": "t", "model": "cheap", "system": "s", "history-limit": 5}, **config), "chat") responder._factual = factual with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock: chat_mock.return_value = ok_result() await responder.chat([{"role": "user", "content": "hi"}], 10) return chat_mock.await_args.kwargs["model"] async def test_factual_uses_stronger_model(self): """BEH-10: factual verdict + factual-model config -> stronger tier.""" self.assertEqual(await self._model_used({"factual-model": "strong"}, True), "strong") async def test_factual_without_config_stays_default(self): """BEH-10: no factual-model config -> default model, no behavior change.""" self.assertEqual(await self._model_used({}, True), "cheap") async def test_small_talk_stays_default(self): """BEH-10: non-factual messages stay on the cheap default.""" self.assertEqual(await self._model_used({"factual-model": "strong"}, False), "cheap") async def test_send_reads_flag_from_message(self): """BEH-10: send() picks the factual flag off the AIMessage.""" responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat") responder.scripted = [envelope(answer="x", answer_needed=True)] message = AIMessage("alice", "opening hours?", "chat") message.factual = True await responder.send(message) self.assertTrue(responder._factual) class TestTypingPacing(OpsBase): async def send_with(self, answer, factual, cps=30): if cps is not None: self.bot.config["typing-chars-per-second"] = cps response = AIResponse(answer, True, "chat", None, None, False, False) channel = MagicMock(spec=TextChannel) channel.send = AsyncMock() with patch("fjerkroa_bot.discord_bot.asyncio.sleep", new_callable=AsyncMock) as sleep: await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=factual) return sleep, channel async def test_delay_proportional_and_capped(self): """BEH-04: delay = len/cps capped at typing-max-seconds.""" sleep, _ = await self.send_with("x" * 300, factual=False, cps=30) sleep.assert_awaited_once_with(8.0) # 300/30=10 -> cap 8 async def test_factual_skips_delay(self): """BEH-05: factual answers go out instantly.""" sleep, _ = await self.send_with("x" * 300, factual=True, cps=30) sleep.assert_not_awaited() async def test_no_knob_no_delay(self): """BEH-04: without typing-chars-per-second there is no pacing.""" sleep, _ = await self.send_with("x" * 300, factual=False, cps=None) sleep.assert_not_awaited() class TestSplitting(unittest.TestCase): def test_split_at_paragraphs_under_limit(self): """BEH-06: long answers split at paragraph boundaries, each under 2000.""" text = "\n\n".join(["Avsnitt " + str(i) + " " + "x" * 700 for i in range(4)]) parts = split_answer(text, threshold=1200, max_parts=3) self.assertGreaterEqual(len(parts), 2) self.assertLessEqual(len(parts), 3) for part in parts: self.assertLessEqual(len(part), 2000) self.assertEqual("\n\n".join(parts).replace("\n\n", ""), text.replace("\n\n", "")) def test_short_answers_untouched(self): """BEH-06: short answers stay a single message.""" self.assertEqual(split_answer("kort svar", 1200, 3), ["kort svar"]) def test_oversized_single_block_hard_split(self): """BEH-06: a single block over 2000 chars is hard-split under the Discord limit.""" parts = split_answer("y" * 4500, 1200, 3) for part in parts: self.assertLessEqual(len(part), 2000) self.assertEqual(sum(len(p) for p in parts), 4500) class TestSplitSends(OpsBase): async def test_parts_sent_in_order_files_last(self): """BEH-06: parts sent sequentially; image files ride on the last part.""" self.bot.config["split-threshold"] = 50 answer = "Første del.\n\nAndre del som også er ganske lang her." response = AIResponse(answer, True, "chat", None, "a cat", False, False) self.bot.airesponder.draw = AsyncMock(return_value=[__import__("io").BytesIO(b"png")]) channel = MagicMock(spec=TextChannel) channel.send = AsyncMock() await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True) self.assertEqual(channel.send.await_count, 2) first_kwargs = channel.send.await_args_list[0].kwargs last_kwargs = channel.send.await_args_list[1].kwargs self.assertIsNone(first_kwargs.get("files")) self.assertIsNotNone(last_kwargs.get("files")) class TestQuietHours(OpsBase): def test_quiet_hours_parsing(self): """BEH-08: window logic incl. midnight wrap.""" self.assertTrue(quiet_hours_active("23:00-08:00", "23:30")) self.assertTrue(quiet_hours_active("23:00-08:00", "07:59")) self.assertFalse(quiet_hours_active("23:00-08:00", "12:00")) self.assertTrue(quiet_hours_active("13:00-15:00", "14:00")) self.assertFalse(quiet_hours_active("13:00-15:00", "15:00")) self.assertFalse(quiet_hours_active(None, "14:00")) self.assertFalse(quiet_hours_active("garbage", "14:00")) async def test_quiet_hours_block_bot_initiated(self): """BEH-08: inside the window bot_initiated_allowed is false, replies unaffected.""" self.bot.config["quiet-hours"] = "00:00-23:59" self.assertFalse(self.bot.bot_initiated_allowed()) self.assertTrue(self.bot.replies_allowed()) class TestPromptPrefixStability(unittest.IsolatedAsyncioTestCase): def test_persona_prefix_stable_and_context_suffix(self): """ENV-20 + ENV-10: byte-stable persona prefix; date/memory in the context suffix.""" config = {"system": "Du er Fjærkroa. I dag er {date} kl {time}. {news} {memory}", "history-limit": 5} responder = FakeModelResponder(config, "chat") responder.memory = "MEMSTR" first = responder.message(AIMessage("alice", "hei"))[0]["content"] second = responder.message(AIMessage("bob", "hallo"))[0]["content"] self.assertIn("## Context", first) prefix_one = first.split("## Context")[0] prefix_two = second.split("## Context")[0] self.assertEqual(prefix_one, prefix_two) self.assertNotIn("{date}", first) self.assertNotIn("{memory}", first) suffix = first.split("## Context")[1] self.assertIn("MEMSTR", suffix) import time as _time self.assertIn(_time.strftime("%Y-%m-%d"), suffix) def test_missing_news_file_no_placeholder(self): """CFG-03 (revised): missing news file -> no literal placeholder, no crash.""" config = {"system": "N: {news}", "history-limit": 5, "news": "/nonexistent/news.txt"} responder = FakeModelResponder(config, "chat") system = responder.message(AIMessage("alice", "hei"))[0]["content"] self.assertNotIn("{news}", system) self.assertNotIn("news:", system.split("## Context")[1]) class TestSafetyIdentifier(unittest.IsolatedAsyncioTestCase): async def test_chat_carries_hashed_user(self): """SAF-10: chat calls pass safety_identifier = sha256(user)[:16], never the raw name.""" responder = OpenAIResponder({"openai-token": "t", "model": "m", "system": "s", "history-limit": 5}, "chat") message = Mock(content=envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None, refusal=None) with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock: chat_mock.return_value = Mock(choices=[Mock(message=message)], usage="usage") payload = '{"user": "alice", "message": "hei", "channel": "chat", "direct": false, "historise_question": true}' await responder.chat([{"role": "user", "content": payload}], 10) identifier = chat_mock.await_args.kwargs.get("safety_identifier") expected = "discord-" + hashlib.sha256(b"alice").hexdigest()[:16] self.assertEqual(identifier, expected) self.assertNotIn("alice", identifier) class TestPinsListing(OpsBase): async def test_pins_command_lists_ids(self): """OPS-11: !bot pins lists pinned facts with ids.""" with tempfile.TemporaryDirectory() as tmp: store = PersistentStore(Path(tmp) / "bot.db") self.bot.airesponder.store = store store.add_pinned(None, "Åpningstider: tirsdag-fredag 12-17") store.add_pinned("chat", "Kanalregel: norsk") await self.bot.on_message(self.staff_msg("!bot pins")) listing = self.bot.staff_channel.send.await_args.args[0] self.assertIn("Åpningstider", listing) self.assertIn("Kanalregel", listing) self.assertIn("1", listing) self.assertIn("2", listing)