Files
discord_bot/tests/test_bdd_envelope.py

148 lines
5.1 KiB
Python

"""BDD steps for features/envelope.feature (SPEC-001, ENV-01..09).
Scenarios drive AIResponder.send() through a FakeModelResponder with
scripted model output — no live OpenAI, no live Discord (D-002).
"""
import asyncio
import json
from typing import Any, Dict, List, Optional, Tuple
from pytest_bdd import given, parsers, scenarios, then, when
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
scenarios("../features/envelope.feature")
def envelope(answer=None, answer_needed=False, channel="chat", staff=None, picture=None, picture_edit=False, hack=False) -> str:
return json.dumps(
{
"answer": answer,
"answer_needed": answer_needed,
"channel": channel,
"staff": staff,
"picture": picture,
"picture_edit": picture_edit,
"hack": hack,
}
)
class FakeModelResponder(AIResponder):
"""AIResponder with the model calls scripted away (D-002)."""
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__(config, channel)
self.scripted: List[str] = []
self.chat_calls = 0
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
self.chat_calls += 1
if not self.scripted:
return None, limit
return {"role": "assistant", "content": self.scripted.pop(0)}, limit
async def consolidate(self, observations, known_facts):
return {"facts": [], "episode": None}
async def classify(self, message, history_tail):
return getattr(self, "scripted_classification", None)
@given(parsers.parse("a responder with history limit {limit:d}"), target_fixture="responder")
def responder(limit):
config = {"system": "You are a test bot. {memory}", "history-limit": limit}
return FakeModelResponder(config, "chat")
@given(parsers.parse('the model answers with answer "{answer}" and answer_needed "{needed}"'))
def script_answer(responder, answer, needed):
responder.scripted.append(envelope(answer=answer, answer_needed=needed == "true"))
@given(parsers.parse('the model answers with answer "{answer}" and staff note "{staff}"'))
def script_staff(responder, answer, staff):
responder.scripted.append(envelope(answer=answer, answer_needed=False, staff=staff))
@given(parsers.parse('the model answers with answer "{answer}" and channel "{channel}"'))
def script_channel(responder, answer, channel):
responder.scripted.append(envelope(answer=answer, answer_needed=True, channel=channel))
@given(parsers.parse('the model answers with answer "{answer}" and no channel'))
def script_channel_none(responder, answer):
responder.scripted.append(envelope(answer=answer, answer_needed=True, channel=None))
@given(parsers.parse('a short-path rule for channels "{chan_re}" and users "{user_re}"'))
def short_path_rule(responder, chan_re, user_re):
responder.config["short-path"] = [[chan_re, user_re]]
@given(parsers.parse('{count:d} prior history entries in channel "{channel}"'))
def prior_history(responder, count, channel):
for i in range(count):
responder.history.append({"role": "user", "content": json.dumps({"message": f"old {i}", "channel": channel})})
@when(parsers.parse('user "{user}" sends "{text}" in channel "{channel}"'), target_fixture="response")
def send_message(responder, user, text, channel):
return asyncio.run(responder.send(AIMessage(user, text, channel)))
@when(parsers.parse('user "{user}" sends "{text}" directly to the bot'), target_fixture="response")
def send_direct(responder, user, text):
return asyncio.run(responder.send(AIMessage(user, text, "chat", direct=True)))
@then(parsers.parse('the response answer contains "{text}"'))
def answer_contains(response, text):
assert response.answer is not None and text in response.answer
@then(parsers.parse('the response answer does not contain "{text}"'))
def answer_not_contains(response, text):
assert response.answer is not None and text not in response.answer
@then("the response is marked as needed")
def is_needed(response):
assert response.answer_needed is True
@then("the response is not marked as needed")
def not_needed(response):
assert response.answer_needed is False
@then(parsers.parse('the response staff note is "{text}"'))
def staff_note_is(response, text):
assert response.staff == text
@then(parsers.parse('the response channel is "{channel}"'))
def channel_is(response, channel):
assert response.channel == channel
@then("the model was not called")
def model_not_called(responder):
assert responder.chat_calls == 0
@then("the response is empty")
def response_empty(response):
assert response.answer is None and response.answer_needed is False
@then(parsers.parse('the history contains the message from "{user}"'))
def history_has_user(responder, user):
assert any(f'"user": "{user}"' in item["content"] for item in responder.history)
@then(parsers.parse("the history length is at most {limit:d}"))
def history_at_most(responder, limit):
assert len(responder.history) <= limit