Files
kefu/wechat_rpa/test_vision_fallback.py
T
2026-08-27 14:04:28 +08:00

912 lines
39 KiB
Python

# -*- coding: utf-8 -*-
"""识别降级链路、卡死自愈与审核模式草稿的行为测试。
这里的每一条都对应一种"机器人看着在跑,其实一步都没往前走"的故障:
1. 昵称三级识别(快速 OCR → 本地检测 OCR → 视觉模型)在每一级失败后是否
真的往下走,以及升级结果是否被后续的身份计算沿用;
2. 整份未读查干净之后是否停止无意义的重复整轮扫描;
3. 审核模式留在输入框里的草稿,是否被认成机器人自己的而不是“人工草稿”;
4. 框选复制留下的选中高亮,会不会被当成"客户又发了新消息"而无限重置合并窗口;
5. 回不到“消息”页时,是否会升级、强制问模型、并把卡在哪一步喊出来,而不是
每轮打同一句话;
6. 模型已经给出的高把握处置,没能立刻执行时是否被留到下一轮重放;
7. PID 被回收后,过期的本地后台地址会不会被当成活的。
"""
import io
from contextlib import redirect_stdout
from unittest import TestCase, main, mock
import numpy as np
import session_name
from test_support import local_state_redirect
import wechat_bot
from ai_chat import _parse_session_row_name
from wechat_bot import WeChatBot
def _never_repeats():
"""永远给出互不相同的指纹:模拟面板一直在重绘、从不稳定下来。"""
counter = 0
while True:
counter += 1
yield b"frame-%d" % counter
def _bare_bot() -> WeChatBot:
bot = WeChatBot.__new__(WeChatBot)
bot._strict_visual_actions = True
bot.identity_by_name = True
bot.scale = 1.0
bot.session_item_h = 60
return bot
class SessionRowNameParsingTest(TestCase):
def test_reads_name_and_confidence(self):
parsed = _parse_session_row_name('{"name":"高瑞@微信","confidence":0.93}')
self.assertEqual(parsed["name"], "高瑞@微信")
self.assertAlmostEqual(parsed["confidence"], 0.93)
def test_two_different_names_in_one_answer_count_as_unreadable(self):
parsed = _parse_session_row_name(
'{"name":"高瑞","confidence":0.9} 也可能是 {"name":"高兴亮","confidence":0.8}'
)
self.assertEqual(parsed["name"], "")
self.assertEqual(parsed["confidence"], 0.0)
def test_empty_name_never_carries_confidence(self):
parsed = _parse_session_row_name('{"name":"","confidence":0.99}')
self.assertEqual(parsed["name"], "")
self.assertEqual(parsed["confidence"], 0.0)
def test_sentence_stuffed_into_name_is_rejected(self):
stuffed = "看不清" * 20
parsed = _parse_session_row_name(
'{"name":"' + stuffed + '","confidence":0.95}'
)
self.assertEqual(parsed["name"], "")
def test_answer_without_json_is_unreadable(self):
self.assertEqual(_parse_session_row_name("我看不清这一行")["name"], "")
class NameReaderDeepTest(TestCase):
def test_joins_the_top_line_and_ignores_the_preview_line(self):
reader = session_name.NameReader()
reader.read_layout = mock.Mock(return_value=[
{"text": "高瑞", "score": 0.95, "x1": 0, "y1": 2, "x2": 20, "y2": 18},
{"text": "@微信", "score": 0.91, "x1": 22, "y1": 3, "x2": 50, "y2": 18},
{"text": "在不在", "score": 0.99, "x1": 0, "y1": 40, "x2": 40, "y2": 56},
])
text, score = reader.read_deep(np.zeros((60, 200, 3), dtype=np.uint8))
self.assertEqual(text, "高瑞@微信")
self.assertAlmostEqual(score, 0.91)
def test_no_text_reads_nothing(self):
reader = session_name.NameReader()
reader.read_layout = mock.Mock(return_value=[])
self.assertEqual(
reader.read_deep(np.zeros((60, 200, 3), dtype=np.uint8)),
("", 0.0),
)
class RowNameEscalationTest(TestCase):
def _bot(self):
bot = _bare_bot()
bot._row_display_name = mock.Mock(return_value="")
bot._row_name_panel = mock.Mock(
return_value=np.zeros((20, 120, 3), dtype=np.uint8)
)
bot._raw_session_fingerprint = mock.Mock(return_value=b"a" * 8)
bot._canonical_fp = mock.Mock(side_effect=lambda raw: raw)
bot._row_center_from_badge = mock.Mock(return_value=30)
bot._row_preview_text = mock.Mock(return_value="在不在")
bot._ui_guard_image_bytes = mock.Mock(return_value=b"png")
bot._call_model_with_observer = mock.Mock(
side_effect=lambda cb, *a, **kw: cb(*a, **kw)
)
return bot
def test_fast_ocr_result_short_circuits_every_deeper_tier(self):
bot = self._bot()
bot._row_display_name = mock.Mock(return_value="高瑞@微信")
bot._name_reader_instance = mock.Mock()
img = np.zeros((300, 200, 3), dtype=np.uint8)
with redirect_stdout(io.StringIO()):
self.assertEqual(bot._row_display_name_deep(img, 30), "高瑞@微信")
bot._name_reader_instance.read_deep.assert_not_called()
def test_local_detection_ocr_takes_over_when_fast_path_reads_nothing(self):
bot = self._bot()
reader = mock.Mock()
reader.read_deep.return_value = ("高瑞@微信", 0.93)
reader.canonical.side_effect = lambda name: name
bot._name_reader_instance = reader
img = np.zeros((300, 200, 3), dtype=np.uint8)
with redirect_stdout(io.StringIO()) as out:
self.assertEqual(bot._row_display_name_deep(img, 30), "高瑞@微信")
reader.read_deep.assert_called_once()
self.assertIn("本地检测OCR", out.getvalue())
def test_low_score_local_detection_falls_through_to_the_model(self):
bot = self._bot()
reader = mock.Mock()
reader.read_deep.return_value = ("高瑞@微信", 0.30)
reader.canonical.side_effect = lambda name: name
bot._name_reader_instance = reader
img = np.zeros((300, 200, 3), dtype=np.uint8)
with mock.patch(
"ai_chat.read_wecom_session_row_name",
return_value={"name": "高瑞@微信", "confidence": 0.95},
):
with redirect_stdout(io.StringIO()) as out:
self.assertEqual(bot._row_display_name_deep(img, 30), "高瑞@微信")
self.assertIn("视觉模型", out.getvalue())
def test_model_below_confidence_floor_is_treated_as_unreadable(self):
bot = self._bot()
reader = mock.Mock()
reader.read_deep.return_value = ("", 0.0)
reader.canonical.side_effect = lambda name: name
bot._name_reader_instance = reader
img = np.zeros((300, 200, 3), dtype=np.uint8)
with mock.patch(
"ai_chat.read_wecom_session_row_name",
return_value={"name": "高瑞@微信", "confidence": 0.40},
):
with redirect_stdout(io.StringIO()):
self.assertEqual(bot._row_display_name_deep(img, 30), "")
def test_model_failure_never_raises_into_the_polling_loop(self):
bot = self._bot()
reader = mock.Mock()
reader.read_deep.return_value = ("", 0.0)
bot._name_reader_instance = reader
img = np.zeros((300, 200, 3), dtype=np.uint8)
with mock.patch(
"ai_chat.read_wecom_session_row_name",
side_effect=RuntimeError("链路断了"),
):
with redirect_stdout(io.StringIO()) as out:
self.assertEqual(bot._row_display_name_deep(img, 30), "")
self.assertIn("视觉模型读昵称失败", out.getvalue())
def test_escalated_name_is_cached_so_the_second_frame_agrees(self):
bot = self._bot()
reader = mock.Mock()
reader.read_deep.return_value = ("高瑞@微信", 0.93)
reader.canonical.side_effect = lambda name: name
bot._name_reader_instance = reader
img = np.zeros((300, 200, 3), dtype=np.uint8)
with redirect_stdout(io.StringIO()):
bot._row_display_name_deep(img, 30)
bot._row_display_name_deep(img, 30)
self.assertEqual(reader.read_deep.call_count, 1)
self.assertEqual(bot._row_name_deep_cached(img, 30), "高瑞@微信")
def test_fingerprint_reuses_the_escalated_name_instead_of_pixel_fallback(self):
bot = self._bot()
bot._session_identity_trustworthy = mock.Mock(return_value=True)
reader = mock.Mock()
reader.read_deep.return_value = ("高瑞@微信", 0.93)
reader.canonical.side_effect = lambda name: name
bot._name_reader_instance = reader
img = np.zeros((300, 200, 3), dtype=np.uint8)
with redirect_stdout(io.StringIO()):
bot._row_display_name_deep(img, 30)
self.assertEqual(
bot._session_fingerprint(img, 30),
WeChatBot._fp_from_name("高瑞@微信"),
)
def test_relaxed_mode_never_calls_a_remote_model(self):
bot = self._bot()
bot._strict_visual_actions = False
bot._name_reader_instance = mock.Mock()
img = np.zeros((300, 200, 3), dtype=np.uint8)
with redirect_stdout(io.StringIO()):
self.assertEqual(bot._row_display_name_deep(img, 30), "")
bot._name_reader_instance.read_deep.assert_not_called()
class UnreadExhaustionTest(TestCase):
def _bot(self):
bot = _bare_bot()
bot._global_unread_signature = mock.Mock(return_value=b"badge-1")
bot._log_unread_discovery = mock.Mock()
bot._last_global_unread_signature = b""
bot._last_session_scan_ts = 0.0
bot._exhausted_unread_signature = b""
bot._exhausted_unread_ts = 0.0
return bot
def test_first_scan_is_allowed(self):
bot = self._bot()
self.assertTrue(bot._deep_unread_scan_allowed(None))
def test_exhausted_badge_is_not_rescanned_every_cooldown(self):
bot = self._bot()
self.assertTrue(bot._deep_unread_scan_allowed(None))
bot._mark_global_unread_exhausted("全是系统入口")
# 冷却早就过了,但这批未读已经逐行查干净,不该再整轮重来
bot._last_session_scan_ts = 0.0
with mock.patch.object(wechat_bot.time, "monotonic", return_value=10_000.0):
bot._exhausted_unread_ts = 10_000.0 - 30.0
self.assertFalse(bot._deep_unread_scan_allowed(None))
def test_a_new_unread_badge_clears_the_exhausted_mark(self):
bot = self._bot()
self.assertTrue(bot._deep_unread_scan_allowed(None))
bot._mark_global_unread_exhausted("全是系统入口")
bot._global_unread_signature = mock.Mock(return_value=b"badge-2")
self.assertTrue(bot._deep_unread_scan_allowed(None))
self.assertEqual(bot._exhausted_unread_signature, b"")
def test_backoff_expiry_re_verifies_the_same_badge(self):
bot = self._bot()
self.assertTrue(bot._deep_unread_scan_allowed(None))
bot._mark_global_unread_exhausted("全是系统入口")
bot._last_session_scan_ts = 0.0
with mock.patch.object(wechat_bot.time, "monotonic", return_value=10_000.0):
bot._exhausted_unread_ts = (
10_000.0 - wechat_bot.UNREAD_EXHAUSTED_RESCAN_SECONDS - 1.0
)
with redirect_stdout(io.StringIO()):
self.assertTrue(bot._deep_unread_scan_allowed(None))
def test_no_badge_never_marks_anything_exhausted(self):
bot = self._bot()
bot._last_global_unread_signature = b""
bot._mark_global_unread_exhausted("没有徽章")
self.assertEqual(bot._exhausted_unread_signature, b"")
def test_a_row_that_could_not_be_identified_blocks_the_exhausted_mark(self):
bot = self._bot()
self.assertTrue(bot._deep_unread_scan_allowed(None))
bot._defer_unread_scan("某一行昵称没读出来")
bot._mark_global_unread_exhausted("看起来全是系统入口")
# 识别失败不等于"这里没有客户":下一轮必须照常再查一遍
self.assertEqual(bot._exhausted_unread_signature, b"")
bot._last_session_scan_ts = 0.0
self.assertTrue(bot._deep_unread_scan_allowed(None))
def test_deferral_reason_is_reported_instead_of_silently_retrying(self):
bot = self._bot()
bot._last_global_unread_signature = b"badge-1"
bot._defer_unread_scan("某一行昵称没读出来")
bot._mark_global_unread_exhausted("看起来全是系统入口")
action, detail = bot._log_unread_discovery.call_args[0]
self.assertEqual(action, "留待重试")
self.assertIn("某一行昵称没读出来", detail)
class ReviewDraftTest(TestCase):
def test_own_review_draft_is_recognised_as_the_bots_own(self):
bot = WeChatBot.__new__(WeChatBot)
state = {"awaiting_review": True, "last_pasted_draft": "在这儿陪你聊天呢"}
self.assertTrue(bot._draft_is_pending_review("在这儿陪你聊天呢", state))
def test_a_human_typed_draft_is_never_claimed_by_the_bot(self):
bot = WeChatBot.__new__(WeChatBot)
state = {"awaiting_review": True, "last_pasted_draft": "在这儿陪你聊天呢"}
self.assertFalse(bot._draft_is_pending_review("我自己来回", state))
def test_draft_outside_review_mode_is_not_a_review_draft(self):
bot = WeChatBot.__new__(WeChatBot)
state = {"awaiting_review": False, "last_pasted_draft": "在这儿陪你聊天呢"}
self.assertFalse(bot._draft_is_pending_review("在这儿陪你聊天呢", state))
def test_empty_composer_is_not_a_review_draft(self):
bot = WeChatBot.__new__(WeChatBot)
self.assertFalse(
bot._draft_is_pending_review("", {"awaiting_review": True})
)
class StaleReviewFlagTest(TestCase):
def _bot(self, mode):
bot = WeChatBot.__new__(WeChatBot)
bot.send_mode = mode
bot._pending_reply_sessions = {
"aa": {"awaiting_review": True, "review_requested_at": 1.0},
"bb": {"send_state": "sending"},
}
bot._persist_pending_replies = mock.Mock(return_value=True)
return bot
def test_switching_back_to_auto_releases_the_review_hold(self):
bot = self._bot("auto")
with redirect_stdout(io.StringIO()) as out:
bot._drop_stale_review_flags()
self.assertNotIn("awaiting_review", bot._pending_reply_sessions["aa"])
self.assertNotIn("review_requested_at", bot._pending_reply_sessions["aa"])
bot._persist_pending_replies.assert_called_once()
self.assertIn("待审核标记已解除", out.getvalue())
def test_review_mode_keeps_every_hold_untouched(self):
bot = self._bot("review")
with redirect_stdout(io.StringIO()):
bot._drop_stale_review_flags()
self.assertTrue(bot._pending_reply_sessions["aa"]["awaiting_review"])
bot._persist_pending_replies.assert_not_called()
def test_nothing_to_clear_never_touches_the_disk(self):
bot = self._bot("auto")
bot._pending_reply_sessions = {"bb": {"send_state": "sending"}}
with redirect_stdout(io.StringIO()):
bot._drop_stale_review_flags()
bot._persist_pending_replies.assert_not_called()
class ExtractionRepaintTest(TestCase):
"""框选复制留下的选中高亮,不能被当成"客户又发了新消息"。
新会话只有一条短消息时,那块高亮几乎就是聊天区的全部墨迹,画面指纹必然
和提取前对不上。不区分的话,合并窗口会被无限重置——第一次联系的客户永远
等不到回复。
"""
def _bot(self, signatures):
bot = _bare_bot()
bot._chat_surface_signature = mock.Mock(side_effect=signatures)
return bot
def test_a_repaint_that_settles_back_is_not_a_new_message(self):
bot = self._bot([b"dirty", b"clean"])
with mock.patch.object(wechat_bot.time, "sleep"):
self.assertEqual(
bot._settled_chat_surface_signature(b"clean"), b"clean"
)
def test_an_unchanged_surface_never_costs_a_second_read(self):
bot = self._bot([b"clean"])
self.assertEqual(
bot._settled_chat_surface_signature(b"clean"), b"clean"
)
self.assertEqual(bot._chat_surface_signature.call_count, 1)
def test_two_identical_new_frames_are_reported_as_a_real_change(self):
bot = self._bot([b"new", b"new"])
with mock.patch.object(wechat_bot.time, "sleep"):
self.assertEqual(
bot._settled_chat_surface_signature(b"clean"), b"new"
)
def test_no_reference_short_circuits(self):
bot = self._bot([b"whatever"])
self.assertEqual(bot._settled_chat_surface_signature(b""), b"whatever")
def test_a_surface_that_never_settles_gives_up_within_the_budget(self):
bot = _bare_bot()
bot._chat_surface_signature = mock.Mock(side_effect=_never_repeats())
with mock.patch.object(wechat_bot.time, "sleep"):
started = wechat_bot.time.monotonic()
bot._settled_chat_surface_signature(b"clean", timeout=0.3)
self.assertLess(wechat_bot.time.monotonic() - started, 3.0)
class ExtractionResetBreakerTest(TestCase):
def _bot(self):
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_sessions = {(b"n" * 8).hex(): {"batch_ready": True}}
bot._persist_pending_replies = mock.Mock(return_value=True)
return bot, b"n" * 8
def test_the_first_rounds_still_restart_the_merge_window(self):
bot, fp = self._bot()
self.assertTrue(bot._note_extraction_surface_reset(fp))
self.assertTrue(bot._note_extraction_surface_reset(fp))
def test_a_new_customer_is_never_starved_forever(self):
bot, fp = self._bot()
outcomes = [
bot._note_extraction_surface_reset(fp)
for _ in range(WeChatBot._MAX_EXTRACTION_SURFACE_RESETS)
]
self.assertEqual(
outcomes,
[True] * (WeChatBot._MAX_EXTRACTION_SURFACE_RESETS - 1) + [False],
)
def test_the_counter_restarts_after_it_fires(self):
bot, fp = self._bot()
for _ in range(WeChatBot._MAX_EXTRACTION_SURFACE_RESETS):
bot._note_extraction_surface_reset(fp)
self.assertNotIn(
"extract_surface_resets", bot._pending_reply_sessions[fp.hex()]
)
self.assertTrue(bot._note_extraction_surface_reset(fp))
def test_a_clean_round_clears_the_streak(self):
bot, fp = self._bot()
bot._note_extraction_surface_reset(fp)
bot._clear_extraction_surface_resets(fp)
self.assertNotIn(
"extract_surface_resets", bot._pending_reply_sessions[fp.hex()]
)
self.assertTrue(bot._note_extraction_surface_reset(fp))
self.assertTrue(bot._note_extraction_surface_reset(fp))
def test_clearing_an_untouched_session_never_writes_to_disk(self):
bot, fp = self._bot()
bot._clear_extraction_surface_resets(fp)
bot._persist_pending_replies.assert_not_called()
def test_a_session_without_a_task_is_allowed_to_restart(self):
bot, _fp = self._bot()
self.assertTrue(bot._note_extraction_surface_reset(b"missing0"))
class ClearChatSelectionTest(TestCase):
def _bot(self, snapshots):
bot = _bare_bot()
bot._chat_layout_snapshot = mock.Mock(side_effect=snapshots)
return bot
def test_it_stops_as_soon_as_the_highlight_is_gone(self):
bot = self._bot([b"clean"])
with mock.patch.object(wechat_bot.pyautogui, "click") as click, \
mock.patch.object(wechat_bot.time, "sleep"):
bot._clear_chat_selection(100, 200, b"clean")
click.assert_called_once_with(100, 200)
def test_it_clicks_again_when_the_first_click_did_not_take(self):
bot = self._bot(_never_repeats())
with mock.patch.object(wechat_bot.pyautogui, "click") as click, \
mock.patch.object(wechat_bot.time, "sleep"):
bot._clear_chat_selection(100, 200, b"clean")
self.assertEqual(click.call_count, 2)
def test_without_a_baseline_it_keeps_the_original_single_click(self):
bot = self._bot([])
with mock.patch.object(wechat_bot.pyautogui, "click") as click, \
mock.patch.object(wechat_bot.time, "sleep"):
bot._clear_chat_selection(100, 200, b"")
click.assert_called_once_with(100, 200)
bot._chat_layout_snapshot.assert_not_called()
class WorkspaceRecoveryEscalationTest(TestCase):
"""回不到"消息"页时,绝不允许每轮打同一句话然后什么都不做。"""
def _bot(self):
bot = _bare_bot()
bot._workspace_recovery_failures = 0
bot._workspace_recovery_since = 0.0
bot._workspace_recovery_detail = ""
bot._last_ui_guard_ts = 123.0
bot._last_ui_guard_signature = "old"
bot._last_layout_guard_ts = 123.0
bot._last_layout_guard_signature = "old"
bot._invalidate_message_nav_band = mock.Mock()
bot._invalidate_unread_group_band = mock.Mock()
bot.report_progress = mock.Mock()
self.queue_log = mock.Mock()
patcher = mock.patch.object(
WeChatBot, "queue_log", new_callable=mock.PropertyMock
)
prop = patcher.start()
prop.return_value = self.queue_log
self.addCleanup(patcher.stop)
return bot
def test_repeated_failures_drop_local_caches_and_wake_the_model(self):
bot = self._bot()
with redirect_stdout(io.StringIO()) as out:
for _ in range(WeChatBot._WORKSPACE_RECOVERY_ESCALATE_AFTER):
bot._note_workspace_recovery_failure("点击消息入口无效")
bot._invalidate_message_nav_band.assert_called_once()
# 冷却清零 = 下一轮模型立刻重新判断,而不是等 8 秒 / 45 秒
self.assertEqual(bot._last_ui_guard_ts, 0.0)
self.assertEqual(bot._last_layout_guard_ts, 0.0)
self.assertIn("已作废本地导航缓存", out.getvalue())
def test_a_long_block_is_reported_with_where_and_how_long(self):
bot = self._bot()
with redirect_stdout(io.StringIO()) as out:
for _ in range(WeChatBot._WORKSPACE_RECOVERY_ALERT_AFTER):
bot._note_workspace_recovery_failure("点击消息入口无效")
text = out.getvalue()
self.assertIn("已连续", text)
self.assertIn("最后卡在:点击消息入口无效", text)
bot.report_progress.assert_called()
self.queue_log.append.assert_called()
def test_the_step_detail_survives_a_failure_without_its_own_detail(self):
bot = self._bot()
bot._note_workspace_recovery_step("模型接管后画面仍然没变")
with redirect_stdout(io.StringIO()):
for _ in range(WeChatBot._WORKSPACE_RECOVERY_ALERT_AFTER):
bot._note_workspace_recovery_failure()
self.assertEqual(
bot._workspace_recovery_detail, "模型接管后画面仍然没变"
)
def test_getting_back_to_messages_clears_the_streak(self):
bot = self._bot()
with redirect_stdout(io.StringIO()):
for _ in range(WeChatBot._WORKSPACE_RECOVERY_ESCALATE_AFTER):
bot._note_workspace_recovery_failure("卡住了")
bot._clear_workspace_recovery_failures()
self.assertEqual(bot._workspace_recovery_failures, 0)
self.assertEqual(bot._workspace_recovery_detail, "")
def _stalled_bot(self, failures):
bot = _bare_bot()
frame = np.zeros((40, 40, 3), dtype=np.uint8)
bot._workspace_recovery_failures = failures
bot._last_ui_guard_ts = 999.0
bot._last_ui_guard_signature = "same-page"
bot._capture_full_window = mock.Mock(return_value=frame)
bot._message_workspace_ready = mock.Mock(return_value=False)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._visible_call_window_title = mock.Mock(return_value="")
bot._ui_guard_surface_signature = mock.Mock(return_value="same-page")
bot._workspace_modal_candidate = mock.Mock(return_value=None)
bot._open_messages_page = mock.Mock(return_value=False)
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._note_workspace_recovery_step = mock.Mock()
return bot
def _run_recovery(self, bot):
with mock.patch.object(
wechat_bot, "looks_like_security_verification", return_value=False
):
with redirect_stdout(io.StringIO()):
return bot._recover_message_workspace("测试")
def test_a_stalled_local_recovery_forces_the_model_past_its_cooldown(self):
"""连续回不去时,必须清掉限频真正问一次模型,而不是默默放弃。"""
bot = self._stalled_bot(WeChatBot._WORKSPACE_RECOVERY_ESCALATE_AFTER)
self.assertFalse(self._run_recovery(bot))
triggers = [call.args[0] for call in bot._run_ai_page_guard.call_args_list]
self.assertTrue(
any("强制交由模型接管" in trigger for trigger in triggers),
triggers,
)
# 冷却被清掉,模型这一次是真的被问到了,不是又被限频挡回来
self.assertEqual(bot._last_ui_guard_ts, 0.0)
def test_the_first_rounds_do_not_burn_a_forced_model_call(self):
bot = self._stalled_bot(0)
self.assertFalse(self._run_recovery(bot))
triggers = [call.args[0] for call in bot._run_ai_page_guard.call_args_list]
self.assertFalse(
any("强制交由模型接管" in trigger for trigger in triggers),
triggers,
)
self.assertEqual(bot._last_ui_guard_ts, 999.0)
def test_a_forced_handover_that_works_is_reported_as_recovery(self):
bot = self._stalled_bot(WeChatBot._WORKSPACE_RECOVERY_ESCALATE_AFTER)
bot._run_ai_page_guard = mock.Mock(side_effect=[False, True])
bot._message_workspace_ready = mock.Mock(side_effect=[False, True])
self.assertTrue(self._run_recovery(bot))
def test_every_give_up_records_where_it_stopped(self):
bot = self._stalled_bot(0)
self._run_recovery(bot)
bot._note_workspace_recovery_step.assert_called_once()
self.assertIn(
"没能回到消息页",
bot._note_workspace_recovery_step.call_args[0][0],
)
class PendingModelVerdictTest(TestCase):
"""模型已经给出 99% 把握的处置,不能因为一时没执行成就丢掉。"""
def _bot(self):
bot = _bare_bot()
bot._pending_ui_recovery = {}
bot._open_messages_page = mock.Mock(return_value=False)
return bot
def test_a_verdict_that_could_not_run_is_kept(self):
bot = self._bot()
with redirect_stdout(io.StringIO()) as out:
bot._remember_pending_ui_recovery(
"sig-1", "open_messages", "non_message_page", 0.99, "工作台业绩页"
)
self.assertEqual(bot._pending_ui_recovery["action"], "open_messages")
self.assertIn("下一轮不等冷却直接重试", out.getvalue())
def test_the_kept_verdict_is_replayed_on_the_same_page(self):
bot = self._bot()
bot._remember_pending_ui_recovery(
"sig-1", "open_messages", "non_message_page", 0.99, ""
)
bot._open_messages_page = mock.Mock(return_value=True)
with redirect_stdout(io.StringIO()) as out:
self.assertTrue(bot._replay_pending_ui_recovery("sig-1"))
bot._open_messages_page.assert_called_once()
self.assertIn("重放模型上一轮的处置", out.getvalue())
self.assertEqual(bot._pending_ui_recovery, {})
def test_a_different_page_discards_the_stale_verdict(self):
bot = self._bot()
bot._remember_pending_ui_recovery(
"sig-1", "open_messages", "non_message_page", 0.99, ""
)
with redirect_stdout(io.StringIO()):
self.assertFalse(bot._replay_pending_ui_recovery("sig-2"))
self.assertEqual(bot._pending_ui_recovery, {})
bot._open_messages_page.assert_not_called()
def test_an_expired_verdict_is_discarded(self):
bot = self._bot()
bot._pending_ui_recovery = {
"signature": "sig-1",
"action": "open_messages",
"ts": wechat_bot.time.monotonic()
- WeChatBot._PENDING_UI_RECOVERY_TTL
- 1.0,
}
self.assertFalse(bot._replay_pending_ui_recovery("sig-1"))
self.assertEqual(bot._pending_ui_recovery, {})
def test_a_failed_replay_stays_queued_for_the_next_round(self):
bot = self._bot()
bot._remember_pending_ui_recovery(
"sig-1", "open_messages", "non_message_page", 0.99, ""
)
with redirect_stdout(io.StringIO()):
self.assertFalse(bot._replay_pending_ui_recovery("sig-1"))
self.assertEqual(bot._pending_ui_recovery["action"], "open_messages")
def test_nothing_queued_is_a_cheap_no_op(self):
bot = self._bot()
self.assertFalse(bot._replay_pending_ui_recovery("sig-1"))
bot._open_messages_page.assert_not_called()
class KnownNameTableTest(TestCase):
"""已认识昵称表必须封顶:`canonical` 是 O(表长) 的,还在轮询热路径上。"""
def test_the_table_stops_growing(self):
reader = session_name.NameReader()
for index in range(session_name.MAX_KNOWN_NAMES * 3):
reader._remember_known(f"客户编号{index:06d}")
self.assertLessEqual(len(reader._known), session_name.MAX_KNOWN_NAMES)
def test_the_name_just_seen_always_survives_the_prune(self):
reader = session_name.NameReader()
for index in range(session_name.MAX_KNOWN_NAMES):
reader._known[f"老客户{index:06d}"] = 99
reader._remember_known("刚刚到店的新客户")
self.assertIn("刚刚到店的新客户", reader._known)
def test_frequent_contacts_are_kept_over_one_off_names(self):
reader = session_name.NameReader()
reader._known = {f"路人{index:06d}": 1 for index in range(
session_name.MAX_KNOWN_NAMES
)}
reader._known["天天来的老客户"] = 5000
reader._remember_known("新来的")
self.assertIn("天天来的老客户", reader._known)
def test_misread_correction_still_works_after_a_prune(self):
reader = session_name.NameReader()
for _ in range(50):
reader._remember_known("一个小迷糊@微信")
for index in range(session_name.MAX_KNOWN_NAMES * 2):
reader._remember_known(f"路人{index:06d}")
self.assertEqual(reader.canonical("一个小迷糊@徽信"), "一个小迷糊@微信")
def test_remember_also_respects_the_cap(self):
reader = session_name.NameReader()
for index in range(session_name.MAX_KNOWN_NAMES * 2):
reader.remember(f"档案恢复{index:06d}")
self.assertLessEqual(len(reader._known), session_name.MAX_KNOWN_NAMES)
class ClockRollbackTest(TestCase):
"""系统时钟往回走时,所有"够久了吗"的判断都不能永远不成立。"""
def test_a_normal_elapsed_is_returned_as_is(self):
self.assertAlmostEqual(
WeChatBot._wall_elapsed(wechat_bot.time.time() - 30.0), 30.0, places=0
)
def test_a_missing_timestamp_counts_as_infinitely_old(self):
# "没记录"必须回答"够久了",否则没有时间戳的任务会被当成刚刚发生
for value in (None, 0, 0.0, "", "坏数据"):
self.assertEqual(
WeChatBot._wall_elapsed(value),
WeChatBot._CLOCK_ROLLBACK_ELAPSED_SECONDS,
value,
)
def test_a_tiny_negative_drift_is_treated_as_just_now(self):
drift = WeChatBot._CLOCK_SKEW_TOLERANCE_SECONDS / 2.0
self.assertEqual(
WeChatBot._wall_elapsed(wechat_bot.time.time() + drift), 0.0
)
def test_a_real_rollback_is_treated_as_long_ago_not_negative(self):
elapsed = WeChatBot._wall_elapsed(wechat_bot.time.time() + 600.0)
self.assertEqual(elapsed, WeChatBot._CLOCK_ROLLBACK_ELAPSED_SECONDS)
self.assertGreater(elapsed, 0.0)
def test_an_unrepliable_session_still_gets_rechecked_after_a_rollback(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"u" * 8
# 标记时间落在未来:时钟被回拨过。不修的话这个会话永远不再复查
bot._unrepliable_sessions = {fp.hex(): wechat_bot.time.time() + 3600.0}
bot._composerless_strikes = {}
bot._unrepliable_path = ""
self.assertFalse(bot._session_is_unrepliable(fp))
def test_mouse_idle_baseline_from_the_future_is_pulled_back(self):
bot = _bare_bot()
bot._bot_controlling = False
bot._last_mouse_pos = (0, 0)
bot._mouse_pos = mock.Mock(return_value=(0, 0))
bot._clock_rollback_logged = False
bot._last_user_move_ts = wechat_bot.time.time() + 600.0
with redirect_stdout(io.StringIO()) as out:
bot._sync_user_mouse_activity()
# 基线回到当下 → 正常再等一个空闲间隔,而不是永远等下去
self.assertLessEqual(
bot._last_user_move_ts, wechat_bot.time.time() + 0.5
)
self.assertIn("检测到系统时间被回拨", out.getvalue())
def test_the_rollback_notice_is_printed_only_once(self):
bot = _bare_bot()
bot._clock_rollback_logged = False
with redirect_stdout(io.StringIO()) as out:
bot._note_clock_rollback("测试")
bot._note_clock_rollback("测试")
self.assertEqual(out.getvalue().count("检测到系统时间被回拨"), 1)
def test_a_normal_mouse_baseline_is_left_alone(self):
bot = _bare_bot()
bot._bot_controlling = False
bot._last_mouse_pos = (0, 0)
bot._mouse_pos = mock.Mock(return_value=(0, 0))
bot._clock_rollback_logged = False
baseline = wechat_bot.time.time() - 3.0
bot._last_user_move_ts = baseline
with redirect_stdout(io.StringIO()) as out:
bot._sync_user_mouse_activity()
self.assertEqual(bot._last_user_move_ts, baseline)
self.assertNotIn("回拨", out.getvalue())
class StaleLocalRuntimeTest(TestCase):
"""PID 会被回收,所以"PID 还活着"证明不了本地后台还在。"""
def setUp(self):
import backend_client
self.backend_client = backend_client
self._saved_file = backend_client.RUNTIME_FILE
backend_client._local_port_probe_cache.clear()
def tearDown(self):
self.backend_client.RUNTIME_FILE = self._saved_file
self.backend_client._local_port_probe_cache.clear()
def _write_runtime(self, port):
import json
import os
import tempfile
from pathlib import Path
handle, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(handle, "w", encoding="utf-8") as stream:
json.dump({
"pid": os.getpid(), # 本进程一定活着
"host": "127.0.0.1",
"port": port,
"server_url": f"http://127.0.0.1:{port}",
"local_sync_token": "token",
"started_at": "2026-07-23T17:35:16+08:00",
}, stream)
self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
self.backend_client.RUNTIME_FILE = Path(path)
def test_a_live_pid_on_a_dead_port_is_not_a_live_backend(self):
import socket
# 先占一个端口再放掉,拿到一个确定没人监听的号
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
dead_port = probe.getsockname()[1]
probe.close()
self._write_runtime(dead_port)
self.assertEqual(self.backend_client.discover_local_runtime(), {})
def test_a_port_that_is_actually_listening_is_accepted(self):
import socket
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(1)
self.addCleanup(listener.close)
live_port = listener.getsockname()[1]
self._write_runtime(live_port)
runtime = self.backend_client.discover_local_runtime()
self.assertEqual(runtime.get("port"), live_port)
self.assertEqual(runtime.get("local_sync_token"), "token")
def test_the_probe_result_is_cached_between_calls(self):
import socket
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(1)
self.addCleanup(listener.close)
live_port = listener.getsockname()[1]
self._write_runtime(live_port)
with mock.patch.object(
self.backend_client.socket,
"create_connection",
wraps=self.backend_client.socket.create_connection,
) as connect:
self.backend_client.discover_local_runtime()
self.backend_client.discover_local_runtime()
self.assertEqual(connect.call_count, 1)
class VisionCapabilityReportTest(TestCase):
def _report(self, **ai):
bot = _bare_bot()
reader = mock.Mock()
reader.available = ai.pop("ocr", True)
bot._name_reader_instance = reader
config = mock.Mock()
config.AI_ENABLED = ai.get("enabled", True)
config.AI_UI_GUARD_ENABLED = ai.get("guard", True)
config.AI_USE_VISION = ai.get("vision", False)
config.AI_PROVIDER_TYPE = "dify"
config.AI_API_BASE = ai.get("base", "http://ai.example/v1")
with mock.patch.dict("sys.modules", {"ai_config": config}):
with redirect_stdout(io.StringIO()) as out:
return bot.report_vision_capability(), out.getvalue()
def test_healthy_stack_reports_model_fallback_available(self):
report, text = self._report()
self.assertTrue(report["local_ocr"])
self.assertTrue(report["model_fallback"])
self.assertIn("模型兜底识别:已启用", text)
def test_missing_local_ocr_is_called_out_loudly(self):
report, text = self._report(ocr=False)
self.assertFalse(report["local_ocr"])
self.assertIn("本地 OCR:不可用", text)
def test_disabled_ai_reports_no_model_fallback(self):
report, text = self._report(enabled=False)
self.assertFalse(report["model_fallback"])
self.assertIn("AI 总开关已关闭", text)
def test_guard_switch_off_reports_no_model_fallback(self):
report, text = self._report(guard=False)
self.assertFalse(report["model_fallback"])
self.assertIn("视觉守护开关已关闭", text)
def test_missing_api_base_reports_no_model_fallback(self):
report, text = self._report(base="")
self.assertFalse(report["model_fallback"])
self.assertIn("未配置 AI 接口地址", text)
def setUpModule():
# 别让测试读到开发机上的真实桌面端配置——配过模型网关的机器会让
# `ai_chat.current_provider()` 整体改走网关分支,一大片无关测试跟着变行为。
local_state_redirect.start()
def tearDownModule():
local_state_redirect.stop()
if __name__ == "__main__":
main()