first commit

This commit is contained in:
Prometheus-first
2026-10-15 18:27:46 +08:00
commit 68eb19ea35
88 changed files with 10319 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
"""文本节奏、统一发送入口及等待期间的退出/重连验证;不连接微信。"""
from __future__ import annotations
import threading
import time
import unittest
from unittest.mock import Mock, patch
from wxbot_framework.pacing import TextSendDelay
from wxbot_framework.sdk import CapabilityError, LegacyService, Message, PluginContext, ServiceFacade
class MemoryBackend:
def __init__(self, session="original"):
self.session_id = session
self.sent = []
self.stopped = False
def send_text(self, chat, text):
self.sent.append((chat, text, time.monotonic()))
return True
def stop(self):
self.stopped = True
return {"dll_unloaded": True}
class RecordingCancel:
def __init__(self):
self.waits = []
self.cancelled = False
def wait(self, seconds):
self.waits.append(seconds)
return self.cancelled
def is_set(self):
return self.cancelled
def set(self):
self.cancelled = True
class TextPacingTests(unittest.TestCase):
def test_character_groups_include_chinese_symbols_and_short_text(self):
pacing = TextSendDelay.from_config()
with patch("wxbot_framework.pacing.random.uniform", return_value=0.2):
self.assertAlmostEqual(pacing.seconds_for("你好🌸 abc!"), 0.2)
self.assertAlmostEqual(pacing.seconds_for("字" * 10), 0.2)
self.assertAlmostEqual(pacing.seconds_for("字" * 11), 0.4)
self.assertAlmostEqual(pacing.seconds_for("字" * 100), 2.0)
self.assertEqual(TextSendDelay.from_config({"enabled": False}).seconds_for("你好"), 0)
def test_configuration_rejects_invalid_random_ranges_and_group_size(self):
for options in ([], {"enabled": 1}, {"characters_per_group": 0},
{"characters_per_group": True}, {"min_seconds": -1},
{"max_seconds": float("inf")}, {"max_seconds": float("nan")},
{"min_seconds": True}, {"min_seconds": 0.4, "max_seconds": 0.3}):
with self.subTest(options=options), self.assertRaises(ValueError):
TextSendDelay.from_config(options)
def test_all_plugin_text_apis_wait_and_other_payloads_do_not(self):
backend = MemoryBackend()
service = ServiceFacade()
service.bind(backend)
cancel = RecordingCancel()
service._cancel = cancel
ctx = PluginContext("test", {}, service, {"text.send"})
legacy = LegacyService(service, backend.session_id)
message = Message(text="test", chat_id="wxid_friend", session_id=backend.session_id)
with patch("wxbot_framework.pacing.random.uniform", return_value=0.2):
ctx.reply(message, "你好")
ctx.send_text("12345@chatroom", "群聊")
legacy.send_text("filehelper", "旧插件")
legacy.send_payload({"type": 11237, "data": {"to_wxid": "filehelper", "content": "文本"}})
with self.assertRaises(CapabilityError):
legacy.send_payload({"type": 99999})
self.assertEqual(cancel.waits, [0.2] * 4)
self.assertEqual(len(backend.sent), 4)
service.close()
def test_invalid_text_is_rejected_before_wait_or_native_send(self):
backend = MemoryBackend()
service = ServiceFacade()
service.bind(backend)
cancel = RecordingCancel()
service._cancel = cancel
for text in (None, "", "含\0字符", "字" * 342):
with self.subTest(text_type=type(text)), self.assertRaises(ValueError):
service.send_text("filehelper", text)
self.assertEqual(cancel.waits, [])
self.assertEqual(backend.sent, [])
service.close()
def test_two_concurrent_plugins_each_wait_between_native_sends(self):
backend = MemoryBackend()
service = ServiceFacade(text_delay={"min_seconds": 0.08, "max_seconds": 0.08})
service.bind(backend)
threads = [threading.Thread(target=service.send_text, args=("filehelper", str(i))) for i in range(2)]
started = time.monotonic()
for thread in threads:
thread.start()
for thread in threads:
thread.join(2)
self.assertFalse(thread.is_alive())
self.assertEqual(len(backend.sent), 2)
self.assertGreaterEqual(backend.sent[0][2] - started, 0.07)
self.assertGreaterEqual(backend.sent[1][2] - backend.sent[0][2], 0.07)
service.close()
def test_close_interrupts_wait_without_blocking_connection_queries(self):
backend = MemoryBackend()
service = ServiceFacade()
service.bind(backend)
waiting, errors = threading.Event(), []
def delay(_text):
waiting.set()
return 30
service.text_delay = Mock(seconds_for=delay)
thread = self._start_send(service, errors)
self.assertTrue(waiting.wait(1))
self.assertTrue(service.connected)
service.close()
thread.join(1)
self.assertFalse(thread.is_alive())
self.assertEqual(len(errors), 1)
self.assertEqual(backend.sent, [])
self.assertTrue(backend.stopped)
def test_pending_send_cannot_move_to_new_connection_even_without_session_argument(self):
original, replacement = MemoryBackend(), MemoryBackend("replacement")
service = ServiceFacade()
service.bind(original)
waiting, errors = threading.Event(), []
def delay(_text):
waiting.set()
return 30
service.text_delay = Mock(seconds_for=delay)
thread = self._start_send(service, errors)
self.assertTrue(waiting.wait(1))
service.close()
service.bind(replacement)
thread.join(1)
self.assertFalse(thread.is_alive())
self.assertEqual(len(errors), 1)
self.assertEqual(original.sent, [])
self.assertEqual(replacement.sent, [])
service.text_delay = TextSendDelay.from_config({"enabled": False})
service.send_text("filehelper", "新连接")
self.assertEqual(replacement.sent[0][:2], ("filehelper", "新连接"))
service.close()
def test_shutdown_cancellation_rejects_new_sends_before_backend_cleanup(self):
backend = MemoryBackend()
service = ServiceFacade(text_delay={"enabled": False})
service.bind(backend)
service.cancel_pending()
with self.assertRaises(RuntimeError):
service.send_text("filehelper", "退出期间不再发消息")
self.assertEqual(backend.sent, [])
self.assertFalse(backend.stopped)
service.close()
self.assertTrue(backend.stopped)
@staticmethod
def _start_send(service, errors):
def send():
try:
service.send_text("filehelper", "等待发送")
except RuntimeError as error:
errors.append(error)
thread = threading.Thread(target=send, daemon=True)
thread.start()
return thread
if __name__ == "__main__":
unittest.main()