Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
"""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()
|