first commit
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
"""不连接微信的框架行为测试;发送结果由内存后端记录。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from wxbot_framework.backend import CAPABILITIES
|
||||
from wxbot_framework.config import ConfigStore
|
||||
from wxbot_framework.locking import InstanceLock
|
||||
from wxbot_framework.plugins import PluginManager
|
||||
from wxbot_framework.runtime import BotApplication
|
||||
from wxbot_framework.sdk import Message, PluginContext, ServiceFacade
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRATCH = ROOT / ".runtime/tests"
|
||||
|
||||
|
||||
def wait_for(condition, seconds=3):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
if condition():
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise AssertionError("等待异步结果超时")
|
||||
|
||||
|
||||
class MemoryBackend:
|
||||
def __init__(self, fail_start=False):
|
||||
self.session_id = uuid.uuid4().hex
|
||||
self.pid = 123
|
||||
self.last_error = None
|
||||
self.sent = []
|
||||
self.fail_start = fail_start
|
||||
self.started = False
|
||||
self.stops = 0
|
||||
|
||||
def start(self, emit):
|
||||
if self.fail_start:
|
||||
raise RuntimeError("模拟微信尚未启动")
|
||||
self.emit = emit
|
||||
self.started = True
|
||||
return True
|
||||
|
||||
def send_text(self, chat_id, text):
|
||||
self.sent.append((chat_id, text))
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
self.stops += 1
|
||||
self.started = False
|
||||
return {"dll_unloaded": True}
|
||||
|
||||
|
||||
class FrameworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.previous_logging = logging.root.manager.disable
|
||||
logging.disable(logging.CRITICAL)
|
||||
SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||
self.temporary = tempfile.TemporaryDirectory(dir=SCRATCH)
|
||||
self.root = Path(self.temporary.name).resolve()
|
||||
self.assertTrue(self.root.is_relative_to(SCRATCH.resolve()))
|
||||
shutil.copytree(ROOT / "plugins", self.root / "plugins")
|
||||
self.path = self.root / "bot_config.json"
|
||||
self.config = json.loads((ROOT / "bot_config.json").read_text(encoding="utf-8"))
|
||||
self.config.update(console=False, reconnect_seconds=0.1, text_send_delay={"enabled": False})
|
||||
# 框架测试不调用外部大模型;玩法单独用假客户端验证。
|
||||
self.config["plugins"]["idiom_chain"]["enabled"] = False
|
||||
self.config["plugins"]["member_identity"]["enabled"] = False
|
||||
self.config["plugins"]["chat_summary"]["enabled"] = False
|
||||
self.config["plugins"]["chat_records"]["enabled"] = False
|
||||
self.config["plugins"]["personality"]["enabled"] = False
|
||||
self.config["plugins"]["mention_chat"]["enabled"] = False
|
||||
self.config["members_config_file"] = None
|
||||
self.write_config()
|
||||
self.store = ConfigStore(self.path)
|
||||
self.backend = MemoryBackend()
|
||||
self.service = ServiceFacade(text_delay=self.config["text_send_delay"])
|
||||
self.service.bind(self.backend)
|
||||
self.manager = PluginManager(self.root / "plugins", self.store, self.service, CAPABILITIES)
|
||||
|
||||
def tearDown(self):
|
||||
self.manager.shutdown()
|
||||
self.service.close()
|
||||
# 递归清理只针对本测试创建、已核对位于工作区的临时目录。
|
||||
self.assertTrue(self.root.resolve().is_relative_to(SCRATCH.resolve()))
|
||||
self.temporary.cleanup()
|
||||
logging.disable(self.previous_logging)
|
||||
|
||||
def write_config(self):
|
||||
self.path.write_text(json.dumps(self.config, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def create_plugin(self, name, source, *, manifest=None):
|
||||
directory = self.root / "plugins" / name
|
||||
directory.mkdir(exist_ok=True)
|
||||
(directory / "__init__.py").write_text(source, encoding="utf-8")
|
||||
if manifest:
|
||||
(directory / "plugin.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
return directory / "__init__.py"
|
||||
|
||||
def message(self, text, sequence=1):
|
||||
return Message.from_native(11046, {"msg": text, "from_wxid": "filehelper", "room_wxid": "",
|
||||
"native_sender": "self_test", "native_receiver": "filehelper",
|
||||
"sequence": sequence}, self.backend.session_id)
|
||||
|
||||
def test_new_and_legacy_plugins_receive_unicode_snapshot(self):
|
||||
self.manager.reload()
|
||||
source = {"msg": "echo 中文 🌸\n第二行", "from_wxid": "filehelper"}
|
||||
message = Message.from_native(11046, source, self.backend.session_id)
|
||||
source["msg"] = "已被上游修改"
|
||||
self.manager.dispatch(message)
|
||||
self.manager.dispatch(self.message("test", 2))
|
||||
wait_for(lambda: len(self.backend.sent) == 2)
|
||||
self.assertCountEqual(self.backend.sent, [("filehelper", "中文 🌸\n第二行"), ("filehelper", "filehelper")])
|
||||
|
||||
def test_plugin_failure_does_not_stop_other_plugins_or_next_message(self):
|
||||
self.create_plugin("broken", "class Plugin:\n def on_message(self,m,c):\n raise SystemExit('模拟插件错误')\n")
|
||||
self.manager.reload()
|
||||
self.manager.dispatch(self.message("test"))
|
||||
self.manager.dispatch(self.message("echo 好的", 2))
|
||||
wait_for(lambda: len(self.backend.sent) == 2 and self.manager.records["broken"].errors == 2)
|
||||
self.assertTrue(self.manager.records["broken"].thread.is_alive())
|
||||
self.assertIn(("filehelper", "好的"), self.backend.sent)
|
||||
|
||||
def test_disabled_and_unsupported_plugins_are_not_imported(self):
|
||||
source = "raise RuntimeError('此插件不应被导入')\n"
|
||||
self.create_plugin("disabled", source)
|
||||
self.create_plugin("groups", source, manifest={"api": "v1", "requires": ["group.at"]})
|
||||
self.config["plugins"]["disabled"] = {"enabled": False}
|
||||
self.write_config()
|
||||
self.manager.reload()
|
||||
self.assertNotIn("disabled", self.manager.records)
|
||||
self.assertNotIn("groups", self.manager.records)
|
||||
groups = next(row for row in self.manager.status() if row["name"] == "groups")
|
||||
self.assertEqual(groups["missing_capabilities"], ["group.at"])
|
||||
|
||||
def test_enable_disable_persist_and_keep_other_plugins_running(self):
|
||||
self.manager.reload()
|
||||
self.manager.disable("echo")
|
||||
saved = self.store.load()["plugins"]["echo"]
|
||||
self.assertFalse(saved["enabled"])
|
||||
self.assertEqual(saved["prefix"], "echo ")
|
||||
self.manager.dispatch(self.message("echo 不应回复"))
|
||||
self.manager.dispatch(self.message("test", 2))
|
||||
wait_for(lambda: self.manager.records["id_responder"].handled == 2)
|
||||
self.assertEqual(self.backend.sent, [("filehelper", "filehelper")])
|
||||
self.manager.enable("echo")
|
||||
self.assertTrue(self.store.load()["plugins"]["echo"]["enabled"])
|
||||
self.manager.dispatch(self.message("echo 恢复了", 3))
|
||||
wait_for(lambda: ("filehelper", "恢复了") in self.backend.sent)
|
||||
|
||||
def test_reload_uses_fresh_source_and_failed_reload_keeps_previous(self):
|
||||
source = "class Plugin:\n def on_message(self,m,c):\n c.reply(m,'v1')\n"
|
||||
path = self.create_plugin("reloadable", source)
|
||||
self.manager.reload()
|
||||
self.manager.dispatch(self.message("one"))
|
||||
wait_for(lambda: ("filehelper", "v1") in self.backend.sent)
|
||||
previous_module = self.manager.records["reloadable"].module_name
|
||||
stat = path.stat()
|
||||
path.write_text(source.replace("v1", "v2"), encoding="utf-8")
|
||||
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns))
|
||||
self.manager.reload("reloadable")
|
||||
self.assertNotIn(previous_module, sys.modules)
|
||||
self.manager.dispatch(self.message("two", 2))
|
||||
wait_for(lambda: ("filehelper", "v2") in self.backend.sent)
|
||||
current = self.manager.records["reloadable"]
|
||||
path.write_text("class Plugin(:\n", encoding="utf-8")
|
||||
self.manager.reload("reloadable")
|
||||
self.assertIs(self.manager.records["reloadable"], current)
|
||||
self.assertTrue(current.active)
|
||||
self.manager.dispatch(self.message("three", 3))
|
||||
wait_for(lambda: self.backend.sent.count(("filehelper", "v2")) == 2)
|
||||
|
||||
def test_unload_releases_plugin_resources(self):
|
||||
marker = self.root / "lifecycle.txt"
|
||||
source = ("class Plugin:\n"
|
||||
" def on_load(self,c):\n self.file=open(c.config['marker'],'w',encoding='utf-8')\n"
|
||||
" def on_message(self,m,c):\n pass\n"
|
||||
" def on_unload(self,c):\n self.file.write('released');self.file.close()\n")
|
||||
self.create_plugin("resource", source)
|
||||
self.config["plugins"]["resource"] = {"enabled": True, "marker": str(marker)}
|
||||
self.write_config()
|
||||
self.manager.reload()
|
||||
self.manager.disable("resource")
|
||||
self.assertEqual(marker.read_text(encoding="utf-8"), "released")
|
||||
|
||||
def test_busy_reload_does_not_replace_active_callback(self):
|
||||
source = ("import threading\nclass Plugin:\n"
|
||||
" def on_load(self,c):\n self.entered=threading.Event();self.release=threading.Event()\n"
|
||||
" def on_message(self,m,c):\n self.entered.set();self.release.wait(4)\n")
|
||||
self.create_plugin("busy", source)
|
||||
self.manager.reload()
|
||||
original = self.manager.records["busy"]
|
||||
self.manager.dispatch(self.message("work"))
|
||||
self.assertTrue(original.instance.entered.wait(1))
|
||||
try:
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.manager.load_one("busy")
|
||||
self.assertIs(self.manager.records["busy"], original)
|
||||
self.assertTrue(original.active)
|
||||
finally:
|
||||
original.instance.release.set()
|
||||
|
||||
def test_old_session_reply_is_rejected_after_reconnect(self):
|
||||
old = self.message("old")
|
||||
self.service.close()
|
||||
replacement = MemoryBackend()
|
||||
self.service.bind(replacement)
|
||||
context = PluginContext("test", {}, self.service, CAPABILITIES)
|
||||
with self.assertRaises(RuntimeError):
|
||||
context.reply(old, "不应发送")
|
||||
self.assertEqual(replacement.sent, [])
|
||||
|
||||
def test_runtime_waits_reconnects_and_stops_cleanly(self):
|
||||
created = []
|
||||
|
||||
def factory():
|
||||
backend = MemoryBackend(fail_start=not created)
|
||||
created.append(backend)
|
||||
return backend
|
||||
|
||||
app = BotApplication(self.root, self.store, backend_factory=factory)
|
||||
thread = threading.Thread(target=app.run)
|
||||
thread.start()
|
||||
try:
|
||||
wait_for(lambda: len(created) >= 2 and app._connection_ready)
|
||||
created[1].last_error = RuntimeError("模拟断线")
|
||||
wait_for(lambda: len(created) >= 3 and app._connection_ready)
|
||||
created[2].emit(Message.from_native(11046, {"msg": "test", "from_wxid": "filehelper"}, created[2].session_id))
|
||||
wait_for(lambda: created[2].sent == [("filehelper", "filehelper")])
|
||||
app.commands.put("quit")
|
||||
thread.join(3)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertTrue(all(backend.stops == 1 for backend in created))
|
||||
self.assertFalse(app.service.connected)
|
||||
finally:
|
||||
app.stop_event.set()
|
||||
thread.join(3)
|
||||
|
||||
def test_single_instance_lock_is_released(self):
|
||||
path = self.root / "bot.lock"
|
||||
with InstanceLock(path):
|
||||
with self.assertRaises(RuntimeError):
|
||||
InstanceLock(path).acquire()
|
||||
with InstanceLock(path):
|
||||
pass
|
||||
|
||||
def test_plugins_reply_to_friend_and_group_conversations(self):
|
||||
from wxbot_framework.text import TextRouter
|
||||
router = TextRouter()
|
||||
router.convert("wxid_self", "filehelper", "init")
|
||||
self.manager.reload()
|
||||
for sender, text in (("wxid_friend", "echo 好友"),
|
||||
("12345@chatroom", "wxid_member:\necho 群聊"),
|
||||
("12345@chatroom", "wxid_member:\ntest")):
|
||||
data = router.convert(sender, "wxid_self", text)
|
||||
self.manager.dispatch(Message.from_native(11046, data, self.backend.session_id))
|
||||
wait_for(lambda: len(self.backend.sent) == 3)
|
||||
self.assertCountEqual(self.backend.sent, [("wxid_friend", "好友"),
|
||||
("12345@chatroom", "群聊"),
|
||||
("12345@chatroom", "12345@chatroom")])
|
||||
|
||||
def test_runtime_allowlist_dispatches_only_selected_chat(self):
|
||||
self.config["backend"] = "native_text"
|
||||
self.config["chat_allowlist"] = ["filehelper"]
|
||||
self.write_config()
|
||||
app = BotApplication(self.root, self.store, backend_factory=lambda: self.backend)
|
||||
app.service.bind(self.backend)
|
||||
app.plugins.reload()
|
||||
try:
|
||||
app._emit(Message(text="test", chat_id="wxid_other", session_id=self.backend.session_id,
|
||||
data={"msg": "test", "from_wxid": "wxid_other"}))
|
||||
app._emit(self.message("echo 允许"))
|
||||
wait_for(lambda: app.plugins.records["id_responder"].handled == 1 and
|
||||
app.plugins.records["echo"].handled == 1)
|
||||
self.assertEqual(self.backend.sent, [("filehelper", "允许")])
|
||||
finally:
|
||||
app.plugins.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user