This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
+147
View File
@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
"""「检测引擎」卡片冒烟测试:offscreen 实例化 DashboardPage
验证初始状态、信号契约、radio 置灰联动;并轻量验证
MainWindow 的设置加载校验与合并保存逻辑。
"""
from __future__ import annotations
import json
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.argv = [sys.argv[0], "--qt-smoke-test"]
from PySide6.QtWidgets import QApplication
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wechat_gui_qt as gui
FAILED = []
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)
app = QApplication.instance() or QApplication(sys.argv)
# ---------- 1. DashboardPage 初始状态 ----------
page = gui.DashboardPage(engine_settings={
"engine_a_enabled": True,
"enable_engine_b": False,
"engine_b_data_source": "db",
})
check("引擎A默认勾选", page.engine_a_check.isChecked() is True)
check("引擎B默认勾选(传入False)", page.engine_b_check.isChecked() is False)
check("数据源=db 时仅DB单选", page.engine_b_db_only.isChecked() is True
and page.engine_b_parallel.isChecked() is False)
check("引擎B关闭→数据源置灰", page.engine_b_parallel.isEnabled() is False
and page.engine_b_db_only.isEnabled() is False
and page.engine_b_json_only.isEnabled() is False)
# ---------- 2. 信号契约 ----------
captured: list[dict] = []
page.settingsChanged.connect(captured.append)
# 2a. 打开引擎 B(radio 应恢复可用)
page.engine_b_check.setChecked(True)
check("引擎B打开→数据源恢复可用", page.engine_b_parallel.isEnabled() is True)
check("信号已发射(打开B)", len(captured) == 1, str(captured[-1]) if captured else "none")
# 2b. 切换数据源 → json
page.engine_b_json_only.setChecked(True)
check("信号含 engine_b_data_source=json",
captured and captured[-1].get("engine_b_data_source") == "json",
str(captured[-1]) if captured else "none")
# 2c. 关引擎 A
page.engine_a_check.setChecked(False)
check("信号含 engine_a_enabled=False",
captured and captured[-1].get("engine_a_enabled") is False,
str(captured[-1]) if captured else "none")
check("信号含 enable_engine_b=True",
captured and captured[-1].get("enable_engine_b") is True,
str(captured[-1]) if captured else "none")
# 2d. 键集合完整(不多不少)
last = captured[-1] if captured else {}
expect_keys = {"engine_a_enabled", "enable_engine_b", "engine_b_data_source"}
check("信号键集合完整", set(last.keys()) == expect_keys, str(sorted(last.keys())))
# 2e. parallel 模式
page.engine_b_parallel.setChecked(True)
check("parallel 模式信号", captured[-1].get("engine_b_data_source") == "parallel",
str(captured[-1]))
# ---------- 3. 独立实例默认 parallel ----------
page2 = gui.DashboardPage(engine_settings={})
check("空配置默认 parallel", page2.engine_b_parallel.isChecked() is True)
check("空配置默认双开", page2.engine_a_check.isChecked() is True
and page2.engine_b_check.isChecked() is True)
# ---------- 4. MainWindow 设置加载校验(轻量,不建 WebEngine----------
mw = gui.MainWindow.__new__(gui.MainWindow)
# 指向临时设置文件,避免污染真实配置
import tempfile
from pathlib import Path
tmp_settings = Path(tempfile.mkdtemp()) / "app_settings.json"
tmp_settings.write_text(
'{"engine_a_enabled": false, "enable_engine_b": true, '
'"engine_b_data_source": "weird", "poll_interval": 1.5}',
encoding="utf-8",
)
orig_file = gui.APP_SETTINGS_FILE
gui.APP_SETTINGS_FILE = tmp_settings
try:
loaded = mw._load_runtime_settings()
finally:
gui.APP_SETTINGS_FILE = orig_file
check("加载: engine_a_enabled 转 bool False", loaded.get("engine_a_enabled") is False,
str(loaded.get("engine_a_enabled")))
check("加载: 非法数据源回落 parallel",
loaded.get("engine_b_data_source") == "parallel",
str(loaded.get("engine_b_data_source")))
check("加载: enable_engine_b 保留 True", loaded.get("enable_engine_b") is True)
# ---------- 5. _on_dashboard_engine_settings 合并保存 ----------
saved: dict = {}
class FakePage:
def mark_saved(self, ok, msg): # noqa: N802
pass
def values(self):
return {}
mw.runtime_settings = {"engine_a_enabled": True, "enable_engine_b": True,
"engine_b_data_source": "parallel", "poll_interval": 2.0}
mw.settings_page = FakePage()
logs: list = []
mw.append_log = lambda msg, level="": logs.append((msg, level)) # noqa: E731
mw._sync_auto_launch = lambda enabled: None # noqa: E731
gui.APP_SETTINGS_FILE = tmp_settings
try:
mw._on_dashboard_engine_settings({
"engine_a_enabled": False,
"engine_b_data_source": "db",
})
saved = dict(mw.runtime_settings)
finally:
gui.APP_SETTINGS_FILE = orig_file
check("合并: engine_a_enabled=False 生效", saved.get("engine_a_enabled") is False)
check("合并: engine_b_data_source=db 生效", saved.get("engine_b_data_source") == "db")
check("合并: 未涉及的键保留", saved.get("enable_engine_b") is True
and saved.get("poll_interval") == 2.0)
check("合并: 已持久化到文件",
json.loads(tmp_settings.read_text(encoding="utf-8")).get("engine_a_enabled") is False)
print()
if FAILED:
print(f"RESULT: FAIL ({len(FAILED)}) -> {FAILED}")
sys.exit(1)
print("RESULT: ALL PASS")
+151
View File
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""引擎 B 数据源模式单测:parallel / db / json 三模式调度 + DB 故障隔离。
验证点:
1. json 模式:每轮只跑 conversations.jsonpoll_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")
+98
View File
@@ -0,0 +1,98 @@
"""验证 _expire_unreachable_pending 的快速放弃逻辑。
覆盖场景:
1. 有 staged_reply_text 的任务:失败 >=3 次即放弃(阈值 3)
2. 有 staged_reply_text 的任务:失败 <3 次保留
3. 无 staged_reply_text 的任务:失败 <8 次保留(原阈值 8)
4. 无 staged_reply_text 的任务:失败 >=8 次放弃
5. 放弃后任务从队列移除(_clear_reply_pending 被调用)
6. 放弃后不落档(_commit_staged_exchange 不被调用)
"""
import os
import sys
import time
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + "/..")
from wechat_bot import WeChatBot # noqa: E402
def make_bot():
bot = WeChatBot.__new__(WeChatBot)
bot._pending_lock = None
bot._pending_reply_sessions = {}
bot._pending_reply_path = ""
bot._pending_scan_progress = {}
bot._pending_exchanges = {}
bot._cancelled_reply_sessions = set()
bot._unrepliable_sessions = {}
bot._uncertain_tracking = {}
bot._last_send_receipt_visible_text = ""
bot._reset_pending_batch = lambda fp: None
bot._forget_uncertain_tracking = lambda fp: None
bot._persist_pending_replies = lambda: None
bot._persist_unrepliable_sessions = lambda: None
bot.store = mock.MagicMock()
bot.remember_exchange = mock.MagicMock()
bot._commit_staged_exchange = mock.MagicMock()
return bot
def run():
passed = 0
def check(name, cond):
nonlocal passed
if cond:
passed += 1
print(f" [PASS] {name}")
else:
print(f" [FAIL] {name}")
print("== 有 AI 回复的任务快速放弃 ==")
bot = make_bot()
fp = bytes.fromhex("a1" * 20)
# 失败 2 次 + 有回复 → 保留
state = {"resume_failures": 2, "staged_reply_text": "在呢,有什么事你慢慢说"}
bot._pending_reply_sessions[fp.hex()] = state
r = bot._expire_unreachable_pending(fp, state)
check("staged 回复失败2次 → 保留", r is False and fp.hex() in bot._pending_reply_sessions)
# 失败 3 次 + 有回复 → 放弃
state["resume_failures"] = 3
r = bot._expire_unreachable_pending(fp, state)
check("staged 回复失败3次 → 放弃", r is True and fp.hex() not in bot._pending_reply_sessions)
check("放弃时不落档(回复未发出)", bot._commit_staged_exchange.call_count == 0)
print("== 纯定位任务保持原阈值 8 ==")
bot = make_bot()
fp2 = bytes.fromhex("b2" * 20)
state2 = {"resume_failures": 7} # 无 staged_reply_text
bot._pending_reply_sessions[fp2.hex()] = state2
r = bot._expire_unreachable_pending(fp2, state2)
check("无回复失败7次 → 保留", r is False and fp2.hex() in bot._pending_reply_sessions)
state2["resume_failures"] = 8
r = bot._expire_unreachable_pending(fp2, state2)
check("无回复失败8次 → 放弃", r is True and fp2.hex() not in bot._pending_reply_sessions)
print("== 边界与异常 ==")
bot = make_bot()
fp3 = bytes.fromhex("c3" * 20)
state3 = {"resume_failures": "invalid", "staged_reply_text": " "}
bot._pending_reply_sessions[fp3.hex()] = state3
r = bot._expire_unreachable_pending(fp3, state3)
check("非法 failures 且空回复 → 按0处理保留", r is False and fp3.hex() in bot._pending_reply_sessions)
bot = make_bot()
fp4 = bytes.fromhex("d4" * 20)
state4 = {"resume_failures": 3, "staged_reply_text": " ", "reply_text": "x"}
bot._pending_reply_sessions[fp4.hex()] = state4
r = bot._expire_unreachable_pending(fp4, state4)
check("空白 staged 回复按纯定位处理(3次保留)", r is False and fp4.hex() in bot._pending_reply_sessions)
print(f"\n结果: {passed}/7 通过")
return passed == 7
if __name__ == "__main__":
ok = run()
sys.exit(0 if ok else 1)
+127
View File
@@ -0,0 +1,127 @@
"""端到端验证任务池闭环:投递 → 发送完成移除 → 新消息重新投递。
覆盖用户核心诉求:
1. AI 回复成功(sent)后任务从队列移除
2. 对方发新消息 = 新 dedup_key → 重新投递新任务(不被 _recently 防抖挡住)
3. 同一消息不会被重复投递(防抖生效)
4. has_active_pending 对已移除任务返回 False → 允许新投递
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + "/..")
from engine_b import DataEngine # noqa: E402
class FakeBot:
"""最小化 bot 桩:记录投递、模拟任务完成移除。"""
def __init__(self):
self.pending = {} # fp_hex -> state
self.enqueued = []
self.removed = []
def has_active_pending(self, fp_hex):
state = self.pending.get(fp_hex)
if state is None:
return False
return str(state.get("stage") or "") not in {
"sent", "cancelled", "expired", "cleared",
}
def enqueue_detected(self, fp_hex, dedup_key="", detected_by="engine_b",
chat_text="", display_name="", last_lines=None):
if fp_hex in self.pending and self.has_active_pending(fp_hex):
return False, "已有活跃任务"
self.pending[fp_hex] = {
"stage": "queued",
"dedup_key": dedup_key,
"chat_text": chat_text,
"display_name": display_name,
}
self.enqueued.append({
"fp_hex": fp_hex, "dedup_key": dedup_key,
"chat_text": chat_text, "display_name": display_name,
})
return True, "ok"
def mark_sent_and_remove(self, fp_hex):
"""模拟发送成功 → 任务移除。"""
self.pending.pop(fp_hex, None)
self.removed.append(fp_hex)
def run():
passed = 0
def check(name, cond):
nonlocal passed
if cond:
passed += 1
print(f" [PASS] {name}")
else:
print(f" [FAIL] {name}")
bot = FakeBot()
eng = DataEngine.__new__(DataEngine)
eng.bot = bot
eng._recently = {}
eng.processed_count = 0
eng.enqueued_count = 0
eng.last_error = ""
fp = "ab" * 40 # 80 hex chars
now = int(time.time())
print("== 场景1:新消息投递 ==")
entry1 = {
"display_name": "测试客户",
"history": [{"content": "第一条", "ts": now - 300}],
}
last_user = entry1["history"][-1]
eng._maybe_enqueue(fp, entry1, last_user)
check("消息1 投递成功", len(bot.enqueued) == 1 and bot.enqueued[0]["chat_text"] == "第一条")
check("任务已入队", bot.pending.get(fp, {}).get("stage") == "queued")
print("== 场景2:同一消息重复轮询不重复投递(防抖) ==")
eng._maybe_enqueue(fp, entry1, last_user)
check("同 dedup_key 300s 内不重复投递", len(bot.enqueued) == 1)
print("== 场景3:AI 回复成功 → 任务移除 ==")
bot.mark_sent_and_remove(fp)
check("发送成功任务被移除", fp not in bot.pending and fp in bot.removed)
check("has_active_pending 返回 False", bot.has_active_pending(fp) is False)
print("== 场景4:对方发新消息 → 重新投递新任务 ==")
entry2 = {
"display_name": "测试客户",
"history": [
{"content": "第一条", "ts": now - 300},
{"content": "在吗", "ts": now - 60},
],
}
last_user2 = entry2["history"][-1]
eng._maybe_enqueue(fp, entry2, last_user2)
check("新消息(新时间戳)重新投递", len(bot.enqueued) == 2)
check("新任务入队", bot.pending.get(fp, {}).get("chat_text") == "在吗")
check("新 dedup_key 不同", bot.enqueued[0]["dedup_key"] != bot.enqueued[1]["dedup_key"])
print("== 场景5:消息B在防抖窗口内第二次轮询不重复 ==")
eng._maybe_enqueue(fp, entry2, last_user2)
check("消息2 防抖生效", len(bot.enqueued) == 2)
print("== 场景6:队列已有活跃任务时引擎B只补标记不重复投递 ==")
# 场景4 后任务已入队(活跃),此时再检测到同一条消息不应重复投递
bot.enqueued_count_before = len(bot.enqueued)
eng._maybe_enqueue(fp, entry2, last_user2)
check("活跃任务存在时不重复投递", len(bot.enqueued) == 2)
print(f"\n结果: {passed}/10 通过")
return passed == 10
if __name__ == "__main__":
ok = run()
sys.exit(0 if ok else 1)
@@ -0,0 +1,75 @@
"""验证 _scroll_session_list_page 的滚动到底检测修复"""
import sys, os
SCRIPT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, SCRIPT_DIR)
import numpy as np
from wechat_bot import WeChatBot
# 构造最小 bot 对象(不需要真实窗口)
bot = WeChatBot.__new__(WeChatBot)
bot.session_item_h = 72
bot.scale = 1.0
# 构造有纹理的页面(模拟会话列表)
def make_page(h=200, w=300, seed=0):
rng = np.random.default_rng(seed)
page = rng.integers(180, 220, size=(h, w, 3), dtype=np.uint8)
# 添加一些"头像"和"文字"区域,增加 informative 像素
page[20:60, 20:80] = rng.integers(80, 160, size=(40, 60, 3), dtype=np.uint8)
page[80:120, 20:200] = rng.integers(80, 160, size=(40, 180, 3), dtype=np.uint8)
return page
a = make_page(seed=1)
b = a.copy()
# shift=0 应该返回 True(内容完全相同)
assert bot._session_pages_overlap_at_shift(a, b, 0) is True, "shift=0 同页应返回 True"
# 微小差异(模拟时间戳变化,1% 区域不同)
b2 = a.copy()
b2[10:20, 10:30] = 255
# shift=0 时,差异区域可能导致覆盖率 <90%,返回 False
overlap0 = bot._session_pages_overlap_at_shift(a, b2, 0)
assert isinstance(overlap0, bool), "应返回 bool"
# --- 测试 _any_scroll_overlap ---
# 完全相同的页面 -> 任何 shift 都匹配
assert bot._any_scroll_overlap(a, b) is True, "同页应返回 True"
# 模拟滚动 1 行(72px
scrolled = np.zeros((200, 300, 3), dtype=np.uint8)
scrolled[:-72] = a[72:]
scrolled[-72:] = 128
overlap = bot._any_scroll_overlap(a, scrolled)
# 如果滚动 72px 确实存在重叠,应返回 True
print(f"[INFO] 滚动 72px 重叠检测: {overlap}")
# --- 测试 _scroll_session_list_page 的签名+重叠双重检测 ---
# 模拟时间戳微差(真实场景:时间戳只占右上角很小区域,颜色差异不大)
a_ts = make_page(seed=3)
b_ts = a_ts.copy()
# 模拟右上角时间戳从 "14:51" 变为 "14:52",只影响 12x40 区域,颜色差异小
b_ts[8:20, 240:280] = np.clip(b_ts[8:20, 240:280].astype(np.int16) + 15, 0, 255).astype(np.uint8)
sig_a = bot._session_page_signature(a_ts)
sig_b = bot._session_page_signature(b_ts)
print(f"[INFO] 时间戳微差时签名相同: {sig_a == sig_b}")
if sig_a != sig_b:
any_overlap = bot._any_scroll_overlap(a_ts, b_ts)
print(f"[INFO] 签名不同但 _any_scroll_overlap: {any_overlap}")
if any_overlap:
print("[PASS] 修复有效:时间戳微差导致签名不同但无有效滚动时,会返回 None")
else:
print("[INFO] 时间戳差异被识别为有意义变化;继续检查滚动检测是否仍有效...")
# 如果页面确实滚动了一行,应仍被识别为有效滚动
scrolled_ts = np.zeros_like(a_ts)
scrolled_ts[:-72] = a_ts[72:]
scrolled_ts[-72:] = 128
scroll_overlap = bot._any_scroll_overlap(a_ts, scrolled_ts)
print(f"[INFO] 滚动 72px 时 _any_scroll_overlap: {scroll_overlap}")
if scroll_overlap:
print("[PASS] 滚动检测仍有效:真实滚动被识别为有效滚动")
else:
print("[PASS] 时间戳微差未影响签名,无需修复")