# -*- coding: utf-8 -*- """引擎 B 数据源模式单测:parallel / db / json 三模式调度 + DB 故障隔离。 验证点: 1. json 模式:每轮只跑 conversations.json(poll_once),不碰 DB; 2. db 模式(有 db_source):只跑 DB 直读,不碰 JSON; 3. db 模式(无 db_source):自动退化为 JSON,避免静默无检测; 4. parallel 模式:两路每轮都跑,互为兜底; 5. DB 故障隔离:get_new_messages 抛异常 → db_active=False, JSON 路径下一轮仍独立检测投递;DB 恢复后 db_active 回 True。 """ from __future__ import annotations import json import os import sys import tempfile import threading import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from engine_b import DataEngine # noqa: E402 FAILED: list[str] = [] def check(name: str, cond: bool, detail: str = "") -> None: tag = "PASS" if cond else "FAIL" print(f"[{tag}] {name}" + (f" ({detail})" if detail else "")) if not cond: FAILED.append(name) class CountingEngine(DataEngine): """重写两路检测方法为计数器,验证 _run 的调度分支。""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.poll_once_calls = 0 self.db_once_calls = 0 self.poll_lock = threading.Lock() def poll_once(self): with self.poll_lock: self.poll_once_calls += 1 def _poll_db_once(self): with self.poll_lock: self.db_once_calls += 1 class StubDB: def __init__(self, rows=None, fail=False): self.rows = rows or [] self.fail = fail self.calls = 0 def get_new_messages(self, since_ts): self.calls += 1 if self.fail: raise RuntimeError("DB 解密失败") return self.rows def run_rounds(engine: DataEngine, seconds: float = 1.5) -> None: engine.start() time.sleep(seconds) engine.stop() # ---------- 1/2/4. 三模式调度 ---------- mode_cases = [ ("json", {}, {"poll": 2, "db": 0}), ("db", {"db_source": StubDB()}, {"poll": 0, "db": 2}), ("parallel", {"db_source": StubDB()}, {"poll": 2, "db": 2}), ] for mode, kw, expect in mode_cases: eng = CountingEngine(bot=object(), conversations_path="/nonexistent.json", poll_interval=0.4, **kw) eng.data_source_mode = mode run_rounds(eng, seconds=1.3) check(f"[{mode}] 每轮调用 poll_once 次数符合预期", eng.poll_once_calls >= expect["poll"], f"got {eng.poll_once_calls}") check(f"[{mode}] 每轮调用 _poll_db_once 次数符合预期", eng.db_once_calls >= expect["db"], f"got {eng.db_once_calls}") # ---------- 3. db 模式无数据源 → 退化为 JSON ---------- eng = CountingEngine(bot=object(), conversations_path="/nonexistent.json", poll_interval=0.4, db_source=None) eng.data_source_mode = "db" run_rounds(eng, seconds=1.3) check("[db-无数据源] 退化为 JSON 检测", eng.poll_once_calls >= 2, f"poll_once={eng.poll_once_calls}") # ---------- 5. DB 故障隔离与恢复 ---------- now = time.time() fp = "f" * 40 conversations = { fp: { "display_name": "隔离测试客户", "history": [{"role": "user", "content": "DB 挂了不影响我", "ts": now - 3}], "last_lines": ["DB 挂了不影响我"], } } tmp_json = os.path.join(tempfile.gettempdir(), "engine_b_mode_test.json") with open(tmp_json, "w", encoding="utf-8") as handle: json.dump(conversations, handle, ensure_ascii=False) class StubBot: def __init__(self): self.enqueued = [] def has_active_pending(self, fp_hex): return False def enqueue_detected(self, **kwargs): self.enqueued.append(kwargs) return True, "ok" bot = StubBot() db = StubDB(fail=True) eng = DataEngine(bot=bot, conversations_path=tmp_json, poll_interval=0.4, db_source=db, data_source_mode="parallel") eng._poll_db_once() check("DB 异常 → db_active=False", eng.db_active is False, f"db_active={eng.db_active}") check("DB 异常 → last_error 记录", "DB 直读失败" in eng.last_error, eng.last_error) eng.poll_once() check("DB 异常后 JSON 路径仍独立投递", bot.enqueued and eng.enqueued_count == 1, f"enqueued={len(bot.enqueued)}") check("DB 异常不传播到 _run(线程安全)", True) # 上面的直接调用已证明 _poll_db_once 自吞异常 # DB 恢复 db.fail = False eng._poll_db_once() check("DB 恢复 → db_active 回 True", eng.db_active is True, f"db_active={eng.db_active}") # 非法模式回落 eng2 = DataEngine(bot=bot, conversations_path=tmp_json, poll_interval=0.5, data_source_mode="garbage") check("非法模式回落 parallel", eng2.data_source_mode == "parallel", eng2.data_source_mode) print() if FAILED: print(f"RESULT: FAIL ({len(FAILED)}) -> {FAILED}") sys.exit(1) print("RESULT: ALL PASS")