Files
qqbotsdk/tests/test_emitter.py
T
snowy-yang 1552c5207b refactor: 工程化收尾——去 pyee、预计算解析计划与派生密钥、命名与接口一致化
以「语义可读、逻辑清晰、减少心智负担」为目标,不追求极致速度:

- **移除 pyee 依赖**:EventEmitter 改为内置的 `事件名 → handler 列表` 注册表
  (on() 追加、emit() 顺序遍历)。SDK 用不到 pyee 的 once/new_listener/错误
  事件机制,去掉后少一个依赖、分发语义一眼可读。注册语义不变(同函数重复
  注册只生效一次),并补测试锁定「重复注册只生效一次」「按注册顺序执行」。
  破坏性:once/remove_listener 等装饰性 API 不再提供。

- **去掉热路径上的重复计算**(纯缓存,不改语义):
  · payloads 按类缓存解析计划(字段名 + 是否嵌套 Author),不再每次事件重跑
    dataclasses.fields 与联合类型解包;未收录字段照旧忽略(新增回归测试,
    线上 payload 会带 author.union_openid 这类字段)。实测 7.5µs → 2.5µs。
  · crypto 按 AppSecret 缓存派生私钥(每个 webhook 请求都要验签,派生一次
    约 40µs);公钥派生只差 3µs,不再多缓存一层。

- **命名与接口一致性**:
  · WebsocketConnecter.reply_helper → send_helper(它从出站缓冲取帧发送,
    原名会让人误以为还有 reply 队列;model.py 的对应注释同步)
  · Connecter 由 typing.Protocol 改为 abc.ABC,与 BaseProtocol 风格统一

- **webhook 去重改摊还清理**:过期判定按当前 id 做(O(1),且 TTL 外重发的
  同一 id 仍按新事件处理——原先的写法在阈值清理下会误判为重复,已由测试
  抓出并修正),整表清过期项只在表长到阈值时执行,不再每请求 O(n) 扫描。

测试 85 全绿(新增未知字段/解析计划缓存/注册语义/去重摊还与 TTL 过期),
ruff check/format + pyright 干净。文档 ARCHITECTURE/API/CHANGELOG 同步。
2026-09-20 22:45:34 +08:00

226 lines
6.5 KiB
Python

