# -*- 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()