first commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user