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
+290
View File
@@ -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()