Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""Regression tests for chat API, permissions, concurrency and cleanup."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
BASE = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
|
||||
|
||||
from chat import engine
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status, payload=None):
|
||||
self.status = status
|
||||
self.payload = payload or {}
|
||||
async def json(self):
|
||||
return self.payload
|
||||
async def text(self):
|
||||
return json.dumps(self.payload)
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, responses):
|
||||
self.responses = responses
|
||||
self.payloads = []
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
def post(self, url, **kwargs):
|
||||
self.payloads.append(kwargs["json"])
|
||||
response = self.responses.pop(0)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
|
||||
|
||||
class ChatRegressionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_official_retry_preserves_multimodal_and_reasoning(self):
|
||||
responses = [
|
||||
FakeResponse(500, {"error": "temporary"}),
|
||||
FakeResponse(200, {"choices": [{"message": {"content": "ok"}}]}),
|
||||
]
|
||||
sessions = []
|
||||
|
||||
def factory():
|
||||
session = FakeSession(responses)
|
||||
sessions.append(session)
|
||||
return session
|
||||
|
||||
content = [{"type": "text", "text": "task"}]
|
||||
with patch.object(engine.aiohttp, "ClientSession", side_effect=factory), \
|
||||
patch.object(engine.asyncio, "sleep", new=AsyncMock()):
|
||||
result = await engine._call_deepseek_official(
|
||||
"system", content, 0, presence_penalty=0.3, reasoning=True)
|
||||
self.assertEqual(result, "ok")
|
||||
self.assertEqual(len(sessions), 2)
|
||||
for session in sessions:
|
||||
self.assertEqual(session.payloads[0]["messages"][1]["content"], content)
|
||||
self.assertEqual(session.payloads[0]["thinking"], {"type": "enabled"})
|
||||
self.assertEqual(session.payloads[0]["presence_penalty"], 0.3)
|
||||
|
||||
async def test_json_structure_is_validated(self):
|
||||
valid = '{"message":[{"content":"ok"}]}'
|
||||
self.assertIsNotNone(engine.parse_json_response(valid))
|
||||
for raw in ('{"message":["x"]}', '{"message":[null]}',
|
||||
'{"message":[{}]}', '{"message":[{"content":1}]}'):
|
||||
self.assertIsNone(engine.parse_json_response(raw))
|
||||
|
||||
async def test_mixed_group_batch_disables_actor_tools(self):
|
||||
engine._group_pending_messages[1] = ["普通用户请求", "管理员插话"]
|
||||
engine._group_pending_users[1] = [2, 1]
|
||||
engine._group_pending_images[1] = []
|
||||
reply = AsyncMock()
|
||||
with patch.object(engine, "_call_group_model_and_reply", new=reply), \
|
||||
patch.object(engine.random, "uniform", return_value=0):
|
||||
await engine._process_group_messages(1)
|
||||
self.assertEqual(reply.await_args.args[3], 0)
|
||||
|
||||
async def test_private_turns_are_serialized(self):
|
||||
active = 0
|
||||
maximum = 0
|
||||
|
||||
async def impl(*args, **kwargs):
|
||||
nonlocal active, maximum
|
||||
active += 1
|
||||
maximum = max(maximum, active)
|
||||
await asyncio.sleep(0.02)
|
||||
active -= 1
|
||||
|
||||
center = type("Center", (), {
|
||||
"begin_conversation": lambda self, uid: None,
|
||||
"end_conversation": lambda self, uid: None,
|
||||
"deliver_due": AsyncMock(),
|
||||
})()
|
||||
import notifications
|
||||
with patch.object(engine, "_resolve_delivery", return_value=None), \
|
||||
patch.object(engine, "_call_model_and_reply_impl", new=impl), \
|
||||
patch.object(notifications, "center", center):
|
||||
await asyncio.gather(*[
|
||||
engine._call_model_and_reply(None, 1, f"message-{i}", False)
|
||||
for i in range(3)
|
||||
])
|
||||
self.assertEqual(maximum, 1)
|
||||
|
||||
async def test_reasoning_json_gets_one_repair_retry(self):
|
||||
valid = '{"message":[{"content":"ok"}],"tools":[{"name":"web_search","args":{"query":"x"}}]}'
|
||||
model = AsyncMock(side_effect=["not json", valid])
|
||||
with patch.object(engine, "call_deepseek", new=model):
|
||||
parsed = await engine._reasoning_json_response("system", "user")
|
||||
self.assertEqual(parsed["tools"][0]["name"], "web_search")
|
||||
self.assertEqual(model.await_count, 2)
|
||||
self.assertTrue(all(call.kwargs["reasoning"] for call in model.await_args_list))
|
||||
|
||||
async def test_reasoning_json_stops_after_two_invalid_responses(self):
|
||||
model = AsyncMock(side_effect=["bad", "still bad"])
|
||||
with patch.object(engine, "call_deepseek", new=model):
|
||||
parsed = await engine._reasoning_json_response("system", "user")
|
||||
self.assertIsNone(parsed)
|
||||
self.assertEqual(model.await_count, 2)
|
||||
|
||||
async def test_send_segments_reports_delivery_failure(self):
|
||||
with patch.object(engine, "send_private_message", new=AsyncMock(side_effect=[True, False])):
|
||||
ok = await engine.send_message_segments(None, 1, [{"content": "a"}, {"content": "b"}])
|
||||
self.assertFalse(ok)
|
||||
|
||||
async def test_pending_images_are_cleaned_after_consumption(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False) as handle:
|
||||
path = Path(handle.name)
|
||||
engine._user_pending_messages[1] = ["读图"]
|
||||
engine._user_pending_images[1] = [{"url": str(path)}]
|
||||
with patch.object(engine, "_call_model_and_reply", new=AsyncMock()):
|
||||
await engine._process_pending_messages(1, 0)
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user