Files
2026-10-15 18:27:46 +08:00

121 lines
6.0 KiB
Python

"""大模型协议和错误处理验证;使用假响应,不消耗实际 API。"""
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from urllib.error import HTTPError, URLError
from wxbot_framework.llm import LLMClient, LLMError
from plugins.idiom_chain.judge import IdiomJudge
class JSONClient:
def __init__(self, result):
self.result = result
self.calls = []
def complete_json(self, system, user):
self.calls.append((system, user))
return self.result
class LLMTests(unittest.TestCase):
def setUp(self):
self.options = {"api_key": "dummy-test-key", "model": "deepseek-flash"}
self.client = LLMClient(self.options)
def response(self, content, **extra):
return io.BytesIO(json.dumps({"choices": [{"message": {"content": content}, **extra}]}).encode())
def test_official_json_request_and_utf8(self):
with patch("wxbot_framework.llm.urlopen", return_value=self.response('{"idiom":"一心一意"}')) as call:
self.assertEqual(self.client.complete_json("只返回 JSON", "中文"), {"idiom": "一心一意"})
request = call.call_args.args[0]
self.assertEqual(request.full_url, "https://api.deepseek.com/chat/completions")
self.assertEqual(request.get_header("Authorization"), "Bearer dummy-test-key")
payload = json.loads(request.data)
self.assertEqual(payload["model"], "deepseek-flash")
self.assertEqual(payload["messages"][1]["content"], "中文")
self.assertEqual(payload["response_format"], {"type": "json_object"})
self.assertEqual(payload["thinking"], {"type": "disabled"})
self.assertEqual(call.call_args.kwargs["timeout"], 20)
def test_config_from_utf8_bom_file(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "llm.json"
path.write_text(json.dumps(self.options), encoding="utf-8-sig")
self.assertEqual(LLMClient.from_file(path).model, "deepseek-flash")
def test_invalid_configuration(self):
for change in ({"api_key": ""}, {"model": 1}, {"base_url": None},
{"base_url": "http://example.com"}, {"base_url": "https://user:pass@example.com"},
{"timeout_seconds": True}, {"timeout_seconds": float("nan")},
{"max_tokens": 1}, {"max_tokens": True}):
with self.subTest(change=change), self.assertRaises(ValueError):
LLMClient({**self.options, **change})
def test_server_and_connection_errors_hide_sensitive_details(self):
for error in (HTTPError("https://example.com", 401, "dummy-test-key", {}, None),
URLError("dummy-test-key"), TimeoutError("dummy-test-key")):
with self.subTest(error=type(error).__name__), patch("wxbot_framework.llm.urlopen", side_effect=error):
with self.assertRaises(LLMError) as caught:
self.client.complete_json("JSON", "测试")
self.assertNotIn("dummy-test-key", str(caught.exception))
def test_malformed_or_truncated_model_results(self):
for content in ("", "not json", "[]", None):
with self.subTest(content=content), patch("wxbot_framework.llm.urlopen", return_value=self.response(content)):
with self.assertRaises(LLMError):
self.client.complete_json("JSON", "测试")
with patch("wxbot_framework.llm.urlopen", return_value=self.response("{}", finish_reason="length")):
with self.assertRaisesRegex(LLMError, "截断"):
self.client.complete_json("JSON", "测试")
with patch("wxbot_framework.llm.urlopen", return_value=io.BytesIO(b'{"choices":[null]}')):
with self.assertRaises(LLMError):
self.client.complete_json("JSON", "测试")
class JudgeTests(unittest.TestCase):
def setUp(self):
self.client = JSONClient({"is_idiom": True, "reason": "常见成语", "next_idiom": "发扬光大"})
self.judge = IdiomJudge(self.client)
def test_local_rejections_do_not_call_model(self):
for previous, word, used in (("一心一意", "不是四字成语", ()),
("一心一意", "异想天开", ()),
("一心一意", "意气风发", ("意气风发",))):
self.assertFalse(self.judge.evaluate(previous, word, used).accepted)
self.assertEqual(self.client.calls, [])
def test_success_sends_only_game_words(self):
result = self.judge.evaluate("一心一意", "意气风发", ("一心一意",))
self.assertTrue(result.accepted)
self.assertEqual(result.next_idiom, "发扬光大")
self.assertEqual(json.loads(self.client.calls[0][1]),
{"previous": "一心一意", "candidate": "意气风发", "used": ["一心一意", "意气风发"]})
def test_model_can_reject_fake_four_character_word(self):
self.client.result = {"is_idiom": False, "reason": "不是成语", "next_idiom": ""}
result = self.judge.evaluate("一心一意", "意意意意", ("一心一意",))
self.assertFalse(result.accepted)
def test_invalid_model_next_word_never_advances(self):
for word in ("假的", "一心一意", "发扬光大", "意气风发"):
self.client.result["next_idiom"] = word
with self.subTest(word=word), self.assertRaises(LLMError):
self.judge.evaluate("一心一意", "意气风发", ("一心一意", "发扬光大"))
def test_model_boolean_is_strict(self):
self.client.result["is_idiom"] = "true"
with self.assertRaises(LLMError):
self.judge.evaluate("一心一意", "意气风发", ())
def test_opening_shape(self):
self.client.result = {"idiom": "一心一意"}
self.assertEqual(self.judge.opening(), "一心一意")
self.client.result = {"idiom": "假词"}
with self.assertRaises(LLMError):
self.judge.opening()