import asyncio
import pytest
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.events import Event
from qqbotsdk.model import Opcode
from qqbotsdk.payloads import GroupAtMessage
from qqbotsdk.queue import EventQueue
from qqbotsdk.session import Session
def test_sequence_only_moves_forward():
s = Session()
assert s.seq == 0
s.update_sequence(5)
s.update_sequence(3) # 回退被忽略
assert s.seq == 5
s.update_sequence(7)
assert s.seq == 7
@pytest.mark.asyncio
async def test_same_handler_registered_twice_runs_once():
"""同名事件重复注册同一函数只生效一次(沿用 pyee 的语义)。"""
emitter = EventEmitter()
calls: list[str] = []
async def handler(_):
calls.append("hit")
emitter.on("READY", handler)
emitter.on("READY", handler)
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
assert calls == ["hit"]
@pytest.mark.asyncio
async def test_handlers_run_in_registration_order():
emitter = EventEmitter()
order: list[int] = []
@emitter.on("READY")
async def first(_):
order.append(1)
@emitter.on("READY")
async def second(_):
order.append(2)
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
assert order == [1, 2]
@pytest.mark.asyncio
async def test_handler_exception_is_isolated():
queue = EventQueue()
emitter = EventEmitter(queue)
called: list[str] = []
@emitter.on("READY")
async def bad(_):
raise RuntimeError("boom")
@emitter.on("READY")
async def good(_):
called.append("ok")
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
assert called == ["ok"]
@pytest.mark.asyncio
async def test_protocol_frame_never_reaches_business_handler():
"""协议帧不进分发路径:即使注册了同名 handler 也不该被调用。"""
emitter = EventEmitter()
called: list[object] = []
@emitter.on(Opcode.HEARTBEAT)
async def beat(d):
called.append(d)
await emitter.emit({"op": Opcode.HEARTBEAT, "d": 0})
assert called == []
@pytest.mark.asyncio
async def test_service_annotation_resolves_from_services():
queue = EventQueue()
emitter = EventEmitter(queue)
class Store:
pass
store = Store()
emitter.services[Store] = store
received: list[tuple[GroupAtMessage, Store, dict]] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def handler(msg: GroupAtMessage, st: Store, d):
received.append((msg, st, d))
await emitter.emit(
{
"op": Opcode.DISPATCH,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": "hi", "group_openid": "G1"},
}
)
assert len(received) == 1
msg, got, d = received[0]
assert got is store
assert isinstance(msg, GroupAtMessage) and msg.content == "hi"
assert d == {"content": "hi", "group_openid": "G1"}
@pytest.mark.asyncio
async def test_unregistered_annotation_falls_back_to_raw_dict():
queue = EventQueue()
emitter = EventEmitter(queue)
received: list[dict] = []
@emitter.on("READY")
async def handler(dep: Session): # Session 未登记进 services
received.append(dep)
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}})
assert received == [{"session_id": "s1"}]
@pytest.mark.asyncio
async def test_service_registered_after_handler_still_injects():
"""回归:形参解析被缓存(签名+标注),但 services 命中必须在每条事件时判定。
先注册 handler、后登记组件是 run_loop 里的真实时序(用户 handler 在
main() 之前注册);若缓存把"已解析好的实参"也存下来,这里会永远拿不到
实例。同一 handler 连发两条也一并覆盖缓存复用路径。
"""
emitter = EventEmitter()
class Store:
pass
store = Store()
got: list[Store] = []
@emitter.on("READY")
async def handler(st: Store):
got.append(st)
emitter.services[Store] = store # 登记晚于 handler 注册
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
assert got == [store, store]
@pytest.mark.asyncio
async def test_typed_and_data_see_same_content():
"""`.typed` 与 `.data` 均可用且一致;只取 `.typed` 的 handler 不触发 data 拷贝。"""
emitter = EventEmitter()
seen: list[tuple[str | None, str | None]] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def handler(msg: GroupAtMessage, event: Event):
seen.append((msg.content, event.data["content"]))
await emitter.emit(
{
"op": Opcode.DISPATCH,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": "hi", "group_openid": "G1"},
}
)
assert seen == [("hi", "hi")]
@pytest.mark.asyncio
async def test_background_handler_does_not_block_dispatch():
"""background=True:慢 handler 进后台任务,不阻塞同一事件的后续 handler。"""
emitter = EventEmitter()
slow_done = asyncio.Event()
quick_done = asyncio.Event()
@emitter.on("GROUP_AT_MESSAGE_CREATE", background=True)
async def slow(msg: GroupAtMessage):
await asyncio.sleep(0.05)
slow_done.set()
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def quick(msg: GroupAtMessage):
quick_done.set()
await emitter.emit(
{
"op": Opcode.DISPATCH,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": "hi"},
}
)
# emit 返回时快 handler 已跑完,慢 handler 仍在后台
assert quick_done.is_set()
assert not slow_done.is_set()
await asyncio.wait_for(slow_done.wait(), timeout=2)
await asyncio.sleep(0) # 让已完成任务的 done_callback 清理 _bg_tasks
await emitter.close()
assert not emitter._bg_tasks # 收尾后在飞任务清空
@pytest.mark.asyncio
async def test_close_cancels_inflight_background_tasks():
emitter = EventEmitter()
cancelled = asyncio.Event()
@emitter.on("READY", background=True)
async def forever():
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
cancelled.set()
raise
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
await asyncio.sleep(0) # 让后台任务实际启动(未启动的任务被 cancel 不会执行函数体)
await emitter.close()
assert cancelled.is_set()