# -*- coding: utf-8 -*- """自动回复短句净化、验证页识别与发送节流测试。""" import io import json import os import tempfile import threading import time from collections import deque from contextlib import ExitStack, redirect_stdout from unittest import SkipTest, TestCase, main, mock import numpy as np from ai_chat import ( _dify_query_from_chat, _history_messages, _humanize, _finalize_vision_reply, _parse_ui_guard_decision, _parse_wecom_layout_decision, _repair_obvious_mismatch, _user_turn, classify_wecom_ui, call_ai_vision, detect_media_types, latest_customer_message, latest_customer_turn, media_archive_text, media_text_content, safe_media_reply, VISION_NO_INCOMING, VISION_VOICE_NEEDS_TEXT, _vision_chat_prompt, ) import wechat_bot from conversation_store import ConversationStore from test_support import queue_log_redirect from registration_store import RegistrationStore, process_registration_reply from wechat_bot import ( CHAT_BOTTOM_FROM_BOTTOM, INPUT_Y_FROM_BOTTOM, WeChatBot, composer_divider_matches, find_blocking_modal_close, find_wx_hwnd, infer_composer_top, infer_chat_pane_right, infer_navigation_width, infer_session_list_width, looks_like_modal_scrim, looks_like_security_verification, fit_window_inside_work_area, window_spans_multiple_displays, window_looks_rendered, ) class ReplyStyleTest(TestCase): CHAT_SAMPLE = ( "高兴亮 7/28 15:54:54\n" "抱歉刚才说得太复杂了,就是让您多休息,别太累着\n" "一个小迷糊@微信@微信联系人 7/28 15:55:08\n" "你多大了" ) def test_removes_markdown_canned_closing_and_extra_questions(self): reply = _humanize( "### 回复\n" "1. 您好,先别太着急,今天血糖大概多少?\n" "2. 是空腹还是餐后测的?\n" "3. 希望以上建议能帮到您。" ) self.assertNotIn("###", reply) self.assertNotIn("希望以上建议能帮到您", reply) self.assertNotIn("您好", reply) self.assertEqual(reply.count("?") + reply.count("?"), 1) self.assertLessEqual(len(reply), 100) def test_emergency_reply_keeps_three_short_sentences(self): reply = _humanize("先别慌。现在立即拨打120。不要自己开车。之后再联系我。") self.assertIn("120", reply) self.assertIn("不要自己开车", reply) self.assertNotIn("之后再联系我", reply) def test_extracts_the_real_latest_customer_sentence(self): self.assertEqual(latest_customer_message(self.CHAT_SAMPLE), "你多大了") def test_merges_five_consecutive_customer_bubbles_as_one_turn(self): chat = ( "贴心管家 7/29 10:00:00\n您想问哪方面呢\n" "客户甲 7/29 10:00:03\n我这两天总口渴\n" "客户甲 7/29 10:00:07\n晚上也睡不好\n" "客户甲 7/29 10:00:11\n空腹是8.6\n" "客户甲 7/29 10:00:16\n饭后是12\n" "客户甲 7/29 10:00:20\n应该怎么办" ) merged = latest_customer_turn(chat) self.assertEqual( merged, "我这两天总口渴\n晚上也睡不好\n空腹是8.6\n饭后是12\n应该怎么办", ) self.assertNotIn("您想问哪方面", merged) prompt = _user_turn(chat)["content"] self.assertIn("客户本轮连续消息|统一回答对象", prompt) self.assertIn("我这两天总口渴\n晚上也睡不好", prompt) def test_old_raw_history_does_not_relabel_staff_reply_as_customer(self): with mock.patch("ai_chat.ai_config.AI_CONTEXT_ENABLED", True): messages = _history_messages([ {"role": "user", "content": self.CHAT_SAMPLE}, {"role": "assistant", "content": "四十来岁啦。"}, ]) self.assertEqual(messages[0]["content"], "你多大了") def test_age_question_mismatch_is_repaired(self): reply = _repair_obvious_mismatch( "您别管我多大,把身体养好才是正经事。", self.CHAT_SAMPLE, ) self.assertEqual(reply, "四十来岁啦,怎么突然问这个?") def test_casual_tired_message_is_not_redirected_to_hospital(self): reply = _repair_obvious_mismatch( "血糖波动会让人乏力,建议去医院让医生调整方案。", "一个小迷糊 7/28 14:55:43\n好困啊", ) self.assertEqual(reply, "困了就先眯一会儿,别硬撑着。") def test_low_information_customer_turn_never_guesses_phone_mistouch(self): chat = ( "一个小迷糊@微信@微信联系人 7/30 11:58:01\n!\n" "一个小迷糊@微信@微信联系人 7/30 11:58:08\n啊\n" "一个小迷糊@微信@微信联系人 7/30 11:58:15\n啊" ) reply = _repair_obvious_mismatch( "这是怎么啦,是不是不小心碰到手机了?", chat, ) self.assertEqual(reply, "我在呢,您慢慢说,怎么啦?") self.assertIn("【本轮信息很少】", _dify_query_from_chat(chat, history=[])) def test_dify_query_marks_latest_message_as_only_target(self): query = _dify_query_from_chat(self.CHAT_SAMPLE, history=[]) self.assertIn("【本轮是日常闲聊】", query) self.assertIn("【客户本轮连续消息|统一回答对象】\n你多大了", query) def test_clarification_does_not_ask_customer_to_repeat(self): reply = _repair_obvious_mismatch( "刚才没听清,您能再说一遍吗?", "测试客户 7/28 15:54:46\n什么", history=[ {"role": "user", "content": "好困啊"}, {"role": "assistant", "content": "困了就先眯一会儿,别硬撑着。"}, ], ) self.assertEqual(reply, "我是说,困了就先眯一会儿,别硬撑着。") class MediaReplyTest(TestCase): def test_media_markers_are_detected_but_unicode_emoji_stays_text(self): self.assertEqual(detect_media_types("[图片],这个处方天数改成15天吧"), {"image"}) self.assertEqual(detect_media_types("[动画表情]"), {"sticker"}) self.assertEqual(detect_media_types("[表情包]"), {"sticker"}) self.assertEqual(detect_media_types("[语音]"), {"voice"}) self.assertEqual(detect_media_types("[语音 5秒]"), {"voice"}) self.assertEqual(detect_media_types("[语音消息 00:05]"), {"voice"}) self.assertEqual(detect_media_types("今天挺开心的😊"), set()) self.assertEqual( media_text_content("客户甲 10:00:00\n[图片],这个处方天数改成15天吧"), "这个处方天数改成15天吧", ) def test_voice_duration_is_not_mistaken_for_transcription(self): self.assertEqual( media_text_content("客户甲 10:00:00\n[语音] 00:05"), "", ) self.assertEqual( media_text_content("客户甲 10:00:00\n[语音]\n00:05\n明天下午可以吗"), "明天下午可以吗", ) self.assertEqual( media_text_content("客户甲 10:00:00\n先说\n语音 5秒\n再说"), "先说\n再说", ) archived = media_archive_text( "客户甲 10:00:00\n[语音]\n00:05\n明天下午可以吗", media_types={"voice"}, ) self.assertIn("可见文字/转写", archived) self.assertNotIn("未取得转写", archived) def test_explicit_empty_media_set_does_not_rescan_old_history(self): full = ( "客户甲 09:00:00\n[语音]\n" "贴心管家 09:00:05\n麻烦打字说一下\n" "客户甲 10:00:00\n今天挺开心的😊" ) prompt = _vision_chat_prompt(full, media_types=set()) self.assertIn("【剪贴板检测到的媒体】剪贴板未识别具体类型", prompt) archived = media_archive_text(full, media_types=set()) self.assertEqual(archived, "今天挺开心的😊") self.assertNotIn("语音", archived) def test_mixed_vision_without_contains_voice_is_rejected(self): raw = ( '{"has_new_customer_message":true,"media_type":"mixed",' '"voice_transcribed":false,"reply":"我看到了"}' ) self.assertEqual( _finalize_vision_reply( raw, chat_text="客户甲 10:00:00\n帮我看看", media_types=set(), ), "", ) def test_vision_parser_ignores_analysis_json_before_protocol_result(self): raw = ( '{"analysis":"先确认最末端左侧气泡"}\n' '{"has_new_customer_message":true,"media_type":"image",' '"media_types":["image"],"contains_voice":false,' '"voice_transcribed":false,"reply":"图片收到了,您想重点看哪一处?"}' ) reply = _finalize_vision_reply(raw, chat_text="", media_types=set()) self.assertEqual(reply, "图片收到了,您想重点看哪一处?") self.assertEqual(set(reply.media_types), {"image"}) def test_vision_parser_uses_last_protocol_object(self): raw = ( '{"has_new_customer_message":false,"media_type":"unknown",' '"reply":""}\n' '{"has_new_customer_message":true,"media_type":"sticker",' '"media_types":["sticker"],"contains_voice":false,' '"voice_transcribed":false,"reply":"看到您发的表情啦。"}' ) reply = _finalize_vision_reply(raw, chat_text="", media_types=set()) self.assertEqual(reply, "看到您发的表情啦。") self.assertEqual(set(reply.media_types), {"sticker"}) def test_nested_diagnostic_object_cannot_override_protocol_result(self): raw = ( '{"has_new_customer_message":true,"media_type":"image",' '"media_types":["image"],"contains_voice":false,' '"voice_transcribed":false,"reply":"图片收到了。",' '"diagnostic":{"has_new_customer_message":false}}' ) reply = _finalize_vision_reply(raw, chat_text="", media_types=set()) self.assertEqual(reply, "图片收到了。") self.assertEqual(set(reply.media_types), {"image"}) def test_vision_protocol_conflicts_fail_closed(self): conflicting_types = ( '{"has_new_customer_message":true,"media_type":"image",' '"media_types":["sticker"],"contains_voice":false,' '"voice_transcribed":false,"reply":"我已经看懂了"}' ) self.assertEqual( _finalize_vision_reply( conflicting_types, chat_text="客户甲 10:00:00\n[图片]", media_types={"image"}, ), "", ) invalid_boolean = ( '{"has_new_customer_message":"maybe","media_type":"image",' '"media_types":["image"],"contains_voice":false,' '"voice_transcribed":false,"reply":"不应发送"}' ) self.assertEqual( _finalize_vision_reply(invalid_boolean, chat_text="", media_types=set()), "", ) impossible_transcript = ( '{"has_new_customer_message":true,"media_type":"text",' '"media_types":[],"contains_voice":false,' '"voice_transcribed":true,"reply":"不应发送"}' ) self.assertEqual( _finalize_vision_reply(impossible_transcript, chat_text="有文字", media_types=set()), "", ) def test_voice_safety_result_preserves_other_mixed_media_types(self): raw = ( '{"has_new_customer_message":true,"media_type":"mixed",' '"media_types":["voice","image"],"contains_voice":true,' '"voice_transcribed":false,"reply":"不应采用模型对语音的猜测"}' ) reply = _finalize_vision_reply( raw, chat_text="客户甲 10:00:00\n[语音] 00:05\n[图片]", media_types={"voice", "image"}, ) self.assertEqual(reply, VISION_VOICE_NEEDS_TEXT) self.assertEqual(set(reply.media_types), {"voice", "image"}) def test_voice_control_labels_are_not_treated_as_transcription(self): for marker in ( "语音消息 00:05", "音频:5秒", "audio 5s", "语音 5′", ): self.assertEqual(detect_media_types(marker), {"voice"}) self.assertEqual( media_text_content("客户甲 10:00:00\n[语音] 00:05\n转文字"), "", ) self.assertEqual( media_text_content("客户甲 10:00:00\n[语音]\n识别中…"), "", ) self.assertEqual( media_text_content("客户甲 10:00:00\n[语音]\n正在转文字…"), "", ) def test_repeated_identical_media_blocks_remain_a_nonempty_delta(self): previous = ["客户甲 10:00:00", "[图片]"] current = previous + ["客户甲 10:00:00", "[图片]"] self.assertEqual(WeChatBot._delta_lines(previous, current), previous) def test_animated_sticker_frames_keep_one_layout_signature(self): first = np.full((360, 480, 4), 245, dtype=np.uint8) first[:, :, 3] = 255 second = first.copy() # 透明画布里的动画主体水平移动;原始像素和可见包围盒都会变化。 yy, xx = np.ogrid[:360, :480] first[((yy - 135) ** 2 + (xx - 85) ** 2) < 45 ** 2, :3] = (40, 90, 220) second[((yy - 135) ** 2 + (xx - 105) ** 2) < 45 ** 2, :3] = (30, 200, 80) self.assertEqual( WeChatBot._chat_layout_signature(first), WeChatBot._chat_layout_signature(second), ) with_new_bubble = second.copy() with_new_bubble[270:315, 300:455, :3] = 180 self.assertNotEqual( WeChatBot._chat_layout_signature(second), WeChatBot._chat_layout_signature(with_new_bubble), ) def test_selected_row_preview_detects_same_layout_media_replacement(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot.session_item_h = 64 old = np.full((96, 230, 4), 245, dtype=np.uint8) old[:, :, 3] = 255 changed_preview = old.copy() # 第二行预览发生变化,代表新图片/表情到达;聊天区即使滚动后布局相同, # 这个独立信号也必须变化。 old[40:45, 82:116, :3] = 25 changed_preview[47:52, 126:168, :3] = 25 # 第一行右侧时间自行变化不能触发回复。 time_only = old.copy() time_only[40:55, 182:220, :3] = 25 self.assertNotEqual( bot._session_row_activity_signature(old, 32), bot._session_row_activity_signature(changed_preview, 32), ) self.assertEqual( bot._session_row_activity_signature(old, 32), bot._session_row_activity_signature(time_only, 32), ) def test_selected_row_preview_is_stable_when_row_moves(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot.session_item_h = 64 image = np.full((128, 230, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 image[40:45, 82:116, :3] = 25 image[72:77, 82:116, :3] = 25 self.assertEqual( bot._session_row_activity_signature(image, 32), bot._session_row_activity_signature(image, 64), ) def test_selected_row_detection_flicker_reuses_same_session_cache(self): bot = WeChatBot.__new__(WeChatBot) fp = b"f" * 40 bot.hwnd = 100 bot.scale = 1.0 bot.session_item_h = 64 bot._list_x = bot._list_y = 0 bot._list_w = 230 bot._list_h = 96 bot._chat_rel_x = 230 bot._chat_rel_y = 0 bot._chat_rel_w = 480 bot._chat_rel_h = 360 bot._active_session_fp = fp bot._row_activity_cache = {} session_list = np.full((96, 230, 4), 245, dtype=np.uint8) session_list[:, :, 3] = 255 session_list[40:45, 82:116, :3] = 25 chat = np.full((360, 480, 4), 245, dtype=np.uint8) chat[:, :, 3] = 255 chat[120:180, 40:120, :3] = 80 full = np.full((360, 710, 4), 245, dtype=np.uint8) full[:, :, 3] = 255 full[:96, :230] = session_list full[:, 230:710] = chat bot._capture_full_window = mock.Mock(return_value=full) bot.detect_selected_row = mock.Mock(side_effect=[32, -1]) bot._session_fingerprint = mock.Mock(return_value=fp) first = bot._chat_surface_signature() second = bot._chat_surface_signature() self.assertEqual(first, second) def test_openai_vision_receives_current_text_history_and_chat_screenshot(self): response = mock.Mock() response.raise_for_status = mock.Mock() response.json.return_value = { "choices": [{"message": {"content": ( '{"has_new_customer_message":true,"media_type":"image",' '"voice_transcribed":false,"reply":"我看到了,先帮您核对具体信息。"}' )}}] } history = [ {"role": "user", "content": "上次发过处方"}, {"role": "assistant", "content": "我看到了"}, ] with ( mock.patch("ai_chat.ai_config.AI_PROVIDER_TYPE", "openai"), mock.patch("ai_chat.ai_config.AI_API_BASE", "https://api.example/v1"), mock.patch("ai_chat.ai_config.AI_API_KEY", "secret"), mock.patch("ai_chat.ai_config.AI_MODEL", "vision-model"), mock.patch("ai_chat.ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.requests.post", return_value=response) as post, ): reply = call_ai_vision( b"png-data", history=history, chat_text="客户甲 10:00:00\n[图片],这个处方天数改成15天吧", media_types={"image"}, ) self.assertIn("先帮您核对", reply) payload = post.call_args.kwargs["json"] self.assertEqual(payload["messages"][1]["content"], "上次发过处方") prompt = payload["messages"][-1]["content"][0]["text"] self.assertIn("这个处方天数改成15天吧", prompt) self.assertIn("不得仅凭图片声称已经修改、提交", prompt) image_url = payload["messages"][-1]["content"][1]["image_url"]["url"] self.assertTrue(image_url.startswith("data:image/png;base64,")) def test_unknown_visual_result_cannot_guess_untranscribed_voice(self): voice_result = ( '{"has_new_customer_message":true,"media_type":"voice",' '"voice_transcribed":false,"reply":"我猜你在问血糖"}' ) self.assertEqual( _finalize_vision_reply(voice_result, chat_text="", media_types=set()), VISION_VOICE_NEEDS_TEXT, ) self.assertEqual( _finalize_vision_reply("我猜你在问血糖", chat_text="", media_types=set()), "", ) self.assertEqual( _finalize_vision_reply( '{"has_new_customer_message":false,"media_type":"unknown",' '"voice_transcribed":false,"reply":"不应发送"}', chat_text="", media_types=set(), ), VISION_NO_INCOMING, ) self.assertEqual( _finalize_vision_reply( '{"has_new_customer_message":true,"media_type":"mixed",' '"voice_transcribed":false,"reply":"按我猜的语音回复"}', chat_text="客户甲 10:00:00\n[语音]\n还有一张图", media_types={"voice", "image"}, ), VISION_VOICE_NEEDS_TEXT, ) def test_non_json_vision_text_is_never_treated_as_new_message_proof(self): self.assertEqual( _finalize_vision_reply( "我看到了,已经帮您改好。", chat_text="客户甲 10:00:00\n[图片],改成15天", media_types={"image"}, ), "", ) def test_dify_vision_query_contains_current_text_and_archive_history(self): history = [ {"role": "user", "content": "上次的问题"}, {"role": "assistant", "content": "上次的回复"}, ] with ( mock.patch("ai_chat.ai_config.AI_PROVIDER_TYPE", "dify"), mock.patch("ai_chat.ai_config.AI_API_BASE", "https://dify.example/v1"), mock.patch("ai_chat.ai_config.AI_CONTEXT_ENABLED", True), mock.patch( "ai_chat._call_dify_with_image", return_value=( '{"has_new_customer_message":true,"media_type":"image",' '"voice_transcribed":false,"reply":"看到了,您想让我重点核对哪一处?"}' ), ) as call, ): call_ai_vision( b"png-data", history=history, chat_text="客户甲 10:00:00\n[图片],请看这里", media_types={"image"}, ) query = call.call_args.args[0] self.assertIn("上次的问题", query) self.assertIn("请看这里", query) self.assertIn("媒体识别规则", query) self.assertEqual(call.call_args.args[1], b"png-data") def test_image_and_text_batch_automatically_uses_mixed_vision(self): bot = WeChatBot.__new__(WeChatBot) fp = b"media001" batch = "客户甲 10:00:00\n[图片],这个处方天数改成15天吧" bot.get_session_history = mock.Mock(return_value=[]) bot.extract_context_for = mock.Mock(return_value=batch) bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.get_ai_reply", return_value="我看到了,先核对后再告诉您。") as get_reply, mock.patch("ai_chat.call_ai_text") as call_text, mock.patch( "registration_store.process_registration_reply", return_value=("我看到了,先核对后再告诉您。", None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp) self.assertEqual(reply, "我看到了,先核对后再告诉您。") bot.capture_chat_area.assert_called_once() call_text.assert_not_called() kwargs = get_reply.call_args.kwargs self.assertEqual(kwargs["chat_text"], batch) self.assertEqual(kwargs["image_bytes"], b"chat-png") self.assertTrue(kwargs["force_vision"]) self.assertEqual(kwargs["media_types"], {"image"}) self.assertEqual( bot._stage_exchange.call_args.args[1], "(客户发来图片)\n这个处方天数改成15天吧", ) def test_explicit_image_uses_safe_reply_when_chat_capture_fails(self): bot = WeChatBot.__new__(WeChatBot) fp = b"media003" batch = "客户甲 10:00:00\n[图片]" expected = safe_media_reply({"image"}) bot.store = mock.Mock() bot.store.has_record.return_value = True bot._pending_reply_sessions = {fp.hex(): {"batch_ready": True}} bot.extract_context_for = mock.Mock(return_value=batch) bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface") bot.capture_chat_area = mock.Mock(side_effect=RuntimeError("capture failed")) bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply") as get_reply, mock.patch( "registration_store.process_registration_reply", return_value=(expected, None), ), mock.patch("wechat_bot.time.sleep"), ): self.assertEqual(bot._generate_ai_reply(fp), expected) get_reply.assert_not_called() def test_pure_voice_never_asks_a_model_to_guess_audio(self): bot = WeChatBot.__new__(WeChatBot) fp = b"voice001" bot.get_session_history = mock.Mock(return_value=[]) bot.extract_context_for = mock.Mock(return_value="客户甲 10:00:00\n[语音]") bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() expected = safe_media_reply({"voice"}) with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.get_ai_reply") as get_reply, mock.patch("ai_chat.call_ai_text") as call_text, mock.patch( "registration_store.process_registration_reply", return_value=(expected, None), ), mock.patch("wechat_bot.time.sleep"), ): self.assertEqual(bot._generate_ai_reply(fp), expected) bot.capture_chat_area.assert_not_called() get_reply.assert_not_called() call_text.assert_not_called() self.assertEqual( bot._stage_exchange.call_args.args[1], "(客户发来语音,未取得转写)", ) def test_voice_plus_visible_text_answers_text_without_guessing_audio(self): bot = WeChatBot.__new__(WeChatBot) fp = b"voice002" batch = "客户甲 10:00:00\n[语音] 00:05\n明天下午能挂号吗" bot.store = mock.Mock() bot.store.has_record.return_value = True bot.extract_context_for = mock.Mock(return_value=batch) bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=VISION_VOICE_NEEDS_TEXT), mock.patch("ai_chat.call_ai_text", return_value="明天下午可以,我先帮您登记。") as call_text, mock.patch( "registration_store.process_registration_reply", return_value=("明天下午可以,我先帮您登记。", None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp) self.assertEqual(reply, "明天下午可以,我先帮您登记。") self.assertIn("不得猜测语音内容", call_text.call_args.args[0]) self.assertIn("明天下午能挂号吗", call_text.call_args.args[0]) def test_confirmed_unread_caption_without_placeholder_still_uses_vision(self): bot = WeChatBot.__new__(WeChatBot) fp = b"media004" batch = "客户甲 10:00:00\n帮我看看这个" bot.store = mock.Mock() bot.store.has_record.return_value = True bot.extract_context_for = mock.Mock(return_value=batch) bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value="我看到了,您想重点确认哪一处?") as get_reply, mock.patch("ai_chat.call_ai_text") as call_text, mock.patch( "registration_store.process_registration_reply", return_value=("我看到了,您想重点确认哪一处?", None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp, confirmed_unread=True) self.assertEqual(reply, "我看到了,您想重点确认哪一处?") bot.capture_chat_area.assert_called_once() get_reply.assert_called_once() call_text.assert_not_called() def test_visual_no_incoming_conflict_never_sends_caption_without_media_warning(self): bot = WeChatBot.__new__(WeChatBot) fp = b"media007" batch = "客户甲 10:00:00\n帮我看看这个" bot.store = mock.Mock() bot.store.has_record.return_value = True bot.extract_context_for = mock.Mock(return_value=batch) bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING), mock.patch("ai_chat.call_ai_text", return_value="您想让我重点看哪一处?") as call_text, mock.patch( "registration_store.process_registration_reply", return_value=("您想让我重点看哪一处?", None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp, confirmed_unread=True) self.assertEqual(reply, "您想让我重点看哪一处?") self.assertIn("不得猜测媒体内容", call_text.call_args.args[0]) def test_second_visual_no_incoming_keeps_confirmed_unread_and_uses_safe_media_reply(self): bot = WeChatBot.__new__(WeChatBot) fp = b"flat0001" bot.store = mock.Mock() bot.store.has_record.return_value = True bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "confirmed_unread": True, "requires_visual_proof": True, "visual_rejection_count": 1, } } bot._pending_reply_path = "" bot._pending_exchanges = {} bot._flat_visual_proof_fps = {fp.hex()} bot._flat_rejected_session_fps = set() bot.extract_context_for = mock.Mock(return_value="") bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface") bot._ensure_session_archive_key = mock.Mock() bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() safe_question = "收到消息,请把重点打字说一下。" with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING), mock.patch("ai_chat.call_ai_text") as call_text, mock.patch( "ai_chat.safe_media_reply", return_value=safe_question, ) as safe_reply, mock.patch( "registration_store.process_registration_reply", return_value=(safe_question, None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp, confirmed_unread=True) self.assertEqual(reply, safe_question) state = bot._pending_reply_sessions[fp.hex()] self.assertTrue(state["confirmed_unread"]) self.assertEqual(state["visual_rejection_count"], 1) self.assertFalse(state["requires_visual_proof"]) self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps) self.assertIn(fp.hex(), bot._flat_verified_session_fps) call_text.assert_not_called() safe_reply.assert_called_once_with() def test_second_visual_no_incoming_keeps_reliable_text_pending_and_uses_safe_text(self): bot = WeChatBot.__new__(WeChatBot) fp = b"flat0004" bot.store = mock.Mock() bot.store.has_record.return_value = True bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "confirmed_unread": False, "requires_visual_proof": True, "visual_rejection_count": 1, } } bot._pending_reply_path = "" bot._pending_exchanges = {} bot._flat_visual_proof_fps = {fp.hex()} bot._flat_rejected_session_fps = set() bot.extract_context_for = mock.Mock( return_value="客户甲 10:00:00\n帮我看看这个" ) bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface") bot._ensure_session_archive_key = mock.Mock() bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() safe_text_reply = "您想让我重点看哪一处?" with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING), mock.patch( "ai_chat.call_ai_text", return_value=safe_text_reply, ) as call_text, mock.patch("ai_chat.safe_media_reply") as safe_reply, mock.patch( "registration_store.process_registration_reply", return_value=(safe_text_reply, None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply( fp, force_media_check=True, confirmed_unread=False, reliable_text_pending=True, ) self.assertEqual(reply, safe_text_reply) state = bot._pending_reply_sessions[fp.hex()] self.assertEqual(state["visual_rejection_count"], 1) self.assertFalse(state["requires_visual_proof"]) self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps) self.assertIn(fp.hex(), bot._flat_verified_session_fps) self.assertIn("只回答本轮可见文字", call_text.call_args.args[0]) safe_reply.assert_not_called() def test_second_visual_no_incoming_clears_unconfirmed_unreliable_candidate(self): bot = WeChatBot.__new__(WeChatBot) fp = b"flat0005" bot.store = mock.Mock() bot.store.has_record.return_value = True bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "confirmed_unread": False, "requires_visual_proof": True, "visual_rejection_count": 1, } } bot._pending_reply_path = "" bot._pending_exchanges = {} bot._flat_visual_proof_fps = {fp.hex()} bot._flat_rejected_session_fps = set() bot.extract_context_for = mock.Mock(return_value="") bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface") bot._ensure_session_archive_key = mock.Mock() bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING), mock.patch("ai_chat.call_ai_text") as call_text, mock.patch("ai_chat.safe_media_reply") as safe_reply, mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply( fp, confirmed_unread=False, reliable_text_pending=False, ) self.assertIsNone(reply) self.assertNotIn(fp.hex(), bot._pending_reply_sessions) self.assertIn(fp.hex(), bot._flat_rejected_session_fps) self.assertNotIn(fp.hex(), bot._flat_visual_proof_fps) call_text.assert_not_called() safe_reply.assert_not_called() bot._stage_exchange.assert_not_called() def test_unknown_flat_avatar_vision_failure_never_uses_generic_fallback(self): bot = WeChatBot.__new__(WeChatBot) fp = b"flat0002" bot.store = mock.Mock() bot.store.has_record.return_value = True bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "confirmed_unread": True, "requires_visual_proof": True, } } bot._pending_reply_path = "" bot._pending_exchanges = {} bot.extract_context_for = mock.Mock(return_value="") bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface") bot._ensure_session_archive_key = mock.Mock() bot.capture_chat_area = mock.Mock(return_value=b"chat-png") with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=""), mock.patch("ai_chat.call_ai_text") as call_text, mock.patch("ai_chat.safe_media_reply") as safe_reply, mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp, confirmed_unread=True) self.assertIsNone(reply) self.assertIn(fp.hex(), bot._pending_reply_sessions) call_text.assert_not_called() safe_reply.assert_not_called() def test_explicit_customer_block_proves_flat_avatar_is_real_chat(self): bot = WeChatBot.__new__(WeChatBot) fp = b"flat0003" bot.store = mock.Mock() bot.store.has_record.return_value = True bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "confirmed_unread": True, "requires_visual_proof": True, } } bot._pending_reply_path = "" bot._pending_exchanges = {} bot._flat_visual_proof_fps = {fp.hex()} bot._flat_verified_session_fps = set() bot._flat_rejected_session_fps = set() bot.extract_context_for = mock.Mock( return_value="客户甲 7/29 10:00:00\n请问今天下午能挂号吗" ) bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface") bot._ensure_session_archive_key = mock.Mock() bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING), mock.patch("ai_chat.call_ai_text", return_value="可以,我先帮您登记。"), mock.patch( "registration_store.process_registration_reply", return_value=("可以,我先帮您登记。", None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp, confirmed_unread=True) self.assertEqual(reply, "可以,我先帮您登记。") self.assertIn(fp.hex(), bot._flat_verified_session_fps) self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps) self.assertFalse( bot._pending_reply_sessions[fp.hex()]["requires_visual_proof"] ) def test_selected_caption_falls_back_to_safe_text_when_vision_fails(self): bot = WeChatBot.__new__(WeChatBot) fp = b"media006" batch = "客户甲 10:00:00\n帮我看看这个" bot.store = mock.Mock() bot.store.has_record.return_value = True bot.extract_context_for = mock.Mock(return_value=batch) bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=""), mock.patch("ai_chat.call_ai_text", return_value="您想让我重点看哪一处?") as call_text, mock.patch( "registration_store.process_registration_reply", return_value=("您想让我重点看哪一处?", None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply( fp, force_media_check=True, reliable_text_pending=True, ) self.assertEqual(reply, "您想让我重点看哪一处?") self.assertIn("不得推测媒体内容", call_text.call_args.args[0]) def test_screenshot_only_image_type_is_preserved_in_archive(self): bot = WeChatBot.__new__(WeChatBot) fp = b"media005" bot.store = mock.Mock() bot.store.has_record.return_value = False bot.get_session_history = mock.Mock(return_value=[]) bot.extract_context_for = mock.Mock(return_value="") bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() visual_reply = _finalize_vision_reply( '{"has_new_customer_message":true,"media_type":"image",' '"media_types":["image"],"contains_voice":false,' '"voice_transcribed":false,"reply":"图片我看到了,您想重点确认哪一处?"}', chat_text="", media_types=set(), ) with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.get_ai_reply", return_value=visual_reply), mock.patch( "registration_store.process_registration_reply", return_value=(str(visual_reply), None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp, confirmed_unread=True) self.assertEqual(reply, "图片我看到了,您想重点确认哪一处?") self.assertEqual(bot._stage_exchange.call_args.args[1], "(客户发来图片)") def test_screenshot_only_voice_type_is_preserved_for_safe_archive(self): bot = WeChatBot.__new__(WeChatBot) fp = b"voice003" bot.store = mock.Mock() bot.store.has_record.return_value = False bot.get_session_history = mock.Mock(return_value=[]) bot.extract_context_for = mock.Mock(return_value="") bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() visual_reply = _finalize_vision_reply( '{"has_new_customer_message":true,"media_type":"voice",' '"media_types":["voice"],"contains_voice":true,' '"voice_transcribed":false,"reply":""}', chat_text="", media_types=set(), ) with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.get_ai_reply", return_value=visual_reply), mock.patch( "registration_store.process_registration_reply", side_effect=lambda **kwargs: (kwargs["reply_text"], None), ), mock.patch("wechat_bot.time.sleep"), ): reply = bot._generate_ai_reply(fp, confirmed_unread=True) self.assertEqual(reply, safe_media_reply({"voice"})) self.assertEqual( bot._stage_exchange.call_args.args[1], "(客户发来语音,未取得转写)", ) def test_unchanged_clipboard_text_is_not_replied_to_again(self): bot = WeChatBot.__new__(WeChatBot) bot.store = mock.Mock() bot.store.has_record.return_value = True bot.store.last_lines.return_value = ["客户甲 10:00:00", "上一轮问题"] same = "客户甲 10:00:00\n上一轮问题" self.assertEqual(bot.extract_context_for(b"same0001", pre_text=same), "") bot.store.set_last_lines.assert_called_once() def test_empty_clipboard_surface_change_enters_media_fallback(self): bot = WeChatBot.__new__(WeChatBot) fp = b"media002" bot._capture_full_window = mock.Mock(return_value=np.zeros((20, 20, 4), dtype=np.uint8)) bot._message_nav_selected = mock.Mock(return_value=True) bot.detect_selected_row = mock.Mock(return_value=-1) bot._chat_identity_signature = mock.Mock(return_value=b"identity") bot._chat_surface_signature = mock.Mock(return_value=b"new-surface") bot._selected_tracking_initialized = True bot._active_session_fp = fp bot._active_identity_signature = b"identity" bot._active_chat_signature = b"old-surface" bot._pending_reply_sessions = {} bot._activate_wx = mock.Mock(return_value=True) bot.extract_chat_text = mock.Mock(side_effect=["", ""]) bot._wait_for_message_batch = mock.Mock(return_value=True) bot._send_gate_open = mock.Mock(return_value=True) bot._generate_ai_reply = mock.Mock(return_value="请把重点打字说一下。") bot.send_reply = mock.Mock(return_value=True) bot._run_ai_page_guard = mock.Mock(return_value=False) with mock.patch("wechat_bot.time.sleep"): self.assertTrue(bot._check_selected_session(np.zeros((20, 20, 4), dtype=np.uint8))) bot._run_ai_page_guard.assert_not_called() self.assertTrue(bot._generate_ai_reply.call_args.kwargs["force_media_check"]) def test_visual_failure_does_not_reply_to_stale_clipboard_text(self): bot = WeChatBot.__new__(WeChatBot) fp = b"stale001" bot.get_session_history = mock.Mock(return_value=[]) bot.extract_context_for = mock.Mock(return_value="") bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.get_ai_reply", return_value=""), mock.patch("ai_chat.call_ai_text") as call_text, mock.patch("ai_chat.safe_media_reply") as safe_reply, mock.patch("wechat_bot.time.sleep"), ): result = bot._generate_ai_reply( fp, chat_text="贴心管家 10:00:00\n上一轮已经发出的回复", force_media_check=True, ) self.assertIsNone(result) call_text.assert_not_called() safe_reply.assert_not_called() def test_unconfirmed_empty_clipboard_never_sends_generic_media_reply(self): bot = WeChatBot.__new__(WeChatBot) bot.extract_context_for = mock.Mock(return_value="") bot.capture_chat_area = mock.Mock(return_value=b"chat-png") with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=""), mock.patch("ai_chat.safe_media_reply") as safe_reply, mock.patch("wechat_bot.time.sleep"), ): result = bot._generate_ai_reply( b"empty001", chat_text="", force_media_check=True, confirmed_unread=False, ) self.assertIsNone(result) safe_reply.assert_not_called() def test_confirmed_unread_empty_clipboard_uses_type_agnostic_safe_reply(self): bot = WeChatBot.__new__(WeChatBot) bot.store = mock.Mock() bot.store.has_record.return_value = True bot.extract_context_for = mock.Mock(return_value="") bot.capture_chat_area = mock.Mock(return_value=b"chat-png") with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_chat.get_ai_reply", return_value=""), mock.patch( "ai_chat.safe_media_reply", return_value="收到消息,请把重点打字说一下。", ) as safe_reply, mock.patch("wechat_bot.time.sleep"), ): result = bot._generate_ai_reply( b"empty002", chat_text="", force_media_check=True, confirmed_unread=True, ) self.assertEqual(result, "收到消息,请把重点打字说一下") safe_reply.assert_called_once_with() def test_first_record_visual_failure_never_answers_visible_old_text(self): bot = WeChatBot.__new__(WeChatBot) fp = b"first001" visible_old_text = "贴心管家 09:59:00\n上一轮已经发出的回复" bot.store = mock.Mock() bot.store.has_record.return_value = False bot.get_session_history = mock.Mock(return_value=[]) bot.extract_context_for = mock.Mock(return_value=visible_old_text) bot.capture_chat_area = mock.Mock(return_value=b"chat-png") with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.get_ai_reply", return_value="") as get_reply, mock.patch("ai_chat.call_ai_text") as call_text, mock.patch( "ai_chat.safe_media_reply", return_value="收到消息,请把重点再发一句。", ) as safe_reply, mock.patch("wechat_bot.time.sleep"), ): result = bot._generate_ai_reply( fp, chat_text=visible_old_text, confirmed_unread=True, ) self.assertEqual(result, "收到消息,请把重点再发一句") get_reply.assert_called_once() call_text.assert_not_called() safe_reply.assert_called_once_with() def test_first_record_explicit_customer_text_is_still_visually_confirmed(self): bot = WeChatBot.__new__(WeChatBot) fp = b"first002" customer_text = "客户甲 7/29 10:00:00\n请问今天下午能挂号吗" bot.store = mock.Mock() bot.store.has_record.return_value = False bot.extract_context_for = mock.Mock(return_value=customer_text) bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", False), mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"), mock.patch("ai_chat.call_ai_text") as call_text, mock.patch( "ai_chat.get_ai_reply", return_value="可以的,我先帮您登记需求。", ) as get_reply, mock.patch( "registration_store.process_registration_reply", return_value=("可以的,我先帮您登记需求。", None), ), mock.patch("wechat_bot.time.sleep"), ): result = bot._generate_ai_reply( fp, confirmed_unread=True, ) self.assertEqual(result, "可以的,我先帮您登记需求。") call_text.assert_not_called() get_reply.assert_called_once() bot.capture_chat_area.assert_called_once() def test_message_arriving_during_model_call_cancels_stale_reply(self): bot = WeChatBot.__new__(WeChatBot) fp = b"race0001" batch = "客户甲 10:00:00\n[图片],请看一下" bot.store = mock.Mock() bot.store.has_record.return_value = True bot._pending_reply_sessions = {fp.hex(): {"batch_ready": True}} bot.extract_context_for = mock.Mock(return_value=batch) bot.get_session_history = mock.Mock(return_value=[]) bot.capture_chat_area = mock.Mock(return_value=b"chat-png") bot._chat_surface_signature = mock.Mock( side_effect=[ b"before-extract", b"before-extract", b"after-new-message", ] ) bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.get_ai_reply", return_value="这是一条已经过时的回复"), mock.patch("wechat_bot.time.sleep"), ): result = bot._generate_ai_reply(fp) self.assertIsNone(result) bot._stage_exchange.assert_not_called() self.assertFalse(bot._pending_reply_state(fp)["batch_ready"]) def test_message_arriving_during_final_extraction_skips_model(self): bot = WeChatBot.__new__(WeChatBot) fp = b"race0002" bot.store = mock.Mock() bot.store.has_record.return_value = True bot._pending_reply_sessions = {fp.hex(): {"batch_ready": True}} bot.extract_context_for = mock.Mock( return_value="客户甲 10:00:00\n尚未包含后到的新消息" ) bot._chat_surface_signature = mock.Mock( side_effect=[b"before-extract", b"after-new-message"] ) with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.call_ai_text") as call_text, mock.patch("ai_chat.get_ai_reply") as vision, mock.patch("wechat_bot.time.sleep"), ): self.assertIsNone(bot._generate_ai_reply(fp)) call_text.assert_not_called() vision.assert_not_called() self.assertFalse(bot._pending_reply_state(fp)["batch_ready"]) def test_pending_text_is_restored_after_model_or_send_failure(self): bot = WeChatBot.__new__(WeChatBot) fp = b"retry001" cached = "客户甲 10:00:00\n上一轮尚未成功发送的问题" bot._pending_reply_sessions = { fp.hex(): {"batch_ready": True, "chat_text": cached} } bot.extract_context_for = mock.Mock(return_value="") bot.get_session_history = mock.Mock(return_value=[]) bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface") bot._stage_exchange = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.call_ai_text", return_value="这次继续回复。") as call_text, mock.patch( "registration_store.process_registration_reply", return_value=("这次继续回复。", None), ), mock.patch("wechat_bot.time.sleep"), ): self.assertEqual(bot._generate_ai_reply(fp), "这次继续回复。") call_text.assert_called_once_with(cached, history=[]) def test_snapshot_is_committed_only_after_successful_send(self): bot = WeChatBot.__new__(WeChatBot) fp = b"snap0001" bot._pending_reply_sessions = {} bot._pending_exchanges = {} bot.store = mock.Mock() bot.store.has_record.return_value = True bot.store.last_lines.return_value = ["客户甲 09:59:00", "旧消息"] current = "客户甲 09:59:00\n旧消息\n客户甲 10:00:00\n新消息" delta = bot.extract_context_for( fp, pre_text=current, defer_snapshot=True, ) self.assertIn("新消息", delta) bot.store.set_last_lines.assert_not_called() self.assertEqual( bot._pending_reply_state(fp)["last_lines"][-1], "新消息", ) bot._commit_staged_exchange(fp.hex()) bot.store.set_last_lines.assert_called_once() bot.store.save.assert_called_once() def test_pending_unread_task_survives_process_restart(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "pending_replies.json") fp = b"p" * 40 bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_path = path bot._pending_reply_sessions = {} bot._active_identity_signature = b"stable-title" bot._mark_reply_pending(fp, batch_ready=True, confirmed_unread=True) state = bot._pending_reply_state(fp) state["chat_text"] = "客户甲 10:00:00\n崩溃前尚未回复" state["last_lines"] = ["客户甲 10:00:00", "崩溃前尚未回复"] state["send_state"] = "sending" state["ctrl_enter_attempted"] = True state["uncertain_since"] = 123.5 state["reply_text"] = "准备发送的回复" state["staged_user_text"] = "客户甲的问题" state["staged_reply_text"] = "准备发送的回复" state["send_surface_signature"] = b"before-enter" state["send_baseline_blocks"] = [ ["客户甲 10:00:00", "崩溃前尚未回复"] ] state["send_reply_was_visible"] = False state["send_known_outgoing_speakers"] = ["高兴亮"] bot._persist_pending_replies() restored_bot = WeChatBot.__new__(WeChatBot) restored_bot._pending_reply_path = path restored = restored_bot._load_pending_replies() self.assertIn(fp.hex(), restored) self.assertTrue(restored[fp.hex()]["confirmed_unread"]) self.assertIn("尚未回复", restored[fp.hex()]["chat_text"]) self.assertEqual(restored[fp.hex()]["send_state"], "sending") self.assertTrue(restored[fp.hex()]["ctrl_enter_attempted"]) self.assertEqual(restored[fp.hex()]["uncertain_since"], 123.5) self.assertEqual(restored[fp.hex()]["reply_text"], "准备发送的回复") self.assertEqual( restored[fp.hex()]["send_surface_signature"], b"before-enter", ) self.assertEqual( restored[fp.hex()]["send_baseline_blocks"], [["客户甲 10:00:00", "崩溃前尚未回复"]], ) self.assertEqual( restored[fp.hex()]["send_known_outgoing_speakers"], ["高兴亮"], ) with open(path, encoding="utf-8") as handle: self.assertIn(fp.hex(), json.load(handle)) def test_unfinished_merge_deadline_survives_process_restart(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "pending_replies.json") fp = b"d" * 40 bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_path = path bot._pending_reply_sessions = { fp.hex(): { "batch_ready": False, "confirmed_unread": True, "batch_started_at": 100.0, "batch_deadline_at": 120.0, "batch_window_seconds": 20.0, "updated_at": 101.0, }, } self.assertTrue(bot._persist_pending_replies()) restored_bot = WeChatBot.__new__(WeChatBot) restored_bot._pending_reply_path = path restored = restored_bot._load_pending_replies()[fp.hex()] self.assertFalse(restored["batch_ready"]) self.assertEqual(restored["batch_started_at"], 100.0) self.assertEqual(restored["batch_deadline_at"], 120.0) self.assertEqual(restored["batch_window_seconds"], 20.0) def test_legacy_inflight_state_does_not_invent_an_empty_send_anchor(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "pending_replies.json") fp = b"l" * 40 with open(path, "w", encoding="utf-8") as handle: json.dump( { fp.hex(): { "confirmed_unread": True, "send_state": "sent_uncommitted", "reply_text": "历史回复", "send_reply_match_count": 1, "updated_at": time.time(), } }, handle, ensure_ascii=False, ) bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_path = path restored = bot._load_pending_replies()[fp.hex()] self.assertNotIn("send_baseline_blocks", restored) self.assertNotIn("send_reply_was_visible", restored) def test_previous_16_byte_pending_key_is_loaded_instead_of_dropped(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "pending_replies.json") old_fp = b"a" * 8 + b"n" * 8 with open(path, "w", encoding="utf-8") as handle: json.dump( { old_fp.hex(): { "batch_ready": True, "confirmed_unread": True, "chat_text": "客户甲 10:00:00\n旧版未回复内容", "identity_signature": b"stable-title".hex(), } }, handle, ) bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_path = path restored = bot._load_pending_replies() self.assertIn(old_fp.hex(), restored) self.assertEqual( restored[old_fp.hex()]["identity_signature"], b"stable-title", ) class SessionViewerSnapshotTest(TestCase): """客户会话记录里那张画面的取路径规则。渲染本身交给 Qt,这里只管定位。""" @classmethod def setUpClass(cls): try: from wechat_gui_qt import SessionDetailDialog except Exception as exc: # PySide6 缺失/无显示环境时跳过,别拖垮整个套件 raise SkipTest(f"PySide6 不可用: {exc}") cls.dialog = SessionDetailDialog def test_a_stored_snapshot_resolves_under_the_media_dir(self): import wechat_gui_qt with tempfile.TemporaryDirectory() as directory: media = os.path.join(directory, "media") os.makedirs(media) with open(os.path.join(media, "shot.png"), "wb") as handle: handle.write(b"PNG") with mock.patch.object(wechat_gui_qt, "SCRIPT_DIR", directory): self.assertEqual( self.dialog._snapshot_path({"image": "shot.png"}), os.path.join(media, "shot.png"), ) def test_a_message_without_a_snapshot_resolves_to_nothing(self): self.assertEqual(self.dialog._snapshot_path({"content": "在不在"}), "") def test_a_missing_file_is_not_offered_to_the_viewer(self): import wechat_gui_qt with tempfile.TemporaryDirectory() as directory: with mock.patch.object(wechat_gui_qt, "SCRIPT_DIR", directory): self.assertEqual( self.dialog._snapshot_path({"image": "没了.png"}), "" ) def test_a_path_in_the_archive_never_escapes_the_media_dir(self): """档案是磁盘上的文件,可能被改。只认纯文件名,不认任何路径。""" for evil in ("../../secret.png", "sub/shot.png", "C:\\Windows\\a.png"): self.assertEqual(self.dialog._snapshot_path({"image": evil}), "") class TransactionStoreTest(TestCase): def test_exchange_id_is_committed_only_once_after_restart(self): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "conversations.json") store = ConversationStore(path) self.assertTrue( store.append_exchange_once("session", "客户消息", "客服回复", "tx-1") ) self.assertFalse( store.append_exchange_once("session", "客户消息", "客服回复", "tx-1") ) restored = ConversationStore(path) self.assertEqual(len(restored.history("session")), 2) def test_visually_proven_outgoing_speaker_survives_restart(self): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "conversations.json") fp = b"k" * 40 store = ConversationStore(path) self.assertTrue(store.add_outgoing_speaker(fp.hex(), "高兴亮")) self.assertFalse(store.add_outgoing_speaker(fp.hex(), "高兴亮")) restored = ConversationStore(path) self.assertEqual(restored.outgoing_speakers(fp.hex()), ["高兴亮"]) bot = WeChatBot.__new__(WeChatBot) bot.store = restored bot._active_session_fp = fp with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertIn("高兴亮", bot._known_outgoing_speakers()) def test_registration_prepare_mode_does_not_write_before_send(self): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "registration.json") store = RegistrationStore(path) reply, lead = process_registration_reply( session_id="customer-a", user_text="我想挂号,最近总是口渴", reply_text="好的,我先帮您预约。", store=store, persist=False, ) self.assertIn("甄养堂互联网医院", reply) self.assertIsNotNone(lead) self.assertEqual(store.list_leads(), []) store.add_or_update(**lead) self.assertEqual(len(store.list_leads()), 1) class SafetyGateTest(TestCase): @staticmethod def _verification_surface(with_nav=False): """登录页样张:正中一个真二维码。 原来这里放的是棋盘格。棋盘格能满足"够黑、够亮、跳变够多",但它不是 二维码——密集小字同样满足,于是一条长广告消息就能让机器人停机。判据改成 真的去找定位图案后,样张也必须是真码。 """ import cv2 image = np.full((1000, 1600, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 if with_nav: image[80:920, :96, :3] = 45 code = cv2.QRCodeEncoder_create().encode( "https://work.weixin.qq.com/login/verify/abc123" ) code = cv2.resize(code, (240, 240), interpolation=cv2.INTER_NEAREST) image[380:620, 680:920, :3] = code[:, :, None] return image @staticmethod def _modal_surface(with_close=True): image = np.full((800, 1200, 4), 235, dtype=np.uint8) image[:, :, 3] = 255 image[130:700, 280:920, :3] = 250 image[130:700, 919:922, :3] = 190 image[697:701, 280:922, :3] = 190 if with_close: for offset in range(-7, 8): image[175 + offset, 870 + offset, :3] = 70 image[175 + offset, 870 - offset, :3] = 70 return image @staticmethod def _nav_surface(message_selected: bool): image = np.full((320, 500, 4), 242, dtype=np.uint8) image[:, :, 3] = 255 if message_selected: # BGR 蓝色块模拟消息行的选中背景。 image[68:102, 4:58, :3] = (238, 126, 36) else: # 其他入口被选中;消息位置只有灰色图标。 image[68:102, 4:58, :3] = (145, 145, 145) image[210:255, 22:52, :3] = (238, 126, 36) image[240:242, :, :3] = 230 image[242:, :, :3] = 255 return image @staticmethod def _mail_nav_surface(): image = SafetyGateTest._nav_surface(False) # 邮件紧邻消息;旧检测框跨到这一行后会把邮件误判为消息。 image[110:142, 4:58, :3] = (238, 126, 36) return image def test_qr_page_without_main_navigation_is_blocked(self): self.assertTrue( looks_like_security_verification(self._verification_surface()) ) def test_normal_navigation_prevents_false_positive(self): """主界面还在时不停机。这道判断已从像素判据搬到 _security_gate_visible: 浅色主题下左侧导航本来就是浅色,"导航够深"根本挡不住任何东西。""" bot = WeChatBot.__new__(WeChatBot) bot.security_verification_required = False bot._security_log_emitted = False bot.hwnd = 1 bot._message_nav_selected = mock.Mock(return_value=True) surface = self._verification_surface(with_nav=True) with ( mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 1600, 1000)), mock.patch("wechat_bot.capture_window_region", return_value=surface), ): self.assertFalse(bot._security_gate_visible()) def test_center_modal_close_button_is_detected(self): candidate = find_blocking_modal_close(self._modal_surface()) self.assertIsNotNone(candidate) self.assertLess(abs(candidate[0] - 870), 5) self.assertLess(abs(candidate[1] - 175), 5) def test_plain_surface_is_not_treated_as_modal(self): self.assertIsNone(find_blocking_modal_close(self._modal_surface(False))) @staticmethod def _chat_bubble_surface(): """聊天气泡里出现形似 × 的字形,且下方叠着一列气泡左边缘。 真实故障画面就是这个形状:客户消息「你怎么不回复啊」的“你”被当成 弹窗关闭按钮,而下方连续气泡的左边缘凑出了“竖直面板边缘”。 """ image = np.full((900, 1600, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 # 最上面一条客户消息气泡,× 字形落在气泡内部的留白里。 image[96:142, 700:1000, :3] = 255 for offset in range(-7, 8): image[119 + offset, 755 + offset, :3] = 70 image[119 + offset, 755 - offset, :3] = 70 # 下方连续气泡:左边缘固定在 x=780,被气泡间隙反复打断。 for top in range(160, 560, 68): image[top:top + 46, 780:1000, :3] = 255 return image def test_chat_bubble_glyph_is_not_treated_as_modal_close(self): self.assertIsNone(find_blocking_modal_close(self._chat_bubble_surface())) def test_dimmed_background_is_recognised_as_modal_scrim(self): image = np.full((900, 1600, 4), 120, dtype=np.uint8) image[:, :, 3] = 255 image[200:700, 500:1100, :3] = 250 self.assertTrue(looks_like_modal_scrim(image)) def test_normal_bright_page_is_not_a_modal_scrim(self): self.assertFalse(looks_like_modal_scrim(self._chat_bubble_surface())) def test_unchanged_surface_after_escape_is_treated_as_false_positive(self): """Esc 没有改变任何画面时,绝不能把聊天内容当弹窗冻结整轮轮询。""" bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot.L = 20 bot.T = 30 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._capture_full_window = mock.Mock(return_value=self._modal_surface()) bot._message_nav_selected = mock.Mock(return_value=True) with ( mock.patch("wechat_bot.safe_set_foreground"), mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=bot.hwnd), mock.patch( "wechat_bot.find_blocking_modal_close", side_effect=[(870, 175), None, (870, 175)], ), mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.pyautogui.click") as click, mock.patch("wechat_bot.time.sleep"), ): self.assertFalse(bot._dismiss_internal_blocker("测试")) # 已被证伪的画面不再重复按 Esc,也不再冻结后续轮询。 self.assertFalse(bot._dismiss_internal_blocker("测试")) press.assert_called_once_with("esc") click.assert_not_called() def test_message_workspace_uses_selected_navigation_state(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 self.assertTrue(bot._message_nav_selected(self._nav_surface(True))) self.assertFalse(bot._message_nav_selected(self._nav_surface(False))) def test_message_workspace_accepts_sparse_200_percent_dpi_highlight(self): """Real 200% DPI rounded highlights expose only about 11% blue pixels.""" image = np.full((360, 600, 4), 242, dtype=np.uint8) image[:, :, 3] = 255 # 1000 / (80 * 116) ~= 10.8%; the old 12% cutoff rejected this. image[140:160, 8:58, :3] = (238, 126, 36) bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 self.assertTrue(bot._message_nav_selected(image)) def test_dynamic_navigation_width_supports_narrow_and_wide_sidebars(self): def surface(nav_width): image = np.full((420, 900, 4), 248, dtype=np.uint8) image[:, :, 3] = 255 # 浅蓝导航背景(BGR),消息行右侧从这里突变为中性列表背景。 image[130:210, :nav_width, :3] = (250, 232, 215) image[130:210, nav_width:, :3] = (245, 245, 245) return image self.assertEqual(infer_navigation_width(surface(136), 2.0)[0], 136) self.assertEqual(infer_navigation_width(surface(320), 2.0)[0], 320) def test_session_list_width_follows_the_persistent_visual_divider(self): image = np.full((1300, 2200, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 # 320px 导航后,会话列表宽 508px;右侧聊天区底色不同并带纵向阴影。 image[112:, :320, :3] = 232 image[112:, 320:820, :3] = 248 image[112:, 820:828, :3] = 220 image[112:, 828:, :3] = 238 detected, confidence = infer_session_list_width(image, 320, 2.0) self.assertGreaterEqual(confidence, 0.60) self.assertGreaterEqual(detected, 500) self.assertLessEqual(detected, 512) def test_flat_text_avatar_is_admitted_only_as_visual_proof_candidate(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot.store = mock.Mock() bot.store.has_record.return_value = False bot._flat_visual_proof_fps = set() bot._flat_rejected_session_fps = set() image = np.full((180, 460, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 # Rounded blue square with a simple white glyph: representative of a # generated WeCom text avatar and deliberately flat-colour. image[20:108, 22:108, :3] = (220, 120, 30) image[14:25, 16:30, :3] = 245 image[14:25, 100:116, :3] = 245 image[100:114, 16:30, :3] = 245 image[100:114, 100:116, :3] = 245 image[42:82, 56:70, :3] = 250 image[54:68, 42:84, :3] = 250 fp = b"f" * 40 bot.detect_badge_rows = mock.Mock(return_value=[32]) bot._session_fingerprint = mock.Mock(return_value=fp) self.assertFalse(bot._is_real_conversation(image, 32, quiet=True)) found = bot._target_from_session_image(image, set(), set()) self.assertEqual(found, (32, fp)) self.assertIn(fp.hex(), bot._flat_visual_proof_fps) def test_selected_blue_row_uses_background_inside_rounded_highlight(self): """200% DPI selected rows must not compare avatar corners to outer white.""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 image = np.full((180, 460, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 selected_blue = np.array((242, 131, 48), dtype=np.uint8) image[0:128, 8:, :3] = selected_blue # Avatar bounds at 200% DPI are x=16..116, y=14..114. Paint a # textured green avatar, then expose selected-blue rounded corners. image[14:114, 16:116, :3] = (80, 190, 70) for offset in range(14, 114): image[offset, 28:104, 1] = 120 + (offset % 100) image[14:24, 16:26, :3] = selected_blue image[14:24, 106:116, :3] = selected_blue image[104:114, 16:26, :3] = selected_blue image[104:114, 106:116, :3] = selected_blue # The old x=6 sample is white while all rounded corners are blue. self.assertGreater( int(np.abs( bot._patch_color(image, 6, 64) - bot._patch_color(image, 16, 14) ).sum()), 45, ) self.assertTrue( bot._is_real_conversation( image, 64, quiet=True, row_center=True, allow_flat=True, ) ) bot._session_fingerprint = mock.Mock(return_value=b"b" * 40) bot._pending_reply_sessions = {} bot._flat_verified_session_fps = set() bot._flat_rejected_session_fps = set() bot._session_render_ids = {} bot._live_session_fp_aliases = set() self.assertFalse(bot._is_tool_selected(image, 64)) def test_square_unread_avatar_is_kept_for_safe_visual_proof(self): """A verified unread + full composite identity must not be discarded by shape.""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot.store = mock.Mock() bot.store.has_record.return_value = False bot._flat_visual_proof_fps = set() bot._flat_rejected_session_fps = set() image = np.full((180, 460, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 # Deliberately square/full-bleed so every corner differs from the row. # Extend one pixel beyond the nominal bounds: a 5x5 corner patch then # has an icon-colour majority, while the x=14 background patch still # has a row-background majority. image[13:115, 15:117, :3] = (70, 190, 80) fp = b"s" * 40 bot.detect_badge_rows = mock.Mock(return_value=[32]) bot._session_fingerprint = mock.Mock(return_value=fp) self.assertFalse( bot._is_real_conversation( image, 32, quiet=True, allow_flat=True, ) ) self.assertEqual( bot._target_from_session_image(image, set(), set()), (32, fp), ) self.assertIn(fp.hex(), bot._flat_visual_proof_fps) def test_selected_square_avatar_only_bypasses_tool_filter_for_exact_pending(self): bot = WeChatBot.__new__(WeChatBot) target = b"a" * 8 + b"n" * 32 different_name = b"a" * 8 + b"x" * 32 bot._pending_reply_sessions = { target.hex(): {"requires_visual_proof": True}, } bot._flat_verified_session_fps = set() bot._flat_rejected_session_fps = set() bot._session_render_ids = {} bot._live_session_fp_aliases = set() bot._is_real_conversation = mock.Mock(return_value=False) bot._session_fingerprint = mock.Mock(return_value=target) page = np.zeros((128, 180, 4), dtype=np.uint8) self.assertFalse(bot._is_tool_selected(page, 64)) bot._session_fingerprint.return_value = different_name self.assertTrue(bot._is_tool_selected(page, 64)) def test_verified_square_avatar_stays_selectable_after_visual_proof_clears(self): bot = WeChatBot.__new__(WeChatBot) target = b"v" * 40 bot._pending_reply_sessions = {} bot._flat_verified_session_fps = {target.hex()} bot._flat_rejected_session_fps = set() bot._session_render_ids = {} bot._live_session_fp_aliases = set() bot._is_real_conversation = mock.Mock(return_value=False) bot._session_fingerprint = mock.Mock(return_value=target) bot.store = mock.Mock() bot.store.has_record.return_value = False self.assertFalse( bot._is_tool_selected(np.zeros((128, 180, 4), dtype=np.uint8), 64) ) def test_200_percent_dpi_input_point_is_inside_editor_body(self): # Regression fixture from the user's original 2726x1756 WeCom image: # the toolbar occupies roughly y=1510..1590. The old 95px offset # landed at y=1566 (toolbar); the new point must be in the editor body. input_y = 1756 - int(INPUT_Y_FROM_BOTTOM * 2.0) self.assertGreaterEqual(input_y, 1600) self.assertLess(input_y, 1756) def test_200_percent_dpi_detects_both_resizable_composer_heights(self): fixtures = ( (1756, 2726, 1510), (1316, 2254, 1008), ) for height, width, divider in fixtures: image = np.full((height, width, 4), 247, dtype=np.uint8) image[:, :, 3] = 255 image[divider:divider + 2, :, :3] = 231 image[divider + 2:, :, :3] = 255 detected = infer_composer_top(image, int(width * 0.30), 2.0) self.assertEqual(detected, divider) self.assertLess(detected - int(2 * 2.0), divider) ambiguous = np.full((1316, 2254, 4), 247, dtype=np.uint8) ambiguous[:, :, 3] = 255 for divider in (820, 1008): ambiguous[divider:divider + 2, :, :3] = 231 ambiguous[divider + 2:, :, :3] = 255 # Repaint the band between the two rules so both remain independent. ambiguous[822:1008, :, :3] = 247 ambiguous[1008:1010, :, :3] = 231 ambiguous[1010:, :, :3] = 255 # 聊天内容里的宽横边(大图/整行气泡边缘)也会形成候选。旧实现遇到 # 多个候选就放弃,小窗口下输入区永远识别不出来、回复功能无法启动。 # 现在选择最靠底部、下方是白色编辑区的那条分隔线。 self.assertEqual( infer_composer_top(ambiguous, int(2254 * 0.30), 2.0), 1008, ) def test_composer_found_despite_wide_content_edge_in_chat(self): """聊天区中部的全宽横边不再让分隔线检测放弃(用户实机截图场景)。""" image = np.full((1196, 2048, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 # 聊天中部一条内容横边,下方仍是灰色聊天背景。 image[510:512, :, :3] = 226 # 真正的输入区分隔线,下方是白色编辑区。 image[916:918, :, :3] = 231 image[918:, :, :3] = 255 self.assertEqual( infer_composer_top(image, int(2048 * 0.30), 2.0), 916, ) def test_chat_pane_right_detects_sidebar_divider(self): """右侧工具栏展开时,靠纵贯分隔线找到聊天面板右缘。""" image = np.full((1196, 2048, 4), 246, dtype=np.uint8) image[:, :, 3] = 255 # 聊天面板与工具栏之间的纵贯分隔线(比左右邻列暗)。 image[:, 1550:1552, :3] = 232 chat_left = 580 self.assertEqual(infer_chat_pane_right(image, chat_left, 2.0), 1550) # 没有分隔线 → 聊天区延伸到窗口右缘。 plain = np.full((1196, 2048, 4), 246, dtype=np.uint8) plain[:, :, 3] = 255 self.assertIsNone(infer_chat_pane_right(plain, chat_left, 2.0)) # 断续的短竖线(气泡边缘/滚动条)不能被当成面板分界。 broken = plain.copy() broken[300:700, 1550:1552, :3] = 232 self.assertIsNone(infer_chat_pane_right(broken, chat_left, 2.0)) def test_composer_scan_is_bounded_inside_chat_pane(self): """右侧工具栏内容不再干扰输入区分隔线检测。""" image = np.full((1196, 2048, 4), 246, dtype=np.uint8) image[:, :, 3] = 255 chat_left = 400 # 工具栏(x>=1550)填充杂乱内容,破坏全宽 80% 变化率要求。 image[:, 1550:1552, :3] = 232 rng = np.random.default_rng(7) image[:, 1560:, :3] = rng.integers(120, 250, (1196, 488, 3), dtype=np.uint8) # 分隔线只存在于聊天面板内部。 image[916:918, chat_left:1550, :3] = 231 image[918:, chat_left:1550, :3] = 255 # 不限定右缘时扫描带跨进工具栏,检测失败(旧 bug)。 self.assertIsNone(infer_composer_top(image, chat_left, 2.0)) # 限定聊天面板右缘后成功。 self.assertEqual( infer_composer_top(image, chat_left, 2.0, chat_right=1550), 916, ) def test_post_paste_divider_check_ignores_sidebar_and_editor_text(self): """The final Enter gate must use the chat pane, not the whole window.""" image = np.full((1196, 2048, 4), 246, dtype=np.uint8) image[:, :, 3] = 255 chat_left = 400 image[:, 1550:1552, :3] = 232 rng = np.random.default_rng(17) image[:, 1560:, :3] = rng.integers( 120, 250, (1196, 488, 3), dtype=np.uint8 ) image[916:918, chat_left:1550, :3] = 231 image[918:, chat_left:1550, :3] = 255 # The reply has already been pasted into the upper editor body. image[958:982, 460:920, :3] = 70 matched, detected = composer_divider_matches( image, chat_left, 916, 2.0, chat_right=1550, ) self.assertTrue(matched) self.assertLessEqual(abs(int(detected) - 916), 8) def test_refresh_geometry_shrinks_chat_region_when_sidebar_opens(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L, bot.T, bot.R, bot.B = 0, 0, 2048, 1196 bot._list_x = 320 bot._selected_tracking_initialized = True bot._active_session_fp = b"old" bot._active_chat_signature = b"old" bot._active_identity_signature = b"old" bot._composer_geometry_valid = True bot._composer_rel_top = 916 full = np.zeros((1196, 2048, 4), dtype=np.uint8) with ( mock.patch("wechat_bot.infer_navigation_width", return_value=(320, 0.9)), mock.patch("wechat_bot.infer_chat_pane_right", return_value=1550), mock.patch("wechat_bot.infer_composer_top", return_value=916), ): # 工具栏从无到有属于几何变化,必须触发重算。 self.assertTrue(bot._refresh_message_geometry(full)) self.assertEqual(bot._chat_pane_right_rel, 1550) region = bot._chat_region self.assertLessEqual(region["left"] + region["width"], 1550) # 输入点击必须落在聊天面板内,而不是工具栏里。 self.assertLess(bot.input_x, 1550) # 视觉截图区域同样止步于面板右缘。 self.assertLessEqual(bot._chat_rel_x + bot._chat_rel_w, 1550) def test_refresh_geometry_pane_unchanged_is_not_a_layout_change(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L, bot.T, bot.R, bot.B = 0, 0, 2048, 1196 bot._list_x = 320 bot._composer_geometry_valid = True bot._composer_rel_top = 916 bot._chat_pane_right_rel = None full = np.zeros((1196, 2048, 4), dtype=np.uint8) with ( mock.patch("wechat_bot.infer_navigation_width", return_value=(320, 0.9)), mock.patch("wechat_bot.infer_chat_pane_right", return_value=None), mock.patch("wechat_bot.infer_composer_top", return_value=916), ): self.assertFalse(bot._refresh_message_geometry(full)) def test_pending_square_avatar_can_be_found_after_unread_badge_disappears(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot.session_item_h = 32 target = b"p" * 40 bot._pending_reply_sessions = { target.hex(): {"requires_visual_proof": True}, } bot._flat_verified_session_fps = set() bot._flat_rejected_session_fps = set() bot._session_render_ids = {} bot._live_session_fp_aliases = set() page = np.zeros((64, 180, 4), dtype=np.uint8) bot.detect_selected_row = mock.Mock(return_value=-1) bot.detect_badge_rows = mock.Mock(return_value=[]) bot._session_fingerprint = mock.Mock( side_effect=lambda _img, y, row_center=True: ( target if y == 16 else b"z" * 40 ) ) bot._is_real_conversation = mock.Mock(return_value=False) self.assertEqual(bot._pending_rows_on_page(page, target), [16]) def test_rejected_flat_row_render_drift_does_not_starve_next_unread(self): """A proven tool row stays excluded after font/selection render drift.""" bot = WeChatBot.__new__(WeChatBot) rejected = b"a" * 8 + bytes(32) drifted_name = bytearray(32) drifted_name[2] = 0b00000001 # allowed middle-render drift drifted_name[8] = 0b00000001 # allowed absolute-grid drift drifted = b"a" * 8 + bytes(drifted_name) customer_name = bytearray(32) customer_name[0] = 0b00000001 # distinct first-glyph identity customer = b"b" * 8 + bytes(customer_name) bot._flat_rejected_session_fps = {rejected.hex()} # Persisted 40-byte archive keys stay byte-for-byte unchanged. Only a # process-local render ID proves that the unread-bold and restored-row # variants are the same system entry. bot._session_render_ids = { rejected.hex(): {"same-live-render"}, drifted.hex(): {"same-live-render"}, } bot.detect_badge_rows = mock.Mock(return_value=[16, 80]) bot._session_fingerprint = mock.Mock( side_effect=lambda _img, y: drifted if y == 16 else customer ) bot._is_real_conversation = mock.Mock(return_value=True) bot._flat_row_requires_visual_proof = mock.Mock(return_value=False) page = np.zeros((128, 180, 4), dtype=np.uint8) non_conversations = set() self.assertEqual( bot._target_from_session_image(page, set(), non_conversations), (80, customer), ) self.assertIn(drifted, non_conversations) def test_badge_and_selected_row_use_the_same_avatar_center(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() image = np.full((180, 460, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 # badge y=32 映射到头像中心 y=64;构造一块不对称纹理头像。 for y in range(40, 88): for x in range(20, 84): image[y, x, :3] = ((x * 3) % 255, (y * 5) % 255, (x + y) % 255) image[28:54, 170:220, :3] = 20 from_badge = bot._session_fingerprint(image, 32) from_selected_center = bot._session_fingerprint( image, 64, row_center=True, ) self.assertEqual(from_badge, from_selected_center) @staticmethod def _session_list_surface(rows=6, pitch=128, first_center=64): """构造多行会话列表:规整行距、各行纹理不同的头像方块与名称字形。""" height = first_center + pitch * (rows - 1) + pitch // 2 image = np.full((height, 460, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 for index in range(rows): center = first_center + index * pitch for y in range(center - 40, center + 40): for x in range(20, 88): image[y, x, :3] = ( (x * 3 + index * 29) % 255, (y * 5) % 255, (x + y + index * 11) % 255, ) image[center - 38:center - 8, 122:158, :3] = 25 image[center + 4:center + 26, 122:200, :3] = 90 return image def _fingerprint_bot(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() return bot def test_row_estimate_error_no_longer_forks_the_archive_key(self): """行锚点估算偏差绝不能让同一个联系人算出第二个档案键。 档案查找是精确匹配,而头像感知哈希对锚点极敏感;锚点一偏,会话就被当成 “首次遇到”,复制到的聊天文字会被丢弃、只靠截图回复,历史也注入不了。 """ bot = self._fingerprint_bot() image = self._session_list_surface() truth = bot._session_fingerprint(image, 192, row_center=True) self.assertTrue(truth) for offset in (-48, -32, -18, -6, -1, 1, 6, 18, 32, 48): self.assertEqual( bot._session_fingerprint(image, 192 + offset, row_center=True), truth, msg=f"行锚点偏 {offset}px 就换了档案键", ) def test_badge_offset_error_keeps_the_unread_and_opened_keys_identical(self): """未读徽章按固定偏移推算行中心,偏差不能改写会话键。""" bot = self._fingerprint_bot() image = self._session_list_surface() # 徽章画得比假定位置高 18px,推算出的行中心随之偏离真实头像中心。 badge_y = 192 - int(16 * bot.scale) - 18 self.assertNotEqual(bot._row_center_from_badge(image, badge_y), 192) self.assertEqual( bot._session_fingerprint(image, badge_y), bot._session_fingerprint(image, 192, row_center=True), ) def test_avatar_anchor_never_snaps_onto_a_neighbouring_row(self): """吸附只能收敛同一行的估算误差,绝不能跨行认错联系人。""" bot = self._fingerprint_bot() image = self._session_list_surface() self.assertEqual(bot._avatar_anchor(image, 192), 192) self.assertEqual(bot._avatar_anchor(image, 320), 320) self.assertNotEqual( bot._session_fingerprint(image, 192, row_center=True), bot._session_fingerprint(image, 320, row_center=True), ) def test_unconfirmed_navigation_width_mints_no_session_identity(self): """导航宽度未识别确认时绝不铸造会话键。 宽/窄侧栏的列表裁剪起点实测相差 184px,此时采到的“头像”其实是别处的 像素,算出的键与真实键相差 25 位(容差 6 位)。这种键一旦落盘就永久 多出一个认不回来的会话:档案查不到 → 每轮都算“首次遇到” → 复制到的 聊天文字被丢弃、只靠截图回复。 """ bot = self._fingerprint_bot() image = self._session_list_surface() bot._nav_width_confirmed = False bot._nav_width_gate_logged = False self.assertEqual(bot._session_fingerprint(image, 192, row_center=True), b"") bot._nav_width_confirmed = True self.assertTrue(bot._session_fingerprint(image, 192, row_center=True)) # ── 头像 pHash 漂移不能把同一个联系人否掉 ──────────────────────────────── # 现场日志(2026-07-31 09:40:18→09:40:19):点击会话后 1 秒复核,名称字形 # 哈希逐位相同,头像 pHash 却漂了 15 位,于是被判成“打开的不是目标会话”。 # 每个会话回两次之后指纹一漂,档案和待回复任务就都认不回来了。 _DRIFT_NAME = bytes.fromhex("07fd03fb") + b"\x5a" * 28 _DRIFT_AVATAR_BEFORE = bytes.fromhex("05050d1d1f0f0700") _DRIFT_AVATAR_AFTER = bytes.fromhex("000818181f1f0f0e") def _fp_bot(self): bot = WeChatBot.__new__(WeChatBot) bot._live_session_fp_aliases = set() return bot def test_same_contact_survives_the_measured_avatar_hash_drift(self): bot = self._fp_bot() before = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME after = self._DRIFT_AVATAR_AFTER + self._DRIFT_NAME distance = ( int.from_bytes(self._DRIFT_AVATAR_BEFORE, "big") ^ int.from_bytes(self._DRIFT_AVATAR_AFTER, "big") ).bit_count() # 守住前提:这确实超出原来的 6 位容差,否则本测试就失去意义。 self.assertGreater(distance, bot._FP_HAMMING_TOL) self.assertTrue(bot._session_fp_matches(before, after)) def test_the_drifted_avatar_canonicalizes_onto_the_existing_key(self): """归一化也必须认下这次漂移,否则同一个人当场被铸成第二把档案键。 `_session_fp_matches` 放行、`_canonical_session_fp` 却按 6 位头像容差把 已有键筛掉的话,落盘的仍是一把新键:档案查不到、待回复任务对不上, 这个联系人一样从此收不到回复。 """ bot = self._fp_bot() before = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME after = self._DRIFT_AVATAR_AFTER + self._DRIFT_NAME bot._known_session_fps = {before} self.assertEqual(bot._canonical_session_fp(after), before) # ── 一次只服务一个会话:当前会话先收尾,其余按到达先后排队 ────────────── def test_the_open_session_is_served_before_anyone_else(self): """正在回复的会话不能被别的会话抢走,否则这条回复要等下一轮重来。""" pending = { "aa" * 40: {"created_at": 100.0}, "bb" * 40: {"created_at": 200.0}, "cc" * 40: {"created_at": 300.0}, } active = bytes.fromhex("cc" * 40) order = [key for key, _ in WeChatBot._pending_queue_order(pending, active)] self.assertEqual(order[0], "cc" * 40) def test_the_rest_of_the_queue_is_first_come_first_served(self): """多个会话同时来消息就排队,按到达先后一条条处理。""" pending = { "cc" * 40: {"created_at": 300.0}, "aa" * 40: {"created_at": 100.0}, "bb" * 40: {"created_at": 200.0}, } order = [key for key, _ in WeChatBot._pending_queue_order(pending, None)] self.assertEqual(order, ["aa" * 40, "bb" * 40, "cc" * 40]) def test_a_missing_arrival_time_never_breaks_the_queue(self): """旧任务缺少 created_at 时排队不能崩,只能排在最前面等着被收掉。""" pending = { "aa" * 40: {}, "bb" * 40: {"created_at": "坏值"}, "cc" * 40: {"created_at": 200.0}, } order = [key for key, _ in WeChatBot._pending_queue_order(pending, None)] self.assertEqual(order[-1], "cc" * 40) # ── 新会话只有一条消息,复制不出表头,仍要能证明回复发出去了 ──────────── # 现场(2026-07-31 12:11):新客户只发了“你好”,框选只能选中气泡内文字, # 企微不给“发言人+时间”表头,消息块锚点建不起来,发送被永久拦下。 def _receipt_bot(self, visible_after, outgoing=True, surface_changed=True): bot = WeChatBot.__new__(WeChatBot) bot.extract_chat_text = lambda screens=1, wait_for_idle=False: visible_after bot._last_visible_bubble_is_outgoing = lambda: outgoing bot._chat_surface_signature = lambda: b"after" if surface_changed else b"before" return bot def test_a_headerless_chat_can_still_prove_the_reply_landed(self): reply = "你好呀,我是贴心管家,有什么想问的您慢慢说" bot = self._receipt_bot(f"你好\n{reply}") self.assertIs( bot._raw_text_receipt_matches(reply, "你好", b"before"), True, ) def test_a_reply_that_never_appeared_is_not_accepted(self): reply = "你好呀,我是贴心管家" bot = self._receipt_bot("你好") self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), False) def test_a_customer_followup_does_not_cancel_our_receipt(self): """客户在我们发完后马上又说一句,末条气泡就在左边。 现场(2026-07-31 12:38):回复实际已经发出去了,客户紧接着补了句 “你在哪呢”,末条落到左侧,回执被判定为失败,任务卡在待核对里既不重发 也不放行。这句回复是模型现生成的,发送前整屏文字里没有、现在有了,就 只能是我们发的;末条在左边只说明客户有新话要接。 """ reply = "你好呀,我是贴心管家" bot = self._receipt_bot(f"你好\n{reply}\n你在哪呢", outgoing=False) self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), True) self.assertIs(bot._last_send_receipt_followup, True) def test_a_quiet_chat_reports_no_followup_to_chase(self): reply = "你好呀,我是贴心管家" bot = self._receipt_bot(f"你好\n{reply}", outgoing=True) self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), True) self.assertIsNone(bot._last_send_receipt_followup) def test_a_reply_missing_from_the_screen_is_still_refused(self): """末条气泡不再有否决权,但"屏幕上根本没有这句话"仍然必须判失败。""" reply = "你好呀,我是贴心管家" bot = self._receipt_bot("你好\n你在哪呢", outgoing=False) self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), False) @staticmethod def _login_page(qr_side=420, center=(0.5, 0.45), width=2258, height=1320): """一张真的登录页:白底、正中一个真二维码、没有任何主界面结构。""" import cv2 page = np.full((height, width, 4), 245, dtype=np.uint8) code = cv2.QRCodeEncoder_create().encode( "https://work.weixin.qq.com/login/verify/abc123" ) code = cv2.resize( code, (qr_side, qr_side), interpolation=cv2.INTER_NEAREST ) cx = int(width * center[0]) cy = int(height * center[1]) x0, y0 = cx - qr_side // 2, cy - qr_side // 2 page[y0:y0 + qr_side, x0:x0 + qr_side, :3] = code[:, :, None] return page def test_a_customer_image_is_kept_with_the_archived_message(self): """企微图片没有可复制文本,档案里只剩「(客户发来图片)」这行占位。 事后翻记录完全不知道客户发了什么。视觉模式本来就截了这一屏发给模型, 顺手落一份盘,会话记录才有东西可显示。 """ from conversation_store import ConversationStore with tempfile.TemporaryDirectory() as directory: store = ConversationStore(os.path.join(directory, "conversations.json")) store.append_exchange_once( "abc", "(客户发来图片)", "收到啦", "tx1", user_image="shot.png" ) history = store.history("abc") self.assertEqual(history[0]["image"], "shot.png") # 我方回复没有画面,不该凭空多出这个键 self.assertNotIn("image", history[1]) def test_a_text_only_exchange_stays_free_of_image_keys(self): from conversation_store import ConversationStore with tempfile.TemporaryDirectory() as directory: store = ConversationStore(os.path.join(directory, "conversations.json")) store.append_exchange_once("abc", "在不在", "在呢", "tx1") self.assertNotIn("image", store.history("abc")[0]) def test_a_saved_snapshot_lands_on_disk_and_old_ones_are_pruned(self): with tempfile.TemporaryDirectory() as directory: with ( mock.patch.object(WeChatBot, "_MEDIA_DIR", directory), mock.patch.object(WeChatBot, "_MEDIA_KEEP_FILES", 3), ): bot = WeChatBot.__new__(WeChatBot) names = [ bot._save_media_snapshot(b"f" * 40, b"PNG-%d" % index) for index in range(6) ] self.assertTrue(all(names)) left = [n for n in os.listdir(directory) if n.endswith(".png")] self.assertLessEqual(len(left), 3) # 留下的必须是最近几张,最早那几张才是该淘汰的 self.assertIn(names[-1], left) self.assertNotIn(names[0], left) def test_an_unwritable_media_dir_never_blocks_the_reply(self): """留存失败是小事,绝不能因此不回客户。""" bot = WeChatBot.__new__(WeChatBot) with mock.patch("wechat_bot.os.makedirs", side_effect=OSError("满了")): self.assertEqual(bot._save_media_snapshot(b"f" * 40, b"PNG"), "") self.assertEqual(bot._save_media_snapshot(b"f" * 40, b""), "") def test_the_staged_snapshot_reaches_the_archive(self): bot = WeChatBot.__new__(WeChatBot) fp = b"m" * 40 bot._pending_exchanges = {} bot._pending_reply_sessions = { fp.hex(): { "staged_user_text": "(客户发来图片)", "staged_reply_text": "收到啦", "staged_user_image": "shot.png", "exchange_id": "tx1", "archive_enabled": True, } } bot.store = mock.Mock() bot.store.append_exchange_once = mock.Mock(return_value=True) bot._forget_uncertain_tracking = mock.Mock() bot._persist_pending_replies = mock.Mock() bot._commit_staged_exchange(fp.hex()) bot.store.append_exchange_once.assert_called_once_with( fp.hex(), "(客户发来图片)", "收到啦", "tx1", user_image="shot.png", ) def test_the_staged_snapshot_survives_a_restart(self): """发送和入档之间进程挂掉,重启后这张画面不能丢。""" with tempfile.TemporaryDirectory() as directory: bot = WeChatBot.__new__(WeChatBot) fp = b"m" * 40 bot._pending_reply_path = os.path.join(directory, "pending.json") bot._pending_reply_sessions = { fp.hex(): { "confirmed_unread": True, "staged_user_image": "shot.png", "created_at": time.time(), "updated_at": time.time(), } } self.assertTrue(bot._persist_pending_replies()) restored = bot._load_pending_replies() self.assertEqual(restored[fp.hex()]["staged_user_image"], "shot.png") def _extract_with_screens(self, screen_lines): """跑一遍逐屏采集,screen_lines 依次是每屏框选到的内容。""" bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 1 bot._activate_wx = mock.Mock(return_value=True) bot._select_visible_chat = mock.Mock(side_effect=list(screen_lines)) scrolls = [] with ( mock.patch("wechat_bot.pyperclip.paste", return_value=""), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.pyautogui.moveTo"), mock.patch( "wechat_bot.pyautogui.scroll", side_effect=lambda amount, *a, **k: scrolls.append(amount), ), mock.patch("wechat_bot.time.sleep"), ): text = bot._extract_chat_text_locked( 1, 100, 100, 800, 600, 10, 500, 400, 130 ) return text, bot._select_visible_chat.call_count, scrolls @staticmethod def _screen_of_body(count, prefix="这是很长的一段留言"): """一屏纯正文,没有任何「发言人+时间」表头。""" return [f"{prefix}{index}" for index in range(count)] def test_a_message_taller_than_one_screen_is_scrolled_up_for(self): """满屏正文却没有表头,说明这条消息的开头还在上面,必须往上翻。 现场(2026-07-31 14:16):客户发来的长留言超过一屏,增量模式只取一屏 读到的是尾巴,档案里只剩「(首次会话的新消息已由聊天截图确认)」, 模型回了句"这句话没显示完整,您再发一遍吧"。 """ first = self._screen_of_body(wechat_bot.CHAT_FULL_SCREEN_LINES + 4) second = ["高瑞 14:16"] + self._screen_of_body(6, "开头那几句") text, calls, scrolls = self._extract_with_screens([first, second]) self.assertEqual(calls, 2) self.assertIn("高瑞 14:16", text) self.assertIn("开头那几句0", text) # 翻上去之后必须原路滚回底部,否则下一轮停在半空 self.assertTrue(any(amount > 0 for amount in scrolls)) self.assertTrue(any(amount < 0 for amount in scrolls)) def test_a_normal_screen_with_headers_never_scrolls(self): """能看到表头就说明这条消息是完整的,多翻一屏是白白浪费两秒。""" screen = ["高瑞 14:16"] + self._screen_of_body( wechat_bot.CHAT_FULL_SCREEN_LINES + 4 ) _text, calls, scrolls = self._extract_with_screens([screen]) self.assertEqual(calls, 1) self.assertFalse(scrolls) def test_a_sparse_headerless_chat_does_not_trigger_the_search(self): """新会话同样复制不出表头,但它行数很少,不该被当成被截断的长消息。""" _text, calls, scrolls = self._extract_with_screens([["在不在"]]) self.assertEqual(calls, 1) self.assertFalse(scrolls) def test_the_upward_search_gives_up_instead_of_scrolling_forever(self): """对方粘了一整份文档时不能一直翻下去,把这个会话拖死。""" full = self._screen_of_body(wechat_bot.CHAT_FULL_SCREEN_LINES + 4) screens = [ self._screen_of_body( wechat_bot.CHAT_FULL_SCREEN_LINES + 4, f"第{index}屏" ) for index in range(20) ] _text, calls, _scrolls = self._extract_with_screens(screens) self.assertEqual(calls, 1 + WeChatBot._CLIPPED_MESSAGE_EXTRA_SCREENS) self.assertTrue(WeChatBot._newest_message_is_clipped([full])) def test_a_reordered_row_does_not_cost_everyone_else_the_round(self): """刷屏消息把列表搅乱时,打不开的那个会话不能拖着所有人一起等下一轮。 现场(2026-07-31 14:04):测试消息一密集,红点识别和点击之间列表就重排, 点开的不是目标,于是整轮 break——其他客户全部顺延。 """ bot = WeChatBot.__new__(WeChatBot) stuck = b"x" * 40 served = b"y" * 40 page = self._nav_surface(True) bot.session_item_h = 32 bot.list_region = {"top": 0} bot.list_click_x = 200 bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot._flat_visual_proof_fps = set() bot._pending_reply_sessions = {} bot._persist_pending_replies = mock.Mock(return_value=True) bot._mark_reply_pending = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=page) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock(return_value=page) bot._check_selected_session = mock.Mock(return_value=False) bot._resume_orphaned_pending_reply = mock.Mock(return_value=False) bot._hold_for_unfinished_active_session = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) bot._row_center_from_badge = mock.Mock(return_value=16) bot._ensure_message_workspace = mock.Mock(return_value=True) bot._last_click_failure_reason = "selected_fingerprint_mismatch" def next_unread(processed_fp, non_conv_fp, full=None): for fp in (stuck, served): if fp not in processed_fp and fp not in non_conv_fp: return page, 16, fp return None bot._find_next_unread_session = mock.Mock(side_effect=next_unread) bot.click_session = mock.Mock(return_value=False) handled = [] bot.handle_session_reply = mock.Mock( side_effect=lambda *a, **k: handled.append(a) ) with mock.patch("wechat_bot.time.sleep"): bot._poll_once() # 打不开的那个被记进本轮跳过集合,扫描才会换人;否则一整轮的名额全耗在它身上 clicked = [call.args[1] if len(call.args) > 1 else call.kwargs["expected_fp"] for call in bot.click_session.call_args_list] self.assertIn(served, clicked) def test_a_real_login_qr_still_stops_everything(self): self.assertTrue( wechat_bot.looks_like_security_verification(self._login_page()) ) def test_dense_chat_text_is_not_a_login_qr(self): """现场(2026-07-31 14:04):一条带表情的长广告消息让机器人整个停机。 旧判据靠"中央够黑 + 够亮 + 明暗跳变够多"猜二维码,实测这三个数是 0.0254 / 0.9203 / 0.0367,全部刚刚压线过关。密集小字就是长这样。 """ rng = np.random.default_rng(20260731) page = np.full((1320, 2258, 4), 250, dtype=np.uint8) for row in range(120, 1100, 26): for col in range(700, 1900, 11): if rng.random() < 0.55: page[row:row + 13, col:col + 7, :3] = 40 self.assertFalse(wechat_bot.looks_like_security_verification(page)) def test_a_payment_qr_sent_into_a_chat_is_not_a_login_page(self): """客户发一张收款码进来,机器人不能就此罢工等人扫码。""" page = self._login_page(qr_side=220, center=(0.78, 0.5)) self.assertFalse(wechat_bot.looks_like_security_verification(page)) def test_the_message_workspace_overrides_any_qr_on_screen(self): """消息工作区还在,就绝不可能是登录页。""" bot = WeChatBot.__new__(WeChatBot) bot.security_verification_required = False bot._security_log_emitted = False bot.hwnd = 1 bot._message_nav_selected = mock.Mock(return_value=True) with ( mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 2258, 1320)), mock.patch( "wechat_bot.capture_window_region", return_value=self._login_page(), ), ): self.assertFalse(bot._security_gate_visible()) self.assertFalse(bot.security_verification_required) def test_a_login_page_without_a_workspace_locks_the_bot(self): bot = WeChatBot.__new__(WeChatBot) bot.security_verification_required = False bot._security_log_emitted = False bot._window_ready = True bot.hwnd = 1 bot._message_nav_selected = mock.Mock(return_value=False) with ( mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 2258, 1320)), mock.patch( "wechat_bot.capture_window_region", return_value=self._login_page(), ), ): self.assertTrue(bot._security_gate_visible()) self.assertTrue(bot.security_verification_required) def test_a_subscription_row_is_evicted_from_the_queue(self): """指纹分毫不差、但这一行根本不是聊天:再等下去也不会变。 现场探针(2026-07-31 12:47):打卡和行业资讯两个任务,头像与名称哈希 距离都是 0,被 _is_real_conversation 否掉,于是队列每一轮都为它们做一次 全列表分页扫描(6 个任务 21 秒),真正的客户全排在后面。 """ bot = WeChatBot.__new__(WeChatBot) fp = b"s" * 40 bot._composerless_strikes = {} bot._unrepliable_sessions = {} bot._persist_unrepliable_sessions = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._note_unclickable_pending(fp) self.assertFalse(bot._unrepliable_sessions) bot._clear_reply_pending.assert_not_called() bot._note_unclickable_pending(fp) self.assertIn(fp.hex(), bot._unrepliable_sessions) bot._clear_reply_pending.assert_called_once_with(fp) bot._persist_unrepliable_sessions.assert_called_once() def test_a_reopened_row_clears_its_own_strikes(self): """真会话偶尔判错一次,不能因此被误判成订阅号。""" bot = WeChatBot.__new__(WeChatBot) fp = b"t" * 40 bot._composerless_strikes = {} bot._unrepliable_sessions = {} bot._persist_unrepliable_sessions = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._composer_geometry_valid = True bot._note_unclickable_pending(fp) self.assertTrue(bot._session_accepts_replies(fp)) self.assertNotIn(fp.hex(), bot._composerless_strikes) bot._note_unclickable_pending(fp) self.assertFalse(bot._unrepliable_sessions) def test_a_task_with_no_matching_row_anywhere_expires(self): """列表里找不到对应会话的空壳任务不能永远占着队列。""" bot = WeChatBot.__new__(WeChatBot) fp = b"g" * 40 bot._clear_reply_pending = mock.Mock() state = {"resume_failures": bot._UNREACHABLE_RESUME_FAILURES - 1} self.assertFalse(bot._expire_unreachable_pending(fp, state)) bot._clear_reply_pending.assert_not_called() state["resume_failures"] = bot._UNREACHABLE_RESUME_FAILURES self.assertTrue(bot._expire_unreachable_pending(fp, state)) bot._clear_reply_pending.assert_called_once_with(fp) def test_a_findable_task_never_expires_on_a_bad_count(self): bot = WeChatBot.__new__(WeChatBot) bot._clear_reply_pending = mock.Mock() self.assertFalse( bot._expire_unreachable_pending(b"h" * 40, {"resume_failures": "坏值"}) ) bot._clear_reply_pending.assert_not_called() def test_an_unchanged_screen_is_never_taken_as_a_send(self): reply = "你好呀,我是贴心管家" bot = self._receipt_bot(f"你好\n{reply}", surface_changed=False) self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), False) def test_a_baseline_already_holding_the_reply_proves_nothing(self): """基线里已经有这句话,发送前后长得一样,证明不了任何事。""" reply = "你好呀,我是贴心管家" bot = self._receipt_bot(f"你好\n{reply}") self.assertIsNone( bot._raw_text_receipt_matches(reply, f"你好 {reply}", b"before") ) self.assertFalse(bot._raw_text_anchor_usable(f"你好 {reply}", reply)) def test_an_empty_baseline_never_unlocks_the_raw_anchor(self): """完全读不到聊天内容时不能放行,否则等于取消了重复发送保护。""" bot = WeChatBot.__new__(WeChatBot) self.assertFalse(bot._raw_text_anchor_usable("", "你好呀")) self.assertFalse(bot._raw_text_anchor_usable("你好", "")) def test_the_raw_anchor_survives_a_restart(self): """重启后还要能核对回执,否则任务只能挂成待核对、客户白等。""" bot = WeChatBot.__new__(WeChatBot) handle, path = tempfile.mkstemp(suffix=".json") os.close(handle) self.addCleanup(lambda: os.path.exists(path) and os.unlink(path)) fp = bytes.fromhex("aa" * 40) bot._pending_reply_path = path bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "send_state": "sent_uncommitted", "send_baseline_text": "你好", "created_at": time.time(), "updated_at": time.time(), } } self.assertTrue(bot._persist_pending_replies()) restored = WeChatBot._load_pending_replies(bot) self.assertEqual(restored[fp.hex()]["send_baseline_text"], "你好") def test_block_anchors_keep_their_own_proven_path(self): """有消息块时必须走原来的路径,裸文本只在没有块时兜底。""" bot = WeChatBot.__new__(WeChatBot) called = [] bot._raw_text_receipt_matches = lambda *a, **k: called.append(a) or True bot._visible_last_message_matches = lambda *a, **k: None result = bot._send_receipt_matches( reply_text="你好呀", customer_speaker="", before_surface=b"before", before_blocks=[["高瑞 7/31 12:00:00", "你好"]], before_text="你好", ) self.assertEqual(called, []) self.assertIsNone(result) # ── 机器人自己遗留的草稿,不能当成人工草稿一路让路 ────────────────────── # 现场(2026-07-31 12:11):上一轮贴好草稿后被中途打断,草稿留在输入框里。 # 下一轮机器人认不出这是自己写的,判成人工草稿取消发送,这个会话从此 # 每轮都调一次模型、又每轮都让路,客户永远等不到回复。 def test_our_own_abandoned_draft_is_cleared_and_resent(self): bot = WeChatBot.__new__(WeChatBot) reply = "在呢,您有什么事就直接说,我这边看着呢" # 调用方传进来的永远是归一化后的草稿(全角标点已折半),这里照做 draft = bot._normalized_message_text(reply) state = {"staged_reply_text": reply, "send_state": ""} self.assertTrue(bot._draft_is_our_own(draft, state, reply)) def test_last_round_wording_is_still_recognised_as_ours(self): """模型每轮换一种说法,只比对新回复就永远认不出框里那句是自己写的。""" bot = WeChatBot.__new__(WeChatBot) stale = "在呢,您有什么事慢慢说,我这边听着呢" fresh = "在呢,您说,我这边听着" state = { "staged_reply_text": fresh, "last_pasted_draft": bot._normalized_message_text(stale), "send_state": "", } draft = bot._normalized_message_text(stale) self.assertTrue(bot._draft_is_our_own(draft, state, fresh)) def test_a_human_sentence_still_stops_the_bot_cold(self): bot = WeChatBot.__new__(WeChatBot) reply = "在呢,您有什么事就直接说" state = {"staged_reply_text": reply, "send_state": ""} draft = bot._normalized_message_text("我自己来回这条") self.assertFalse(bot._draft_is_our_own(draft, state, reply)) def test_a_dispatched_transaction_keeps_its_draft_untouched(self): """已经按过回车的事务归回执核对管,这里清掉再发就是重复发送。""" bot = WeChatBot.__new__(WeChatBot) reply = "在呢,您有什么事就直接说" draft = bot._normalized_message_text(reply) state = {"staged_reply_text": reply, "send_state": "sent_uncommitted"} self.assertFalse(bot._draft_is_our_own(draft, state, reply)) def test_a_session_without_a_task_never_gets_its_draft_wiped(self): bot = WeChatBot.__new__(WeChatBot) self.assertFalse(bot._draft_is_our_own("随便什么字", {}, "机器人的回复")) self.assertFalse(bot._draft_is_our_own("随便什么字", None, "机器人的回复")) # ── 只有一两条消息的新会话,框选起点必须落在真正有字的地方 ────────────── # 现场(2026-07-31 11:51):新客户只发了一句“在不在”,气泡贴在聊天区最顶 # 上,其余全是空白。两条固定边距的拖拽路径都按在空白上,锚不到文字, # “两次框选均为空”。复制不到就只能走视觉,视觉文本没有“发言人+时间”表头, # 发送前锚点建不起来,于是不停调模型又不停取消,客户永远等不到回复。 def _ink_bot(self, chat_image): bot = WeChatBot.__new__(WeChatBot) bot.L, bot.T = 100, 200 bot.hwnd = 1 bot.scale = 1.0 return bot, mock.patch.object( wechat_bot, "capture_window_region", return_value=chat_image, ) @staticmethod def _sparse_chat_image(height=800, width=600, bubble_bottom=60): """顶部一条气泡,其余全是背景色——新会话的真实样子。""" img = np.full((height, width, 4), 246, dtype=np.uint8) img[10:bubble_bottom, 40:160, :3] = 40 return img def test_the_lone_bubble_at_the_top_is_found(self): image = self._sparse_chat_image() bot, patcher = self._ink_bot(image) with patcher: bounds = bot._chat_ink_bounds(100, 200, 600, 800) self.assertIsNotNone(bounds) ink_top, ink_bottom = bounds # 屏幕坐标 = 区域左上角 + 墨迹行号 self.assertEqual(ink_top, 200 + 10) self.assertEqual(ink_bottom, 200 + 59) def test_an_empty_chat_reports_no_ink_instead_of_guessing(self): image = np.full((800, 600, 4), 246, dtype=np.uint8) bot, patcher = self._ink_bot(image) with patcher: self.assertIsNone(bot._chat_ink_bounds(100, 200, 600, 800)) def test_the_scrollbar_gutter_never_counts_as_a_message(self): """右缘滚动条会随鼠标淡入淡出,算进墨迹就会把范围撑满整个面板。""" image = np.full((800, 600, 4), 246, dtype=np.uint8) image[:, -6:, :3] = 120 bot, patcher = self._ink_bot(image) with patcher: self.assertIsNone(bot._chat_ink_bounds(100, 200, 600, 800)) def test_a_sparse_chat_tries_the_ink_anchored_drag_first(self): """消息稀疏时先走墨迹路径,省下两次注定选空的拖拽。""" bot, patcher = self._ink_bot(self._sparse_chat_image()) attempts = [] bot._drag_select = lambda x1, y1, x2, y2, steps=12: attempts.append((y1, y2)) bot._copy_selection = lambda: "" with patcher: bot._select_visible_chat(100, 200, 600, 800, 20, 230) # 起点仍被钳在 top+margin:那道边距是防止拖到聊天区外触发历史翻页。 # 气泡跨 210~259,钳到 220 依然落在字上,锚得住 self.assertEqual(attempts[0], (200 + 20, 200 + 57)) def test_a_full_chat_keeps_the_proven_path_first(self): """消息占满面板时不改动既有顺序,墨迹路径只当兜底。""" image = np.full((800, 600, 4), 246, dtype=np.uint8) image[10:790, 40:560, :3] = 40 bot, patcher = self._ink_bot(image) attempts = [] bot._drag_select = lambda x1, y1, x2, y2, steps=12: attempts.append((y1, y2)) bot._copy_selection = lambda: "" with patcher: bot._select_visible_chat(100, 200, 600, 800, 20, 230) self.assertEqual(attempts[0], (200 + 800 - 20, 230)) self.assertEqual(len(attempts), 3) def test_a_working_first_drag_never_costs_a_second_one(self): bot, patcher = self._ink_bot(self._sparse_chat_image()) attempts = [] bot._drag_select = lambda x1, y1, x2, y2, steps=12: attempts.append((y1, y2)) bot._copy_selection = lambda: "高瑞@微信 7/31 11:51:00\n在不在" with patcher: lines = bot._select_visible_chat(100, 200, 600, 800, 20, 230) self.assertEqual(len(attempts), 1) self.assertEqual(lines, ["高瑞@微信 7/31 11:51:00", "在不在"]) def test_a_recovered_copy_rebuilds_the_missing_send_anchor(self): """带表头的复制文本必须能解析出锚点——这正是发送被卡住的那一步。""" bot = WeChatBot.__new__(WeChatBot) self.assertEqual(bot._normalized_copied_blocks("在不在"), []) blocks = bot._normalized_copied_blocks( "高瑞@微信@微信联系人 7/31 11:51:00\n在不在" ) self.assertEqual(len(blocks), 1) self.assertEqual(blocks[0][1], "在不在") def test_a_composed_reply_waits_out_the_last_seconds_of_spacing(self): """回复已经生成就不能因为还差几秒发送间隔被丢掉,下一轮要从头再调模型。""" bot = WeChatBot.__new__(WeChatBot) remaining = [1.0, 1.0, 0.0] bot._send_gate_open = lambda: False bot._send_gate_remaining = lambda: remaining.pop(0) if remaining else 0.0 self.assertTrue(bot._await_send_gate()) def test_a_long_rate_limit_still_leaves_the_task_for_later(self): """分钟/小时配额用尽时不能原地干等,必须放行让轮询继续跑。""" bot = WeChatBot.__new__(WeChatBot) bot._send_gate_open = lambda: False bot._send_gate_remaining = lambda: bot._SEND_GATE_WAIT_BUDGET_SECONDS + 30.0 self.assertFalse(bot._await_send_gate()) def _hold_bot(self, active_fp: bytes, pending: dict): bot = WeChatBot.__new__(WeChatBot) bot._active_session_fp = active_fp bot._pending_reply_sessions = pending bot._active_hold_key = "" bot._active_hold_since = 0.0 bot._active_hold_expired = False return bot def test_an_unfinished_session_keeps_the_round_to_itself(self): """当前会话没回完,本轮不能掉进未读扫描去点开别人。""" fp = bytes.fromhex("aa" * 40) bot = self._hold_bot(fp, {fp.hex(): {"confirmed_unread": True}}) self.assertTrue(bot._hold_for_unfinished_active_session()) self.assertTrue(bot._hold_for_unfinished_active_session()) def test_a_finished_session_releases_the_round_at_once(self): """回完就立刻放行,排队的会话不该多等一轮。""" fp = bytes.fromhex("aa" * 40) pending = {fp.hex(): {"confirmed_unread": True}} bot = self._hold_bot(fp, pending) self.assertTrue(bot._hold_for_unfinished_active_session()) pending.clear() self.assertFalse(bot._hold_for_unfinished_active_session()) def test_a_stuck_session_stops_blocking_everyone_else(self): """真卡住的会话必须到点放行,否则后面排队的客户一起陪葬。""" fp = bytes.fromhex("aa" * 40) bot = self._hold_bot(fp, {fp.hex(): {"confirmed_unread": True}}) self.assertTrue(bot._hold_for_unfinished_active_session()) bot._active_hold_since -= bot._ACTIVE_SESSION_HOLD_SECONDS + 1.0 self.assertFalse(bot._hold_for_unfinished_active_session()) # 放行之后不能再反复压住:同一个会话不该每 60 秒又独占一次 self.assertFalse(bot._hold_for_unfinished_active_session()) def test_switching_sessions_starts_a_fresh_hold(self): """换到下一个会话时重新计时,不继承上一个会话烧掉的额度。""" first = bytes.fromhex("aa" * 40) second = bytes.fromhex("bb" * 40) pending = {first.hex(): {}, second.hex(): {}} bot = self._hold_bot(first, pending) self.assertTrue(bot._hold_for_unfinished_active_session()) bot._active_hold_since -= bot._ACTIVE_SESSION_HOLD_SECONDS + 1.0 self.assertFalse(bot._hold_for_unfinished_active_session()) bot._active_session_fp = second self.assertTrue(bot._hold_for_unfinished_active_session()) # ── 订阅号/系统号没有输入框,不能一直占着回复队列 ────────────────────── # 现场(2026-07-31 11:04):“行业资讯”订阅号的聊天页没有输入框,机器人每轮 # 都把它重新打开、调一次视觉模型,再卡在“无法确认消息区与输入区分隔线”上。 # 队列里 7 条待回复有 3 条是这种永远完不成的任务,真客户被挤到后面。 def _composer_bot(self): bot = WeChatBot.__new__(WeChatBot) handle, path = tempfile.mkstemp(suffix=".json") os.close(handle) os.unlink(path) self.addCleanup( lambda: os.path.exists(path) and os.unlink(path) ) bot._unrepliable_path = path bot._unrepliable_sessions = {} bot._composerless_strikes = {} bot._pending_reply_path = "" bot._pending_reply_sessions = {} bot._pending_scan_progress = {} bot._pending_exchanges = {} return bot def test_a_page_without_a_composer_leaves_the_reply_queue(self): bot = self._composer_bot() fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME bot._pending_reply_sessions[fp.hex()] = {"confirmed_unread": True} bot._composer_geometry_valid = False for _ in range(bot._COMPOSERLESS_STRIKES): self.assertFalse(bot._session_accepts_replies(fp)) # 判定之后:任务出队、后续轮次直接跳过,不再重复调用模型 self.assertNotIn(fp.hex(), bot._pending_reply_sessions) self.assertTrue(bot._session_is_unrepliable(fp)) self.assertFalse(bot._session_accepts_replies(fp)) def test_one_bad_frame_never_silences_a_real_contact(self): """单帧没渲染完不能把真客户静默移出队列。""" bot = self._composer_bot() fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME bot._composer_geometry_valid = False self.assertFalse(bot._session_accepts_replies(fp)) bot._composer_geometry_valid = True self.assertTrue(bot._session_accepts_replies(fp)) # 计数已清零:再连续失败仍需攒满整轮才判定 bot._composer_geometry_valid = False for _ in range(bot._COMPOSERLESS_STRIKES - 1): self.assertFalse(bot._session_accepts_replies(fp)) self.assertFalse(bot._session_is_unrepliable(fp)) def test_the_filter_rechecks_itself_after_the_cooldown(self): """企微改版或误判时必须能自己走回来。""" bot = self._composer_bot() fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME bot._unrepliable_sessions[fp.hex()] = ( time.time() - bot._UNREPLIABLE_RECHECK_SECONDS - 1.0 ) self.assertFalse(bot._session_is_unrepliable(fp)) bot._composer_geometry_valid = True self.assertTrue(bot._session_accepts_replies(fp)) def test_cross_monitor_window_is_detected_even_when_its_center_monitor_is_valid(self): """The field failure spans a 200% laptop and a 100% external panel.""" monitors = [ (0, 0, 1536, 960), (3072, 0, 4992, 1080), ] # Exact geometry from the 2026-08-18 field log. Looking only at the # centre/nearest monitor missed this split and left the composer offset. self.assertTrue( window_spans_multiple_displays( (1387, 474, 4359, 1774), monitors, ) ) # In a per-monitor-aware Python process the same physical arrangement # exposes doubled monitor coordinates, while virtualized WeCom keeps # reporting the rectangle above. Its external-screen half therefore # lands in a numeric gap and must still be treated as unsafe. self.assertTrue( window_spans_multiple_displays( (1387, 474, 4359, 1774), [(0, 0, 3072, 1920), (6144, 0, 9984, 2160)], ) ) self.assertFalse( window_spans_multiple_displays( (40, 20, 1496, 912), monitors, ) ) def test_cross_monitor_window_is_resized_entirely_inside_primary_work_area(self): x, y, width, height = fit_window_inside_work_area( (1387, 474, 4359, 1774), (0, 0, 1536, 912), ) self.assertGreaterEqual(x, 0) self.assertGreaterEqual(y, 0) self.assertLessEqual(x + width, 1536) self.assertLessEqual(y + height, 912) self.assertGreaterEqual(width, 640) self.assertGreaterEqual(height, 480) def test_open_customer_recaptures_geometry_before_composer_filter(self): bot = self._composer_bot() bot.hwnd = 1 fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME bot._composer_geometry_valid = False bot._input_geometry_valid = False bot.report_operation = mock.Mock() bot._capture_full_window = mock.Mock( return_value=np.zeros((720, 1280, 4), dtype=np.uint8) ) def recover(_full): bot._composer_geometry_valid = True bot._input_geometry_valid = True return True bot._refresh_message_geometry = mock.Mock(side_effect=recover) bot._maximize_window_for_composer = mock.Mock(return_value=False) bot._run_ai_layout_guard = mock.Mock(return_value=False) self.assertTrue(bot._session_accepts_replies(fp)) bot._capture_full_window.assert_called_once() bot._refresh_message_geometry.assert_called_once() bot._run_ai_layout_guard.assert_not_called() self.assertNotIn(fp.hex(), bot._composerless_strikes) def test_visual_model_link_error_preserves_real_customer_task(self): bot = self._composer_bot() bot.hwnd = 1 fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME bot._pending_reply_sessions[fp.hex()] = {"confirmed_unread": True} bot._composer_geometry_valid = False bot._input_geometry_valid = False bot.report_operation = mock.Mock() bot._capture_full_window = mock.Mock( return_value=np.zeros((720, 1280, 4), dtype=np.uint8) ) bot._refresh_message_geometry = mock.Mock(return_value=False) bot._maximize_window_for_composer = mock.Mock(return_value=False) def link_error(*_args, **_kwargs): bot._last_layout_guard_outcome = "link_error" return False bot._run_ai_layout_guard = mock.Mock(side_effect=link_error) for _ in range(bot._COMPOSERLESS_STRIKES + 2): self.assertFalse(bot._session_accepts_replies(fp)) self.assertIn(fp.hex(), bot._pending_reply_sessions) self.assertNotIn(fp.hex(), bot._composerless_strikes) self.assertFalse(bot._session_is_unrepliable(fp)) def test_a_far_avatar_under_the_same_name_is_still_a_different_row(self): """名称相同也不能无条件放行;头像彻底不同仍必须拒绝。""" bot = self._fp_bot() near = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME far = bytes.fromhex("ffffffffffffffff") + self._DRIFT_NAME self.assertFalse(bot._session_fp_matches(near, far)) def test_close_avatars_with_different_names_never_match(self): """现场最接近的一对不同联系人(头像仅差 10 位)必须继续被分开。""" bot = self._fp_bot() left = bytes.fromhex("18181810151d1f1f") + bytes.fromhex("017b0163") + b"\x5a" * 28 right = self._DRIFT_AVATAR_AFTER + self._DRIFT_NAME avatar_distance = ( int.from_bytes(left[:8], "big") ^ int.from_bytes(right[:8], "big") ).bit_count() self.assertLessEqual(avatar_distance, bot._FP_HAMMING_TOL_NAMED) self.assertFalse(bot._session_fp_matches(left, right)) def test_a_blank_name_hash_falls_back_to_the_strict_avatar_tolerance(self): """名称采不到字时它什么都证明不了,头像必须回到严格容差。""" bot = self._fp_bot() blank = b"\x00" * 32 left = self._DRIFT_AVATAR_BEFORE + blank right = self._DRIFT_AVATAR_AFTER + blank self.assertFalse(bot._name_fp_is_substantive(blank)) self.assertFalse(bot._session_fp_matches(left, right)) # 同样一片空白的名称下,头像足够接近时仍然照旧放行。 near = bytes( self._DRIFT_AVATAR_BEFORE[:7] + bytes([self._DRIFT_AVATAR_BEFORE[7] ^ 0b11]) ) + blank self.assertTrue(bot._session_fp_matches(left, near)) def test_calibration_gate_keeps_warning_instead_of_going_silent(self): """闸门关闭 = 自动回复完全停摆,绝不能只吭一声就静默。""" bot = WeChatBot.__new__(WeChatBot) bot._nav_width_confirmed = False bot._nav_width_gate_logged = 0.0 bot._nav_width_gate_since = 0.0 def gate_output(now): buffer = io.StringIO() with mock.patch("wechat_bot.time.monotonic", return_value=now): with redirect_stdout(buffer): self.assertFalse(bot._session_identity_trustworthy()) return buffer.getvalue() self.assertIn("自动回复已暂停", gate_output(1000.0)) # 紧接着的轮次不刷屏。 self.assertEqual(gate_output(1005.0), "") # 但停摆持续下去必须继续告警,并报出已经停了多久。 later = gate_output(1040.0) self.assertIn("自动回复已暂停 40 秒", later) bot._nav_width_confirmed = True self.assertTrue(bot._session_identity_trustworthy()) # 恢复后重新计时,下次停摆立刻重新告警。 bot._nav_width_confirmed = False self.assertIn("自动回复已暂停 0 秒", gate_output(2000.0)) def test_low_confidence_frame_never_revokes_a_confirmed_navigation_width(self): """置信度不足时沿用已生效宽度,因此不能撤销既有的确认结论。""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot.L = bot.T = 0 bot.R, bot.B = 1200, 800 bot._list_x = 320 bot._composer_rel_top = 600 bot._composer_geometry_valid = True bot._nav_width_confirmed = True full = np.zeros((800, 1200, 4), dtype=np.uint8) with mock.patch( "wechat_bot.infer_navigation_width", return_value=(68, 0.0), ), mock.patch( "wechat_bot.infer_composer_top", return_value=600, ): bot._refresh_message_geometry(full) self.assertEqual(bot._list_x, 320) self.assertTrue(bot._nav_width_confirmed) def test_navigation_geometry_is_committed_before_a_chat_composer_exists(self): """消息首页没有选中会话时也必须先校准列表,才能找到并打开未读客户。""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L = bot.T = 0 bot.R, bot.B = 2200, 1300 bot._list_x = 136 bot._nav_width_confirmed = False bot._selected_tracking_initialized = True bot._active_session_fp = b"old" bot._active_chat_signature = b"old" bot._active_identity_signature = b"old" full = np.zeros((1300, 2200, 4), dtype=np.uint8) with mock.patch( "wechat_bot.infer_navigation_width", return_value=(320, 0.9), ), mock.patch( "wechat_bot.infer_composer_top", return_value=None, ): self.assertTrue(bot._refresh_message_geometry(full)) self.assertEqual(bot._list_x, 320) self.assertEqual(bot.list_region["left"], 320) self.assertTrue(bot._nav_width_confirmed) self.assertTrue(bot._session_geometry_valid) self.assertFalse(bot._composer_geometry_valid) self.assertFalse(bot._selected_tracking_initialized) def test_semantic_layout_hint_still_requires_local_pixel_boundaries(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L = bot.T = 0 bot.R, bot.B = 2200, 1300 bot._list_x = 136 bot._list_w = 460 bot._nav_width_confirmed = False bot._selected_tracking_initialized = False bot._active_session_fp = None bot._active_chat_signature = None bot._active_identity_signature = None full = np.full((1300, 2200, 4), 245, dtype=np.uint8) full[:, :, 3] = 255 full[:, :320, :3] = 225 full[:, 320:820, :3] = 248 full[:, 820:, :3] = 238 hint = { "navigation_right_ratio": 320 / 2200, "session_list_right_ratio": 820 / 2200, "composer_top_ratio": 1000 / 1300, } with ( mock.patch("wechat_bot.infer_navigation_width", return_value=(136, 0.0)), mock.patch("wechat_bot.infer_session_list_width", return_value=(460, 0.0)), mock.patch("wechat_bot.infer_composer_top", return_value=None), mock.patch("wechat_bot.composer_divider_matches", return_value=(True, 1000)), ): self.assertTrue(bot._refresh_message_geometry(full, semantic_hint=hint)) self.assertEqual(bot._list_x, 320) self.assertEqual(bot._list_w, 500) self.assertEqual(bot._composer_rel_top, 1000) self.assertTrue(bot._nav_width_confirmed) self.assertTrue(bot._composer_geometry_valid) def test_avatar_anchor_keeps_the_caller_estimate_without_a_lattice(self): """看不出行距时宁可沿用调用方的估算,不能凭单个色块乱吸附。""" bot = self._fingerprint_bot() blank = np.full((320, 460, 4), 245, dtype=np.uint8) blank[:, :, 3] = 255 self.assertEqual(bot._avatar_anchor(blank, 137), 137) single = blank.copy() for y in range(24, 104): for x in range(20, 88): single[y, x, :3] = ((x * 3) % 255, (y * 5) % 255, (x + y) % 255) self.assertEqual(bot._avatar_anchor(single, 137), 137) def test_unread_badge_disappearing_does_not_change_flat_contact_key(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() base = np.full((160, 460, 4), 245, dtype=np.uint8) base[:, :, 3] = 255 # 纯色文字头像 + 两个深色名称字形块。 base[20:108, 22:108, :3] = (220, 120, 30) base[42:82, 56:70, :3] = 250 base[26:56, 122:150, :3] = 25 base[30:52, 164:194, :3] = 25 unread = base.copy() yy, xx = np.ogrid[:unread.shape[0], :unread.shape[1]] # 多位数未读徽章会越过头像右边缘,覆盖名称第一字左上/下缘。 badge = (xx - 116) ** 2 + (yy - 32) ** 2 <= 20 ** 2 unread[badge, :3] = (55, 55, 245) unread[26:39, 107:125, :3] = 250 selected = base.copy() selected[:, -24:, :3] = (238, 126, 36) before = bot._session_fingerprint(unread, 32) after = bot._session_fingerprint(selected, 64, row_center=True) self.assertEqual(before, after) def test_same_avatar_with_different_row_names_gets_distinct_session_keys(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() first = np.full((160, 460, 4), 245, dtype=np.uint8) second = first.copy() first[:, :, 3] = 255 second[:, :, 3] = 255 # 完全相同的头像。 for image in (first, second): for y in range(40, 88): for x in range(20, 84): image[y, x, :3] = ((x * 3) % 255, (y * 5) % 255, (x + y) % 255) # 名称首行字形位置不同,模拟两个使用相同头像的联系人。 first[26:56, 122:158, :3] = 25 first[32:50, 174:210, :3] = 25 second[26:56, 226:262, :3] = 25 second[32:50, 278:314, :3] = 25 first_fp = bot._session_fingerprint(first, 64, row_center=True) second_fp = bot._session_fingerprint(second, 64, row_center=True) self.assertEqual(len(first_fp), 40) self.assertEqual(first_fp[:8], second_fp[:8]) self.assertNotEqual(first_fp, second_fp) def test_same_avatar_short_names_with_different_first_character_stay_distinct(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() first = np.full((160, 460, 4), 245, dtype=np.uint8) second = first.copy() first[:, :, 3] = 255 second[:, :, 3] = 255 for image in (first, second): image[40:88, 20:84, :3] = 90 # 相同的第二个字。 image[28:55, 174:210, :3] = 20 # 仅首字底部字形不同,覆盖名称前缀安全采样条。 first[48:55, 126:142, :3] = 20 first[51:58, 146:158, :3] = 20 second[51:58, 126:142, :3] = 20 second[48:55, 146:158, :3] = 20 first_fp = bot._session_fingerprint(first, 64, row_center=True) second_fp = bot._session_fingerprint(second, 64, row_center=True) self.assertNotEqual(first_fp, second_fp) self.assertFalse(bot._remember_live_render_alias(first_fp, second_fp)) def test_same_name_is_stable_between_normal_and_selected_row_colors(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() normal = np.full((160, 460, 4), 245, dtype=np.uint8) selected = np.full((160, 460, 4), (210, 130, 45, 255), dtype=np.uint8) normal[:, :, 3] = 255 for image in (normal, selected): for y in range(40, 88): for x in range(20, 84): image[y, x, :3] = ((x * 3) % 255, (y * 5) % 255, (x + y) % 255) normal[28:54, 124:154, :3] = 20 normal[30:56, 170:204, :3] = 20 selected[28:54, 124:154, :3] = 250 selected[30:56, 170:204, :3] = 250 normal_fp = bot._session_fingerprint(normal, 64, row_center=True) selected_fp = bot._session_fingerprint(selected, 64, row_center=True) self.assertEqual(normal_fp, selected_fp) def test_persisted_name_hash_keeps_established_40_byte_key_layout(self): """Do not silently reinterpret existing conversations.json keys.""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 image = np.full((160, 460, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 image[28:54, 124:154, :3] = 20 image[30:56, 170:204, :3] = 20 self.assertEqual( bot._session_name_fingerprint(image, 64, row_center=True).hex(), "7ff07ff0000000000000007c00007c00007c00007c00007c00007c00007c0000", ) def test_unread_bold_name_and_selected_regular_name_keep_same_identity(self): """WeCom removes unread bold weight when the row becomes selected.""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() unread = np.full((160, 460, 4), 245, dtype=np.uint8) selected = np.full((160, 460, 4), (210, 130, 45, 255), dtype=np.uint8) unread[:, :, 3] = 255 for image in (unread, selected): for y in range(40, 88): for x in range(20, 84): image[y, x, :3] = ( (x * 3) % 255, (y * 5) % 255, (x + y) % 255, ) # Same glyph centre-lines. The unread rendering is five pixels wider; # the selected rendering uses the normal three-pixel font weight. unread[27:56, 178:187, :3] = 20 unread[34:44, 168:204, :3] = 20 unread[28:56, 224:233, :3] = 20 unread[45:55, 216:246, :3] = 20 selected[29:54, 181:184, :3] = 250 selected[37:40, 171:201, :3] = 250 selected[30:54, 227:230, :3] = 250 selected[48:51, 219:243, :3] = 250 unread_fp = bot._session_fingerprint(unread, 64, row_center=True) selected_fp = bot._session_fingerprint(selected, 64, row_center=True) distance = ( int.from_bytes(unread_fp[8:], "big") ^ int.from_bytes(selected_fp[8:], "big") ).bit_count() # The established 40-byte archive key must not be redefined merely to # absorb a font-weight change. The immediately observed unread and # selected rows are linked by a process-local render proof instead. self.assertTrue( bot._remember_live_render_alias(unread_fp, selected_fp), ( f"name distance={distance}, " f"unread_render={bot._live_render_ids_for(unread_fp)}, " f"selected_render={bot._live_render_ids_for(selected_fp)}" ), ) self.assertTrue(bot._session_fp_matches(unread_fp, selected_fp)) def test_unread_badge_and_dynamic_time_do_not_change_session_identity(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() unread = np.full((160, 460, 4), 245, dtype=np.uint8) selected = np.full((160, 460, 4), (210, 130, 45, 255), dtype=np.uint8) unread[:, :, 3] = 255 for image in (unread, selected): image[40:88, 20:84, :3] = 90 # 名称位于固定名称带;未读红点在头像右上,右侧时间每天都会变化。 unread[28:55, 170:220, :3] = 20 selected[28:55, 170:220, :3] = 250 unread[18:48, 70:146, :3] = (35, 45, 235) unread[30:50, 350:420, :3] = 80 selected[30:50, 340:430, :3] = 250 self.assertEqual( bot._session_fingerprint(unread, 64, row_center=True), bot._session_fingerprint(selected, 64, row_center=True), ) def test_typing_status_below_title_does_not_change_chat_identity(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot.scale = 2.0 bot.L, bot.R = 0, 1800 bot._list_x = 136 bot._list_w = 460 idle = np.full((112, 1000, 4), 245, dtype=np.uint8) typing = idle.copy() idle[:, :, 3] = 255 typing[:, :, 3] = 255 idle[22:54, 50:210, :3] = 30 typing[22:54, 50:210, :3] = 30 typing[76:100, 50:190, :3] = 80 with mock.patch( "wechat_bot.capture_window_region", side_effect=[idle, typing], ): self.assertEqual( bot._chat_identity_signature(), bot._chat_identity_signature(), ) def test_same_avatar_long_common_name_prefix_keeps_distinct_suffixes(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() first = np.full((160, 460, 4), 245, dtype=np.uint8) second = first.copy() first[:, :, 3] = 255 second[:, :, 3] = 255 for image in (first, second): image[40:88, 20:84, :3] = 90 # 相同的长公共前缀。 image[28:54, 122:282, :3] = 25 # 名称末尾不同,且都位于动态时间左侧。 first[26:38, 296:326, :3] = 25 first[44:56, 330:360, :3] = 25 second[44:56, 296:326, :3] = 25 second[26:38, 330:360, :3] = 25 first_fp = bot._session_fingerprint(first, 64, row_center=True) second_fp = bot._session_fingerprint(second, 64, row_center=True) self.assertNotEqual(first_fp, second_fp) def test_same_avatar_names_differing_at_last_visible_character_stay_distinct(self): """The final name glyph before the time column is part of session identity.""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._known_fps = set() bot._known_session_fps = set() first = np.full((160, 460, 4), 245, dtype=np.uint8) second = first.copy() first[:, :, 3] = 255 second[:, :, 3] = 255 for image in (first, second): image[40:88, 20:84, :3] = 90 # Identical long visible prefix through logical x=160. image[28:54, 122:320, :3] = 25 # The last visible glyph lives before the x=170 time-column boundary. first[28:38, 322:338, :3] = 25 second[44:54, 322:338, :3] = 25 first_fp = bot._session_fingerprint(first, 64, row_center=True) second_fp = bot._session_fingerprint(second, 64, row_center=True) self.assertNotEqual(first_fp, second_fp) self.assertFalse(bot._remember_live_render_alias(first_fp, second_fp)) def test_mixed_legacy_and_composite_keys_never_match_by_avatar_only(self): bot = WeChatBot.__new__(WeChatBot) self.assertFalse( bot._session_fp_matches(b"a" * 8, b"a" * 8 + b"b" * 32) ) def test_legacy_archive_is_moved_once_after_composite_identity_is_known(self): bot = WeChatBot.__new__(WeChatBot) bot.store = mock.Mock() bot.store.entry_snapshot.side_effect = [ None, { "last_lines": [ "高兴亮 7/28 08:59:00", "已回复", "客户甲 7/28 09:00:00", "旧问题", ], "history": [ {"role": "assistant", "content": "已回复"}, { "role": "user", "content": ( "高兴亮 7/28 08:59:00\n已回复\n" "客户甲 7/28 09:00:00\n旧问题" ), }, ], }, ] bot.store.migrate_key.return_value = True bot._legacy_migration_checked = set() fp = b"avatar01" + b"n" * 32 chat_text = ( "高兴亮 7/29 09:59:00\n已回复\n" "客户甲 7/29 10:00:00\n新问题" ) with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): bot._ensure_session_archive_key(fp, chat_text) bot._ensure_session_archive_key(fp, chat_text) bot.store.migrate_key.assert_called_once_with( b"avatar01".hex(), fp.hex(), ) def test_previous_16_byte_archive_is_preferred_over_avatar_only_archive(self): bot = WeChatBot.__new__(WeChatBot) avatar = b"avatar01" old16 = avatar + b"oldname1" current = avatar + b"N" * 32 legacy_entry = { "last_lines": [ "客户甲 7/28 09:00:00", "旧问题", "高兴亮 7/28 09:01:00", "已回复", ], "history": [ {"role": "user", "content": "旧问题"}, {"role": "assistant", "content": "已回复"}, ], } bot.store = mock.Mock() bot.store.keys.return_value = [avatar.hex(), old16.hex()] bot.store.entry_snapshot.side_effect = [ None, legacy_entry, legacy_entry, ] bot.store.migrate_key.return_value = True bot._legacy_fp_for_current = {current.hex(): old16} bot._legacy_migration_checked = set() bot._known_session_fps = set() bot._pending_reply_sessions = {} bot._chat_identity_signature = mock.Mock(return_value=b"") chat_text = ( "高兴亮 7/29 09:59:00\n已回复\n" "客户甲 7/29 10:00:00\n新问题" ) with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): bot._ensure_session_archive_key(current, chat_text) bot.store.migrate_key.assert_called_once_with(old16.hex(), current.hex()) def test_previous_16_byte_pending_is_atomically_rekeyed_after_title_proof(self): bot = WeChatBot.__new__(WeChatBot) avatar = b"avatar01" old16 = avatar + b"oldname1" current = avatar + b"C" * 32 state = { "batch_ready": True, "confirmed_unread": True, "chat_text": "客户甲 10:00:00\n待回复内容", "identity_signature": b"stable-title", } bot.store = mock.Mock() bot.store.keys.return_value = [] bot.store.entry_snapshot.return_value = None bot._legacy_fp_for_current = {current.hex(): old16} bot._legacy_migration_checked = set() bot._known_session_fps = set() bot._pending_reply_sessions = {old16.hex(): state} bot._pending_reply_path = "" bot._chat_identity_signature = mock.Mock(return_value=b"stable-title") bot._ensure_session_archive_key( current, "客户甲 10:00:00\n待回复内容", ) self.assertNotIn(old16.hex(), bot._pending_reply_sessions) self.assertIs(bot._pending_reply_sessions[current.hex()], state) self.assertTrue(state["confirmed_unread"]) def test_legacy_archive_is_not_migrated_when_customer_name_does_not_match(self): bot = WeChatBot.__new__(WeChatBot) bot.store = mock.Mock() bot.store.entry_snapshot.side_effect = [ None, {"last_lines": ["客户甲 7/28 09:00:00", "旧问题"]}, ] bot._legacy_migration_checked = set() fp = b"avatar01" + b"m" * 32 bot._ensure_session_archive_key( fp, "客户乙 7/29 10:00:00\n新问题", ) bot.store.migrate_key.assert_not_called() def test_legacy_archive_with_another_customer_in_history_is_never_migrated(self): bot = WeChatBot.__new__(WeChatBot) bot.store = mock.Mock() bot.store.entry_snapshot.side_effect = [ None, { "last_lines": ["客户乙 7/28 09:00:00", "乙的问题"], "history": [ { "role": "user", "content": "客户甲 7/27 09:00:00\n甲的问题", } ], }, ] bot._legacy_migration_checked = set() fp = b"avatar01" + b"q" * 32 bot._ensure_session_archive_key(fp, "客户乙 7/29 10:00:00\n新问题") bot.store.migrate_key.assert_not_called() def test_date_bearing_agent_header_is_not_treated_as_customer_message(self): bot = WeChatBot.__new__(WeChatBot) bot.store = mock.Mock() bot.store.history.return_value = [ {"role": "assistant", "content": "更早的一条回复"} ] text = ( "客户甲 7/29 10:52:00\n上一条问题\n" "高兴亮 7/29 10:53:23\n这是我方刚发出的回复" ) with mock.patch("ai_config.AI_AGENT_NAME", "高兴亮"): self.assertFalse(bot._has_pending_customer_message(text, b"session")) def test_actual_staff_name_is_learned_when_configured_nickname_differs(self): bot = WeChatBot.__new__(WeChatBot) bot.store = mock.Mock() bot.store.history.return_value = [ {"role": "assistant", "content": "历史我方回复"} ] bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True) text = ( "高兴亮 7/29 10:50:00\n历史我方回复\n" "高兴亮 7/29 10:53:23\n这是人工刚发出的新回复" ) with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertFalse(bot._has_pending_customer_message(text, b"session")) def test_customer_echoing_an_old_assistant_phrase_is_still_pending(self): bot = WeChatBot.__new__(WeChatBot) bot.store = mock.Mock() bot.store.history.return_value = [ {"role": "assistant", "content": "好的"} ] bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False) text = "客户甲 10:00:00\n好的" with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertTrue(bot._has_pending_customer_message(text, b"session")) self.assertNotIn( "客户甲", getattr(bot, "_known_agent_speakers", set()), ) def test_right_side_unselectable_media_does_not_relabel_last_copied_customer(self): bot = WeChatBot.__new__(WeChatBot) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True) text = "客户甲 10:00:00\n请看上一条,后面还有一张图片" with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertFalse(bot._has_pending_customer_message(text, b"session")) self.assertNotIn( "客户甲", getattr(bot, "_known_agent_speakers", set()), ) def test_message_page_refreshes_geometry_after_wide_sidebar_opens(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L, bot.T, bot.R, bot.B = 100, 50, 2300, 1350 bot._list_x = 136 bot._selected_tracking_initialized = True bot._active_session_fp = b"old" bot._active_chat_signature = b"old" bot._active_identity_signature = b"old" image = np.full((1300, 2200, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 image[130:210, :320, :3] = (250, 232, 215) image[1000:1002, :, :3] = 230 image[1002:, :, :3] = 255 self.assertTrue(bot._refresh_message_geometry(image)) self.assertEqual(bot._list_x, 320) self.assertEqual(bot.list_region["left"], 420) self.assertEqual(bot.list_click_x, 650) # 视觉/指纹裁剪从会话列表右边缘附近开始,必须覆盖客户左侧气泡; # 不能再额外右移 300×DPI。 self.assertLessEqual(bot._chat_rel_x, bot._list_x + bot._list_w + 20 * bot.scale) self.assertGreater(bot._chat_rel_w, 1000) self.assertTrue(bot._composer_geometry_valid) self.assertLessEqual( bot._chat_region["top"] + bot._chat_region["height"], bot.T + 1000 - int(2 * bot.scale), ) self.assertGreater(bot.input_y, bot.T + 1000) self.assertFalse(bot._selected_tracking_initialized) @staticmethod def _geometry_surface(): image = np.full((1300, 2200, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 image[130:210, :320, :3] = (250, 232, 215) image[1000:1002, :, :3] = 230 image[1002:, :, :3] = 255 return image def test_auto_hiding_chat_scrollbar_never_counts_as_a_new_message(self): """消息区右缘的滚动条随鼠标进出淡入淡出,绝不能改写聊天画面指纹。 机器人自己把鼠标从消息区移到输入框就会让这条滚动条消失。若它落在指纹 裁剪区内,按 Enter 前的校验会把这次重绘当成“又收到新消息”,清掉刚写好 的草稿,自动回复永远发不出去。 """ bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L, bot.T, bot.R, bot.B = 100, 50, 2300, 1350 bot._list_x = 136 bot.detect_selected_row = mock.Mock(return_value=-1) bot._active_session_fp = None base = self._geometry_surface() self.assertTrue(bot._refresh_message_geometry(base)) chat_x1 = bot._chat_rel_x chat_x2 = chat_x1 + bot._chat_rel_w with_bubble = base.copy() with_bubble[400:460, chat_x1 + 40:chat_x1 + 300, :3] = 90 bot._capture_full_window = mock.Mock(return_value=with_bubble) baseline = bot._chat_surface_signature() self.assertTrue(baseline) # 滚动条紧贴消息区右缘,出现或淡出都不得改变指纹。 with_scrollbar = with_bubble.copy() with_scrollbar[500:900, chat_x2 - 6:chat_x2, :3] = 150 bot._capture_full_window.return_value = with_scrollbar self.assertEqual(bot._chat_surface_signature(), baseline) # 同样的判据仍须能发现消息区内部真正的新增内容。 with_new_message = with_bubble.copy() with_new_message[700:760, chat_x1 + 40:chat_x1 + 300, :3] = 90 bot._capture_full_window.return_value = with_new_message self.assertNotEqual(bot._chat_surface_signature(), baseline) def test_chat_capture_still_covers_the_full_outgoing_bubble(self): """自己的气泡右缘紧贴滚动条,截图裁剪区不能为了避开滚动条把它切掉。""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L, bot.T, bot.R, bot.B = 100, 50, 2300, 1350 bot._list_x = 136 self.assertTrue(bot._refresh_message_geometry(self._geometry_surface())) window_width = bot.R - bot.L # 只允许留出视觉边距本身,不得额外内缩。 self.assertGreaterEqual( bot._chat_rel_x + bot._chat_rel_w, window_width - max(4, int(8 * bot.scale)), ) def test_text_selection_region_is_clamped_inside_narrow_window(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L, bot.T, bot.R, bot.B = 100, 50, 800, 500 bot._list_x = 136 bot._selected_tracking_initialized = True bot._active_session_fp = b"old" bot._active_chat_signature = b"old" bot._active_identity_signature = b"old" full = np.zeros((450, 700, 4), dtype=np.uint8) with ( mock.patch("wechat_bot.infer_navigation_width", return_value=(320, 0.9)), mock.patch("wechat_bot.infer_composer_top", return_value=280), ): self.assertTrue(bot._refresh_message_geometry(full)) region = bot._chat_region self.assertGreaterEqual(region["left"], bot.L) self.assertGreaterEqual(region["top"], bot.T) self.assertLessEqual(region["left"] + region["width"], bot.R) self.assertLessEqual(region["top"] + region["height"], bot.B) self.assertGreaterEqual(bot.input_x, bot.L) self.assertLess(bot.input_x, bot.R) self.assertGreaterEqual(bot.input_y, bot.T) self.assertLess(bot.input_y, bot.B) self.assertFalse(bot._session_geometry_valid) self.assertFalse(bot._input_geometry_valid) def test_tiny_chat_region_never_starts_mouse_drag(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot._chat_region = {"left": 799, "top": 200, "width": 1, "height": 120} bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._drag_select = mock.Mock() self.assertEqual(bot.extract_chat_text(screens=1), "") bot.wait_for_mouse_idle.assert_not_called() bot._drag_select.assert_not_called() def test_failed_composer_redetection_invalidates_every_chat_reader(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot.L, bot.T, bot.R, bot.B = 0, 0, 1200, 800 bot._list_x = 68 bot._list_w = 230 bot._composer_rel_top = 620 bot._composer_geometry_valid = True bot._chat_geometry_valid = True bot._input_geometry_valid = True bot._chat_region = {"left": 310, "top": 100, "width": 850, "height": 500} bot._drag_select = mock.Mock() frame = np.full((800, 1200, 4), 245, dtype=np.uint8) with ( mock.patch("wechat_bot.infer_navigation_width", return_value=(68, 0.9)), mock.patch("wechat_bot.infer_composer_top", return_value=None), mock.patch("wechat_bot.pyautogui.click") as click, ): self.assertFalse(bot._refresh_message_geometry(frame)) self.assertFalse(bot._composer_geometry_valid) self.assertFalse(bot._chat_geometry_valid) self.assertFalse(bot._input_geometry_valid) self.assertEqual(bot.extract_chat_text(screens=1), "") self.assertIsNone(bot._last_visible_bubble_is_outgoing()) self.assertEqual(bot._chat_surface_signature(), b"") with self.assertRaises(RuntimeError): bot.capture_chat_area() click.assert_not_called() bot._drag_select.assert_not_called() def test_invalid_session_geometry_never_scrolls_mouse(self): bot = WeChatBot.__new__(WeChatBot) bot._session_geometry_valid = False bot.wait_for_mouse_idle = mock.Mock(return_value=True) with ( mock.patch("wechat_bot.pyautogui.moveTo") as move, mock.patch("wechat_bot.pyautogui.scroll") as scroll, ): bot._scroll_session_list_top() self.assertIsNone( bot._scroll_session_list_page(np.zeros((20, 20, 4), dtype=np.uint8)) ) bot.wait_for_mouse_idle.assert_not_called() move.assert_not_called() scroll.assert_not_called() def test_selected_mail_is_not_mistaken_for_messages(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 self.assertFalse(bot._message_nav_selected(self._mail_nav_surface())) def test_ui_guard_rejects_model_action_that_does_not_match_state(self): decision = _parse_ui_guard_decision( '说明文字 {"state":"chat_ready","action":"open_messages",' '"confidence":0.99,"reason":"误判动作"}' ) self.assertEqual(decision["state"], "chat_ready") self.assertEqual(decision["action"], "none") def test_ui_guard_skips_unrelated_json_before_protocol_object(self): decision = _parse_ui_guard_decision( '{"trace_id":"abc","tokens":42}\n' '{"state":"non_message_page","action":"open_messages",' '"confidence":0.96,"reason":"当前是邮件页"}' ) self.assertEqual(decision["state"], "non_message_page") self.assertEqual(decision["action"], "open_messages") self.assertEqual(decision["confidence"], 0.96) def test_ui_guard_conflicting_protocol_objects_fail_closed(self): decision = _parse_ui_guard_decision( '{"state":"blocking_modal","action":"escape","confidence":0.95}\n' '{"state":"non_message_page","action":"open_messages","confidence":0.99}' ) self.assertEqual(decision["state"], "unknown") self.assertEqual(decision["action"], "none") self.assertEqual(decision["confidence"], 0.0) def test_ui_guard_missing_protocol_field_fails_closed(self): decision = _parse_ui_guard_decision( '{"state":"non_message_page","confidence":0.99}\n' '{"action":"open_messages","confidence":0.99}' ) self.assertEqual(decision["state"], "unknown") self.assertEqual(decision["action"], "none") def test_layout_guard_accepts_one_normalized_semantic_layout(self): decision = _parse_wecom_layout_decision( '{"state":"chat_ready","navigation_right_ratio":0.10,' '"session_list_right_ratio":0.30,"composer_top_ratio":0.78,' '"chat_right_ratio":1.0,"confidence":0.93,"reason":"消息页"}' ) self.assertEqual(decision["state"], "chat_ready") self.assertEqual(decision["confidence"], 0.93) def test_layout_guard_rejects_impossible_or_ambiguous_geometry(self): impossible = _parse_wecom_layout_decision( '{"state":"chat_ready","navigation_right_ratio":0.40,' '"session_list_right_ratio":0.20,"composer_top_ratio":0.10,' '"chat_right_ratio":1.0,"confidence":0.99}' ) self.assertEqual(impossible["confidence"], 0.0) ambiguous = _parse_wecom_layout_decision( '{"state":"chat_ready","navigation_right_ratio":0.10,' '"session_list_right_ratio":0.30,"confidence":0.9}\n' '{"state":"chat_ready","navigation_right_ratio":0.12,' '"session_list_right_ratio":0.32,"confidence":0.9}' ) self.assertEqual(ambiguous["confidence"], 0.0) def test_dify_ui_guard_uploads_image_before_chat_message(self): upload = mock.Mock(ok=True, status_code=201, text="") upload.json.return_value = {"id": "file-123"} chat = mock.Mock(ok=True, status_code=200, text="") chat.json.return_value = { "answer": ( '{"state":"blocking_modal","action":"close_modal",' '"confidence":0.96,"reason":"文档弹窗遮挡"}' ) } with ( mock.patch("ai_chat.ai_config.AI_PROVIDER_TYPE", "dify"), mock.patch("ai_chat.ai_config.AI_API_BASE", "https://dify.example/v1"), mock.patch("ai_chat.ai_config.AI_API_KEY", "app-secret"), mock.patch("ai_chat.requests.post", side_effect=[upload, chat]) as post, ): decision = classify_wecom_ui(b"png-data", trigger="测试") self.assertEqual(decision["action"], "close_modal") self.assertEqual(post.call_args_list[0].args[0], "https://dify.example/v1/files/upload") self.assertIn("file", post.call_args_list[0].kwargs["files"]) second_payload = post.call_args_list[1].kwargs["json"] self.assertEqual(second_payload["files"][0]["upload_file_id"], "file-123") self.assertEqual(second_payload["files"][0]["transfer_method"], "local_file") def test_ai_page_guard_can_only_open_fixed_message_entry(self): bot = WeChatBot.__new__(WeChatBot) bot._last_ui_guard_ts = 0.0 bot._last_ui_guard_signature = "" bot._open_messages_page = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._escape_proven_blocker = mock.Mock(return_value=False) surface = np.full((300, 500, 4), 220, dtype=np.uint8) surface[:, :, 3] = 255 with ( mock.patch("wechat_bot.time.monotonic", return_value=100.0), mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_UI_GUARD_ENABLED", True), mock.patch( "ai_chat.classify_wecom_ui", return_value={ "state": "non_message_page", "action": "open_messages", "confidence": 0.95, "reason": "当前是微盘", }, ), ): self.assertTrue(bot._run_ai_page_guard("测试", full=surface)) bot._open_messages_page.assert_called_once() bot._dismiss_internal_blocker.assert_not_called() bot._escape_proven_blocker.assert_not_called() def test_ai_page_guard_never_closes_security_verification(self): bot = WeChatBot.__new__(WeChatBot) bot._last_ui_guard_ts = 0.0 bot._last_ui_guard_signature = "" bot.security_verification_required = False bot._window_ready = True bot._open_messages_page = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=True) bot._escape_proven_blocker = mock.Mock(return_value=True) surface = np.full((300, 500, 4), 220, dtype=np.uint8) surface[:, :, 3] = 255 with ( mock.patch("wechat_bot.time.monotonic", return_value=100.0), mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_UI_GUARD_ENABLED", True), mock.patch( "ai_chat.classify_wecom_ui", return_value={ "state": "security_verification", "action": "none", "confidence": 0.93, "reason": "需要扫码", }, ), ): self.assertFalse(bot._run_ai_page_guard("测试", full=surface)) self.assertTrue(bot.security_verification_required) self.assertFalse(bot._window_ready) bot._open_messages_page.assert_not_called() bot._dismiss_internal_blocker.assert_not_called() bot._escape_proven_blocker.assert_not_called() def test_minimum_send_interval_is_enforced(self): bot = WeChatBot.__new__(WeChatBot) bot._send_timestamps = deque([95.0]) bot._last_send_ts = 95.0 with mock.patch("wechat_bot.time.monotonic", return_value=100.0): self.assertAlmostEqual(bot._send_gate_remaining(), 3.0) def test_send_stops_immediately_after_owned_blocker_handling(self): bot = WeChatBot.__new__(WeChatBot) bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=True) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse(bot.send_reply("这次不能发送")) bot._ensure_visible.assert_not_called() bot._dismiss_internal_blocker.assert_not_called() click.assert_not_called() def test_send_stops_immediately_after_internal_modal_handling(self): bot = WeChatBot.__new__(WeChatBot) bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock() with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse(bot.send_reply("这次不能发送")) bot._capture_full_window.assert_not_called() click.assert_not_called() def test_send_is_cancelled_before_click_when_chat_identity_changed(self): bot = WeChatBot.__new__(WeChatBot) bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._selected_session_fingerprint = mock.Mock(return_value=b"target") bot._chat_identity_signature = mock.Mock(return_value=b"changed") bot._active_identity_signature = b"original" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._run_ai_page_guard = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse( bot.send_reply( "不会发送", session_id=b"target".hex(), expected_fp=b"target", ) ) click.assert_not_called() def test_send_is_cancelled_when_pending_title_does_not_match(self): bot = WeChatBot.__new__(WeChatBot) fp = b"target02" bot._pending_reply_sessions = { fp.hex(): {"identity_signature": b"original-title"} } bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"other-title") bot._active_identity_signature = b"other-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._run_ai_page_guard = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse( bot.send_reply( "不会串发", session_id=fp.hex(), expected_fp=fp, ) ) click.assert_not_called() def test_send_is_cancelled_when_new_message_arrives_after_generation(self): bot = WeChatBot.__new__(WeChatBot) fp = b"target04" bot._pending_reply_sessions = { fp.hex(): { "identity_signature": b"same-title", "generation_surface_signature": b"before-model", } } bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_surface_signature = mock.Mock(return_value=b"new-message") bot._active_identity_signature = b"same-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._run_ai_page_guard = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse( bot.send_reply( "已经过时的回复", session_id=fp.hex(), expected_fp=fp, ) ) click.assert_not_called() bot._run_ai_page_guard.assert_not_called() def test_send_is_cancelled_when_message_arrives_after_paste_before_enter(self): bot = WeChatBot.__new__(WeChatBot) fp = b"z" * 40 bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "identity_signature": b"same-title", "generation_surface_signature": b"stable-chat", } } bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_surface_signature = mock.Mock( side_effect=[b"stable-chat", b"new-message"] ) bot._active_identity_signature = b"same-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._input_geometry_valid = True bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock( side_effect=["", "已经过时的草稿"] ) with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.pyperclip.paste", return_value=""), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.time.sleep"), ): self.assertFalse( bot.send_reply( "已经过时的草稿", session_id=fp.hex(), expected_fp=fp, ) ) self.assertIn(mock.call("ctrl", "v"), hotkey.call_args_list) self.assertIn(mock.call("ctrl", "a"), hotkey.call_args_list) self.assertIn(mock.call("backspace"), press.call_args_list) self.assertFalse(bot._pending_reply_state(fp)["batch_ready"]) def test_send_is_cancelled_when_input_geometry_is_invalid(self): bot = WeChatBot.__new__(WeChatBot) bot._input_geometry_valid = False bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse(bot.send_reply("不会发送")) click.assert_not_called() def test_send_is_cancelled_when_selected_session_fingerprint_is_missing(self): bot = WeChatBot.__new__(WeChatBot) fp = b"target03" bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._selected_session_fingerprint = mock.Mock(return_value=None) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._active_identity_signature = b"same-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._run_ai_page_guard = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse( bot.send_reply( "不会发送", session_id=fp.hex(), expected_fp=fp, ) ) click.assert_not_called() def test_successful_send_keeps_current_chat_open(self): bot = WeChatBot.__new__(WeChatBot) bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._selected_session_fingerprint = mock.Mock(return_value=b"target") bot._chat_identity_signature = mock.Mock(return_value=b"identity") bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat") bot._active_identity_signature = b"identity" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock(side_effect=["", "正常发送"]) bot._record_send = mock.Mock() bot._commit_staged_exchange = mock.Mock() bot._remember_active_surface = mock.Mock() bot._deselect_session = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.pyautogui.hotkey"), mock.patch("wechat_bot.pyautogui.press"), mock.patch("wechat_bot.pyperclip.paste", return_value=""), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.time.sleep"), ): self.assertTrue( bot.send_reply( "正常发送", session_id=b"target".hex(), expected_fp=b"target", ) ) bot._deselect_session.assert_not_called() def test_final_enter_gate_uses_detected_chat_pane_right(self): """Regression: an expanded customer panel used to fake a resize forever.""" bot = WeChatBot.__new__(WeChatBot) fp = b"r" * 40 bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "identity_signature": b"same-title", "generation_surface_signature": b"stable-chat", "last_lines": ["客户甲 10:00:00", "在不在"], } } bot._pending_reply_path = "" bot._set_task_stage = mock.Mock() bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat") bot._active_identity_signature = b"same-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._input_geometry_valid = True bot._composer_geometry_valid = True bot.scale = 2.0 bot._list_x = 320 bot._list_w = 460 bot._chat_pane_right_rel = 1550 bot._composer_rel_top = 916 bot._refresh_message_geometry = mock.Mock(return_value=False) bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock(side_effect=["", "会回复的"]) bot._persist_pending_replies = mock.Mock(return_value=True) bot._record_send = mock.Mock() bot._same_chat_is_open = mock.Mock(return_value=True) bot._send_receipt_matches = mock.Mock(return_value=True) bot._last_send_receipt_followup = False bot._visible_reply_has_customer_followup = mock.Mock(return_value=False) bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() with ( mock.patch( "wechat_bot.composer_divider_matches", return_value=(True, 916), ) as divider, mock.patch("wechat_bot.pyautogui.hotkey"), mock.patch("wechat_bot.pyautogui.press"), mock.patch("wechat_bot.pyperclip.paste", return_value=""), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.time.sleep"), ): self.assertTrue( bot.send_reply("会回复的", session_id=fp.hex(), expected_fp=fp) ) self.assertEqual(divider.call_args.kwargs["chat_right"], 1550) def test_layout_change_after_paste_keeps_draft_and_staged_reply(self): bot = WeChatBot.__new__(WeChatBot) fp = b"l" * 40 bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "identity_signature": b"same-title", "generation_surface_signature": b"stable-chat", "staged_reply_text": "保留这条回复", "last_lines": ["客户甲 10:00:00", "在不在"], } } bot._pending_reply_path = "" bot._set_task_stage = mock.Mock() bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat") bot._active_identity_signature = b"same-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._input_geometry_valid = True bot._composer_geometry_valid = True bot.scale = 2.0 bot._list_x = 320 bot._list_w = 460 bot._chat_pane_right_rel = 1550 bot._composer_rel_top = 916 bot._refresh_message_geometry = mock.Mock(return_value=False) bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock(side_effect=["", "保留这条回复"]) bot._persist_pending_replies = mock.Mock(return_value=True) with ( mock.patch( "wechat_bot.composer_divider_matches", return_value=(False, 1008), ), mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.pyperclip.paste", return_value=""), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.time.sleep"), ): self.assertFalse( bot.send_reply( "保留这条回复", session_id=fp.hex(), expected_fp=fp ) ) state = bot._pending_reply_state(fp) self.assertTrue(state["layout_retry_pending"]) self.assertTrue(state["batch_ready"]) self.assertEqual(state["staged_reply_text"], "保留这条回复") self.assertEqual(state["last_pasted_draft"], "保留这条回复") self.assertNotIn(mock.call("ctrl", "a"), hotkey.call_args_list) self.assertNotIn(mock.call("backspace"), press.call_args_list) def test_layout_rebase_distinguishes_resize_from_appended_message(self): bot = WeChatBot.__new__(WeChatBot) fp = b"b" * 40 baseline = ["客户甲 10:00:00", "在不在"] state = { "identity_signature": b"same-title", "generation_surface_signature": b"old-size", "last_lines": list(baseline), "staged_reply_text": "我在呢", "layout_retry_pending": True, } bot._pending_reply_sessions = {fp.hex(): state} bot._pending_reply_path = "" bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_target_matches = mock.Mock(return_value=True) bot.extract_chat_text = mock.Mock( return_value="客户甲 10:00:00\n在不在" ) bot._chat_surface_signature = mock.Mock(return_value=b"new-size") bot._persist_pending_replies = mock.Mock(return_value=True) self.assertEqual( bot._rebase_pending_after_layout_change(fp, state), "unchanged", ) self.assertEqual(state["generation_surface_signature"], b"new-size") self.assertEqual(state["staged_reply_text"], "我在呢") self.assertNotIn("layout_retry_pending", state) changed_state = { "identity_signature": b"same-title", "last_lines": list(baseline), } bot.extract_chat_text.return_value = ( "客户甲 10:00:00\n在不在\n客户甲 10:01:00\n还有一件事" ) self.assertEqual( bot._rebase_pending_after_layout_change(fp, changed_state), "changed", ) def test_unchanged_surface_reuses_staged_reply_without_model_call(self): bot = WeChatBot.__new__(WeChatBot) fp = b"u" * 40 state = { "identity_signature": b"same-title", "generation_surface_signature": b"stable-chat", "staged_reply_text": "已经生成过的回复", "layout_retry_pending": True, } bot._pending_reply_sessions = {fp.hex(): state} bot._pending_reply_path = "" bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_target_matches = mock.Mock(return_value=True) bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat") bot._persist_pending_replies = mock.Mock(return_value=True) bot._set_task_stage = mock.Mock() with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch( "ai_chat.get_ai_reply", side_effect=AssertionError("unchanged retry must not call the model"), ), ): self.assertEqual( bot._generate_ai_reply(fp), "已经生成过的回复", ) self.assertNotIn("layout_retry_pending", state) bot._set_task_stage.assert_called_with( fp, "ready_to_send", detail="聊天内容未变化,复用已生成回复", ) def test_repeated_unknown_layout_escalates_to_maximize_then_multimodal_guard(self): bot = WeChatBot.__new__(WeChatBot) fp = b"e" * 40 state = { "identity_signature": b"same-title", "generation_surface_signature": b"old-layout", "staged_reply_text": "保留回复", "layout_retry_pending": True, "layout_retry_count": 2, } bot._pending_reply_sessions = {fp.hex(): state} bot._pending_reply_path = "" bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_target_matches = mock.Mock(return_value=True) bot._chat_surface_signature = mock.Mock(return_value=b"new-layout") bot._rebase_pending_after_layout_change = mock.Mock(return_value="unknown") bot._persist_pending_replies = mock.Mock(return_value=True) bot._set_task_stage = mock.Mock() bot._maximize_window_for_composer = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock( return_value=np.zeros((300, 500, 4), dtype=np.uint8) ) bot._run_ai_page_guard = mock.Mock(return_value=False) with mock.patch("ai_config.AI_ENABLED", True): self.assertIsNone(bot._generate_ai_reply(fp)) bot._maximize_window_for_composer.assert_called_once_with(force=True) bot._run_ai_page_guard.assert_called_once_with( "连续布局重定位失败", full=bot._capture_full_window.return_value, ) def test_tiny_window_is_maximized_before_any_input_click(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.L, bot.T, bot.R, bot.B = 0, 0, 1200, 800 bot.hwnd = 123 bot.auto_activate_window = True bot._window_ready = True bot.connect = mock.Mock(return_value=True) with ( mock.patch( "wechat_bot.win32gui.GetWindowPlacement", return_value=(0, wechat_bot.win32con.SW_SHOWNORMAL, (0, 0), (0, 0), (0, 0, 1200, 800)), ), mock.patch("wechat_bot.win32gui.ShowWindow") as show, mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot._maximize_window_for_composer()) show.assert_called_once_with(123, wechat_bot.win32con.SW_MAXIMIZE) bot.connect.assert_called_once_with(activate=False) def test_reply_without_generation_surface_still_records_pre_enter_baseline(self): """AI-disabled/fixed-reply mode must not become permanently uncertain.""" bot = WeChatBot.__new__(WeChatBot) fp = b"f" * 40 bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "identity_signature": b"same-title", "last_lines": ["客户甲 10:00:00", "固定回复前的问题"], } } bot._pending_reply_path = "" bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_surface_signature = mock.Mock(return_value=b"pre-enter") bot._active_identity_signature = b"same-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._input_geometry_valid = True bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock(side_effect=["", "固定回复"]) bot._persist_pending_replies = mock.Mock(return_value=True) bot._record_send = mock.Mock() bot._same_chat_is_open = mock.Mock(return_value=True) bot._send_receipt_matches = mock.Mock(return_value=True) bot._last_send_receipt_followup = False bot._visible_reply_has_customer_followup = mock.Mock(return_value=False) bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.hotkey"), mock.patch("wechat_bot.pyautogui.press"), mock.patch("wechat_bot.pyperclip.paste", return_value=""), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.time.sleep"), ): self.assertTrue( bot.send_reply("固定回复", session_id=fp.hex(), expected_fp=fp) ) state = bot._pending_reply_state(fp) self.assertEqual(state["send_surface_signature"], b"pre-enter") bot._send_receipt_matches.assert_called_once() def test_human_draft_is_preserved_and_enter_is_never_pressed(self): bot = WeChatBot.__new__(WeChatBot) bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._input_geometry_valid = True bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock(return_value="人工正在编辑的内容") with ( mock.patch("wechat_bot.pyperclip.paste", return_value="原剪贴板"), mock.patch("wechat_bot.pyperclip.copy") as copy, mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, mock.patch("wechat_bot.pyautogui.press") as press, ): self.assertFalse(bot.send_reply("自动回复")) self.assertNotIn(mock.call("ctrl", "v"), hotkey.call_args_list) self.assertNotIn(mock.call("enter"), press.call_args_list) copy.assert_called_once_with("原剪贴板") def test_incomplete_paste_is_never_sent(self): bot = WeChatBot.__new__(WeChatBot) bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._input_geometry_valid = True bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock(side_effect=["", "应该完整写入"]) with ( mock.patch("wechat_bot.pyperclip.paste", return_value="原剪贴板"), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.time.sleep"), ): self.assertFalse(bot.send_reply("应该完整写入的自动回复")) self.assertIn(mock.call("ctrl", "v"), hotkey.call_args_list) self.assertNotIn(mock.call("enter"), press.call_args_list) self.assertIn(mock.call("backspace"), press.call_args_list) def test_delayed_copy_marker_never_claims_human_draft_is_empty(self): bot = WeChatBot.__new__(WeChatBot) bot.input_x = 800 bot.input_y = 700 bot._input_editor_looks_blank = mock.Mock(return_value=False) marker = "__wecom_empty_123__" with ( mock.patch("wechat_bot.time.time_ns", return_value=123), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.pyperclip.paste", return_value=marker), mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.pyautogui.hotkey"), mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.time.sleep"), ): self.assertIsNone(bot._read_input_draft()) press.assert_called_with("end") def _draft_read(self, clipboard, blank): """跑一次 _read_input_draft,剪贴板与输入框像素判定都由调用方给定。""" bot = WeChatBot.__new__(WeChatBot) bot.input_x = 800 bot.input_y = 700 bot._input_editor_looks_blank = mock.Mock(return_value=blank) with ( mock.patch("wechat_bot.time.time_ns", return_value=123), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.pyperclip.paste", return_value=clipboard), mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.pyautogui.hotkey"), mock.patch("wechat_bot.pyautogui.press"), mock.patch("wechat_bot.time.sleep"), ): return bot._read_input_draft() def test_a_lingering_chat_selection_is_not_a_human_draft(self): """机器人刚框选复制过聊天记录,那块高亮不会因为点一下空白就消失。 现场(2026-07-31 12:26~12:30):Ctrl+C 拿回来的是聊天区里还亮着的 “在不在”,被当成人工草稿,于是每轮调一次模型、又每轮让路,连着十几轮 一条都没发出去。输入框像素上是空的,就不能算草稿。 """ self.assertEqual(self._draft_read("在不在", blank=True), "") def test_a_real_draft_in_a_non_blank_editor_still_wins(self): self.assertEqual( self._draft_read("我自己在打字", blank=False), "我自己在打字", ) def test_an_uncertain_editor_never_gets_overwritten(self): """像素判定拿不准时保持原样保守:宁可不发,也不能覆盖人工输入。""" self.assertEqual( self._draft_read("说不清是什么", blank=None), "说不清是什么", ) def test_empty_editor_requires_visual_blank_evidence(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot._list_x = 0 bot._list_w = 100 bot._composer_geometry_valid = True bot._composer_rel_top = 180 blank = np.full((300, 500, 4), 255, dtype=np.uint8) bot._capture_full_window = mock.Mock(return_value=blank) self.assertTrue(bot._input_editor_looks_blank()) with_text = blank.copy() with_text[240:260, 130:200, :3] = (100, 130, 240) bot._capture_full_window.return_value = with_text self.assertFalse(bot._input_editor_looks_blank()) def test_expanded_right_toolbar_is_excluded_from_empty_editor_vision(self): """200% DPI field evidence must stop at the detected chat divider.""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot._list_x = 0 bot._list_w = 100 bot._composer_geometry_valid = True bot._composer_rel_top = 180 bot._chat_pane_right_rel = 700 frame = np.full((400, 1100, 4), 255, dtype=np.uint8) frame[:, :, 3] = 255 # Simulate a dense customer-info toolbar to the right of x=700. frame[180:390, 720:1080, :3] = 40 bot._capture_full_window = mock.Mock(return_value=frame) self.assertTrue(bot._input_editor_looks_blank()) with_draft = frame.copy() with_draft[290:320, 150:250, :3] = (70, 100, 220) bot._capture_full_window.return_value = with_draft self.assertFalse(bot._input_editor_looks_blank()) def test_input_focus_retries_a_second_visual_editor_point(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 0 bot.scale = 2.0 bot.L, bot.T, bot.R, bot.B = 60, 70, 2324, 1774 bot._list_x = 320 bot._list_w = 460 bot._chat_pane_right_rel = 2200 bot._composer_rel_top = 1270 bot._editor_rel_top = 1350 bot.input_x = 1760 bot.input_y = 1654 bot._input_editor_looks_blank = mock.Mock(side_effect=[None, True]) marker = "__wecom_empty_123__" with ( mock.patch("wechat_bot.time.time_ns", return_value=123), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.pyperclip.paste", return_value=marker), mock.patch("wechat_bot.pyautogui.click") as click, mock.patch("wechat_bot.pyautogui.hotkey"), mock.patch("wechat_bot.pyautogui.press"), mock.patch("wechat_bot.time.sleep"), ): self.assertEqual(bot._read_input_draft(), "") self.assertEqual(click.call_count, 2) self.assertNotEqual(click.call_args_list[0], click.call_args_list[1]) @staticmethod def _composer_panel_surface(with_draft=False): """带圆角边框的输入面板,面板下方还有窗口底色。 采样框必然盖到面板边框和面板外底色;若不先收缩到编辑区内部,空输入 框的几乎每一列都会被算成“有内容”。 """ image = np.full((300, 500, 4), 235, dtype=np.uint8) image[:, :, 3] = 255 image[200:273, 118:339, :3] = 255 image[269:273, 118:339, :3] = 215 image[200:273, 118:122, :3] = 215 image[200:273, 334:339, :3] = 215 # 空输入框里只有一条细光标。 image[226:244, 126, :3] = 60 if with_draft: image[230:250, 150:260, :3] = (90, 120, 235) return image def test_composer_border_and_window_backdrop_never_fake_a_draft(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot._list_x = 0 bot._list_w = 100 bot._composer_geometry_valid = True bot._composer_rel_top = 180 bot._capture_full_window = mock.Mock( return_value=self._composer_panel_surface() ) self.assertTrue(bot._input_editor_looks_blank()) bot._capture_full_window.return_value = self._composer_panel_surface(True) self.assertFalse(bot._input_editor_looks_blank()) def test_ctrl_enter_accepts_verified_square_avatar_raw_fingerprint_once(self): bot = WeChatBot.__new__(WeChatBot) fp = b"q" * 40 state = {} bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=None) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._active_identity_signature = b"same-title" bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock( side_effect=["兼容发送", "兼容发送"] ) bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat") bot._persist_pending_replies = mock.Mock(return_value=True) bot._send_timestamps = deque([1.0]) with ( mock.patch("wechat_bot.pyperclip.paste", return_value="原剪贴板"), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.pyautogui.press"), mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, mock.patch("wechat_bot.time.sleep"), ): self.assertTrue( bot._try_ctrl_enter_for_retained_draft( "兼容发送", state, fp, b"same-title", ) ) self.assertFalse( bot._try_ctrl_enter_for_retained_draft( "兼容发送", state, fp, b"same-title", ) ) self.assertTrue(state["ctrl_enter_attempted"]) self.assertGreater(bot._send_timestamps[-1], 1.0) self.assertGreater(state["send_dispatched_at"], 0.0) self.assertEqual( hotkey.call_args_list.count(mock.call("ctrl", "enter")), 1, ) bot._selected_session_fingerprint.assert_not_called() def test_ctrl_enter_persist_failure_does_not_lock_future_recovery(self): bot = WeChatBot.__new__(WeChatBot) fp = b"p" * 40 state = {"send_state": "sent_uncommitted"} bot._same_chat_is_open = mock.Mock(return_value=True) bot._read_input_draft = mock.Mock( side_effect=["兼容发送", "兼容发送"] ) bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat") bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._persist_pending_replies = mock.Mock(return_value=False) with ( mock.patch("wechat_bot.pyperclip.paste", return_value="原剪贴板"), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, mock.patch("wechat_bot.time.sleep"), ): self.assertFalse( bot._try_ctrl_enter_for_retained_draft( "兼容发送", state, fp, ) ) self.assertEqual(state["send_state"], "sent_uncommitted") self.assertNotIn("ctrl_enter_attempted", state) self.assertNotIn(mock.call("ctrl", "enter"), hotkey.call_args_list) def test_ctrl_enter_stops_immediately_when_human_has_taken_over(self): bot = WeChatBot.__new__(WeChatBot) state = {"send_state": "sent_uncommitted"} bot._mouse_is_idle_now = mock.Mock(return_value=False) bot._same_chat_is_open = mock.Mock(return_value=True) bot._begin_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock() bot._persist_pending_replies = mock.Mock() bot._refresh_send_reservation = mock.Mock() with mock.patch("wechat_bot.pyautogui.hotkey") as hotkey: self.assertFalse( bot._try_ctrl_enter_for_retained_draft( "兼容发送", state, b"target", ) ) bot._same_chat_is_open.assert_not_called() bot._begin_bot_mouse.assert_not_called() bot._read_input_draft.assert_not_called() bot._persist_pending_replies.assert_not_called() bot._refresh_send_reservation.assert_not_called() hotkey.assert_not_called() self.assertNotIn("ctrl_enter_attempted", state) self.assertNotIn("send_dispatched_at", state) def test_send_receipt_allows_bounded_delayed_render(self): bot = WeChatBot.__new__(WeChatBot) bot._send_receipt_matches = mock.Mock(side_effect=[False, True]) with mock.patch("wechat_bot.time.sleep") as sleep: self.assertTrue( bot._wait_for_visible_reply( "已发送", "客户甲", b"before", ) ) self.assertEqual(bot._send_receipt_matches.call_count, 2) self.assertEqual(sleep.call_count, 1) def test_old_same_text_bubble_cannot_confirm_unchanged_transaction(self): bot = WeChatBot.__new__(WeChatBot) bot._visible_last_message_matches = mock.Mock(return_value=True) bot._chat_surface_signature = mock.Mock(return_value=b"before-send") self.assertFalse( bot._send_receipt_matches( "好的", "客户甲", b"before-send", ) ) def test_old_same_text_plus_new_customer_message_cannot_confirm_send(self): bot = WeChatBot.__new__(WeChatBot) visible = ( "贴心管家 10:00:00\n好的\n" "客户甲 10:00:05\n我再问一句" ) bot.extract_chat_text = mock.Mock(return_value=visible) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False) bot._chat_surface_signature = mock.Mock(return_value=b"customer-followup") with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertFalse( bot._send_receipt_matches( reply_text="好的", customer_speaker="客户甲", before_surface=b"before-send", before_blocks=[["贴心管家 10:00:00", "好的"]], reply_was_visible=True, ) ) def test_new_same_text_outgoing_block_confirms_send(self): bot = WeChatBot.__new__(WeChatBot) visible = ( "贴心管家 10:00:00\n好的\n" "客户甲 10:00:05\n我再问一句\n" "贴心管家 10:00:10\n好的" ) bot.extract_chat_text = mock.Mock(return_value=visible) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True) bot._chat_surface_signature = mock.Mock(return_value=b"after-send") with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertTrue( bot._send_receipt_matches( reply_text="好的", customer_speaker="客户甲", before_surface=b"before-send", before_blocks=[ ["贴心管家 10:00:00", "好的"], ["客户甲 10:00:05", "我再问一句"], ], reply_was_visible=True, ) ) def test_actual_staff_name_can_be_proved_by_outgoing_bubble_side(self): bot = WeChatBot.__new__(WeChatBot) visible = ( "客户甲 10:00:05\n我再问一句\n" "高兴亮 10:00:10\n好的" ) bot.extract_chat_text = mock.Mock(return_value=visible) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True) bot._chat_surface_signature = mock.Mock(return_value=b"after-send") with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertTrue( bot._send_receipt_matches( reply_text="好的", customer_speaker="客户甲", before_surface=b"before-send", before_blocks=[["客户甲 10:00:05", "我再问一句"]], ) ) self.assertIn("高兴亮", bot._known_agent_speakers) def test_group_member_echo_cannot_be_mistaken_for_our_reply(self): bot = WeChatBot.__new__(WeChatBot) visible = ( "客户甲 10:00:00\n有人在吗\n" "客户乙 10:00:03\n我在呢" ) bot.extract_chat_text = mock.Mock(return_value=visible) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False) bot._chat_surface_signature = mock.Mock(return_value=b"changed") with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertFalse( bot._send_receipt_matches( reply_text="我在呢", customer_speaker="客户甲", before_surface=b"before", before_blocks=[["客户甲 10:00:00", "有人在吗"]], ) ) def test_proven_real_staff_name_confirms_reply_before_fast_followup(self): bot = WeChatBot.__new__(WeChatBot) bot._known_agent_speakers = {"高兴亮"} visible = ( "客户甲 10:00:00\n原问题\n" "高兴亮 10:00:05\n我在呢\n" "客户甲 10:00:06\n还有一个问题" ) bot.extract_chat_text = mock.Mock(return_value=visible) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False) bot._chat_surface_signature = mock.Mock(return_value=b"after") with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertTrue( bot._send_receipt_matches( reply_text="我在呢", customer_speaker="客户甲", before_surface=b"before", before_blocks=[["客户甲 10:00:00", "原问题"]], ) ) self.assertTrue(bot._last_send_receipt_followup) self.assertTrue( bot._visible_reply_has_customer_followup("我在呢", "客户甲") ) def test_receipt_and_fast_followup_are_committed_from_one_snapshot(self): bot = WeChatBot.__new__(WeChatBot) fp = b"j" * 40 state = { "send_state": "sent_uncommitted", "reply_text": "我在呢", "customer_speaker": "客户甲", "send_surface_signature": b"before", "send_baseline_blocks": [["客户甲 10:00:00", "原问题"]], "send_known_outgoing_speakers": ["高兴亮"], } visible = ( "客户甲 10:00:00\n原问题\n" "高兴亮 10:00:05\n我在呢\n" "客户甲 10:00:06\n还有一个问题" ) bot._known_agent_speakers = set() bot.extract_chat_text = mock.Mock(return_value=visible) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False) bot._chat_surface_signature = mock.Mock(return_value=b"after") bot._visible_reply_has_customer_followup = mock.Mock(return_value=False) bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._queue_followup_after_confirmed_send = mock.Mock() bot._remember_active_surface = mock.Mock() with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertEqual(bot._reconcile_uncertain_send(fp, state), "sent") bot.extract_chat_text.assert_called_once() bot._visible_reply_has_customer_followup.assert_not_called() bot._queue_followup_after_confirmed_send.assert_called_once_with(fp) bot._remember_active_surface.assert_not_called() def test_unknown_followup_direction_keeps_a_pending_probe(self): bot = WeChatBot.__new__(WeChatBot) fp = b"v" * 40 state = { "send_state": "sent_uncommitted", "reply_text": "已发送", "send_surface_signature": b"before", "send_baseline_blocks": [["客户甲 10:00:00", "原问题"]], } bot._chat_surface_signature = mock.Mock(return_value=b"after") bot._send_receipt_matches = mock.Mock(return_value=True) bot._last_send_receipt_followup = None bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._queue_followup_after_confirmed_send = mock.Mock() bot._remember_active_surface = mock.Mock() self.assertEqual(bot._reconcile_uncertain_send(fp, state), "sent") bot._queue_followup_after_confirmed_send.assert_called_once_with(fp) bot._remember_active_surface.assert_not_called() def test_block_anchor_tolerates_old_blocks_scrolling_off_screen(self): bot = WeChatBot.__new__(WeChatBot) baseline = [ ["客户甲 09:59:00", "更早的问题"], ["贴心管家 09:59:10", "更早的回复"], ["客户甲 10:00:00", "新的问题"], ] current = baseline[1:] + [["贴心管家 10:00:10", "新的回答"]] self.assertEqual( bot._blocks_after_baseline(baseline, current), [["贴心管家 10:00:10", "新的回答"]], ) def test_missing_or_malformed_legacy_anchor_never_confirms_old_reply(self): bot = WeChatBot.__new__(WeChatBot) fp = b"g" * 40 state = { "send_state": "sent_uncommitted", "reply_text": "好的", "send_surface_signature": b"before", "send_reply_match_count": 0, } bot._chat_surface_signature = mock.Mock(return_value=b"after") bot._send_receipt_matches = mock.Mock(return_value=True) bot._persist_pending_replies = mock.Mock(return_value=True) bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain") bot._send_receipt_matches.assert_not_called() bot._commit_staged_exchange.assert_not_called() bot._clear_reply_pending.assert_not_called() state["send_state"] = "sent_uncommitted" state["send_baseline_blocks"] = [] self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain") bot._send_receipt_matches.assert_not_called() def test_legacy_uncertain_task_yields_to_a_proven_new_customer_turn(self): bot = WeChatBot.__new__(WeChatBot) fp = b"y" * 40 state = { "send_state": "uncertain", "reply_text": "旧草稿绝不能重发", "send_surface_signature": b"before", "uncertain_since": 1.0, "customer_speaker": "客户甲", "last_lines": ["客户甲 10:00:00", "原问题"], "confirmed_unread": True, } bot._pending_reply_sessions = {fp.hex(): state} bot._pending_reply_path = "" bot._uncertain_send_last_check = {} bot._uncertain_send_last_log = {} bot._uncertain_send_last_surface = {fp.hex(): b"before"} bot._chat_surface_signature = mock.Mock(return_value=b"after") bot.extract_chat_text = mock.Mock( return_value=( "客户甲 10:00:00\n原问题\n" "客户甲 10:00:30\n这是后续新消息" ) ) bot._persist_pending_replies = mock.Mock(return_value=True) bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() with mock.patch("wechat_bot.pyautogui.hotkey") as hotkey: self.assertEqual(bot._reconcile_uncertain_send(fp, state), "unsent") refreshed = bot._pending_reply_state(fp) self.assertNotIn("send_state", refreshed) self.assertNotIn("reply_text", refreshed) self.assertTrue(refreshed["confirmed_unread"]) self.assertEqual(refreshed["last_lines"], ["客户甲 10:00:00", "原问题"]) bot._commit_staged_exchange.assert_not_called() bot._clear_reply_pending.assert_not_called() hotkey.assert_not_called() def test_same_chat_receipt_uses_strong_chat_fallback_when_nav_colour_misses(self): bot = WeChatBot.__new__(WeChatBot) fp = b"h" * 40 bot._capture_full_window = mock.Mock(return_value=self._nav_surface(False)) bot._message_nav_selected = mock.Mock(return_value=False) bot._target_chat_ready = mock.Mock(return_value=fp) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=None) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._active_identity_signature = b"same-title" self.assertTrue(bot._same_chat_is_open(fp, b"same-title")) def test_same_chat_receipt_accepts_title_render_drift_with_full_selected_fp(self): bot = WeChatBot.__new__(WeChatBot) fp = b"i" * 40 bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"new-render") bot._active_identity_signature = b"old-render" self.assertTrue(bot._same_chat_is_open(fp, b"old-render")) def test_send_receipt_deadline_stops_more_expensive_checks(self): bot = WeChatBot.__new__(WeChatBot) bot._send_receipt_matches = mock.Mock(return_value=False) with ( mock.patch("wechat_bot.time.monotonic", side_effect=[0.0, 9.0]), mock.patch("wechat_bot.time.sleep") as sleep, ): self.assertFalse( bot._wait_for_visible_reply( "已发送", "客户甲", b"before", ) ) bot._send_receipt_matches.assert_called_once() sleep.assert_not_called() def test_restart_reconciles_visible_sent_reply_without_resending(self): bot = WeChatBot.__new__(WeChatBot) fp = b"r" * 40 state = { "send_state": "sent_uncommitted", "reply_text": "已经发送的回复", "send_surface_signature": b"before-send", "send_baseline_blocks": [["客户甲 10:00:00", "原问题"]], } def visible_sent(*_args, **_kwargs): bot._last_send_receipt_followup = False return True bot._visible_last_message_matches = mock.Mock(side_effect=visible_sent) bot._chat_surface_signature = mock.Mock(return_value=b"after-send") bot._visible_reply_has_customer_followup = mock.Mock(return_value=False) bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() self.assertEqual(bot._reconcile_uncertain_send(fp, state), "sent") bot._commit_staged_exchange.assert_called_once_with(fp.hex()) bot._clear_reply_pending.assert_called_once_with(fp) def test_followup_detection_anchors_latest_same_text_reply(self): bot = WeChatBot.__new__(WeChatBot) no_followup = ( "贴心管家 10:00:00\n好的\n" "客户甲 10:00:01\n谢谢\n" "贴心管家 10:00:02\n好的" ) bot.extract_chat_text = mock.Mock(return_value=no_followup) with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertFalse( bot._visible_reply_has_customer_followup("好的", "客户甲") ) with_followup = no_followup + "\n客户甲 10:00:03\n还有一个问题" bot.extract_chat_text.return_value = with_followup with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"): self.assertTrue( bot._visible_reply_has_customer_followup("好的", "客户甲") ) def test_restart_only_retries_when_pre_send_surface_proves_enter_did_not_happen(self): bot = WeChatBot.__new__(WeChatBot) fp = b"u" * 40 state = { "send_state": "sending", "reply_text": "尚未发送的回复", "send_surface_signature": b"same-before-enter", } bot._visible_last_message_matches = mock.Mock(return_value=False) bot._chat_surface_signature = mock.Mock(return_value=b"same-before-enter") bot._persist_pending_replies = mock.Mock(return_value=True) self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain") self.assertEqual(state["send_state"], "uncertain") self.assertEqual(state["reply_text"], "尚未发送的回复") def test_uncertain_receipt_is_rechecked_at_low_frequency(self): bot = WeChatBot.__new__(WeChatBot) fp = b"l" * 40 state = { "send_state": "sent_uncommitted", "reply_text": "等待回执", "send_baseline_blocks": [["客户甲 10:00:00", "原问题"]], } bot._uncertain_send_last_check = {} bot._uncertain_send_last_log = {} bot._uncertain_send_last_surface = {} bot._visible_last_message_matches = mock.Mock(return_value=False) bot._try_ctrl_enter_for_retained_draft = mock.Mock(return_value=False) bot._chat_surface_signature = mock.Mock(return_value=b"stable") bot._persist_pending_replies = mock.Mock(return_value=True) with mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 101.0]): self.assertEqual( bot._reconcile_uncertain_send(fp, state), "uncertain", ) self.assertEqual( bot._reconcile_uncertain_send(fp, state), "uncertain", ) bot._visible_last_message_matches.assert_called_once() bot._try_ctrl_enter_for_retained_draft.assert_not_called() def test_uncertain_state_is_strictly_read_only_even_after_surface_change(self): bot = WeChatBot.__new__(WeChatBot) fp = b"x" * 40 state = { "send_state": "uncertain", "reply_text": "不允许再发", "send_surface_signature": b"before", "send_baseline_blocks": [["客户甲 10:00:00", "原问题"]], "uncertain_since": 1.0, } bot._uncertain_send_last_check = {} bot._uncertain_send_last_log = {} bot._uncertain_send_last_surface = {fp.hex(): b"before"} bot._chat_surface_signature = mock.Mock(return_value=b"changed") bot._send_receipt_matches = mock.Mock(return_value=False) bot._try_ctrl_enter_for_retained_draft = mock.Mock(return_value=True) bot._persist_pending_replies = mock.Mock(return_value=True) self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain") bot._send_receipt_matches.assert_called_once() bot._try_ctrl_enter_for_retained_draft.assert_not_called() def test_unknown_send_state_fails_closed_with_real_uncertain_timestamp(self): bot = WeChatBot.__new__(WeChatBot) fp = b"w" * 40 state = { "send_state": "future-v99", "reply_text": "未知事务", "uncertain_since": 0.0, } bot._chat_surface_signature = mock.Mock(return_value=b"surface") bot._send_receipt_matches = mock.Mock(return_value=False) bot._persist_pending_replies = mock.Mock(return_value=True) self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain") self.assertEqual(state["send_state"], "uncertain") self.assertGreater(state["uncertain_since"], 0.0) bot._persist_pending_replies.assert_called_once() def test_an_untouched_chat_proves_the_send_never_landed(self): """按回车前后整片聊天区一模一样 → 那一下什么都没发生,可以安全重来。 这条路以前不存在:没有表头就没有回执比对,任务永远停在"发送待核对", 而 `_check_selected_session` 每轮都在这个状态上 return False,那位客户 之后再发多少条都不会有人理。这里也不许重按回车,只是把任务放回队列。 """ bot = WeChatBot.__new__(WeChatBot) fp = b"d" * 40 state = { "send_state": "uncertain", "reply_text": "等待确认", "send_surface_signature": b"same", "uncertain_since": 123.0, } bot._uncertain_send_last_check = {} bot._uncertain_send_last_log = {} bot._uncertain_send_last_surface = {} bot._chat_surface_signature = mock.Mock(return_value=b"same") bot._send_receipt_matches = mock.Mock(return_value=False) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=None) bot._reset_pending_batch = mock.Mock() bot._pending_reply_sessions = {} bot._persist_pending_replies = mock.Mock(return_value=True) bot._log_queue_event = mock.Mock() with mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 106.0]): self.assertEqual(bot._reconcile_uncertain_send(fp, state), "unsent") # 回执比对要表头,这条路正是为"没有表头"准备的,不该被调用 bot._send_receipt_matches.assert_not_called() bot._reset_pending_batch.assert_called_once_with(fp) def test_an_undecidable_screen_is_rechecked_instead_of_rewritten(self): """判不出结果时不反复写盘,也不反复框选,就是安静地等下一轮。""" bot = WeChatBot.__new__(WeChatBot) fp = b"d" * 40 state = { "send_state": "uncertain", "reply_text": "等待确认", "send_surface_signature": b"before", "uncertain_since": time.time() - 1.0, } bot._uncertain_send_last_check = {} bot._uncertain_send_last_log = {} bot._uncertain_send_last_surface = {} bot._chat_surface_signature = mock.Mock(return_value=b"after") bot._send_receipt_matches = mock.Mock(return_value=False) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=None) bot._persist_pending_replies = mock.Mock(return_value=True) bot._log_queue_event = mock.Mock() with mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 106.0]): self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain") self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain") bot._persist_pending_replies.assert_not_called() @staticmethod def _stalled_bot(fp: bytes, state: dict, bubble=None, visible: str = ""): """一个卡在「发送待核对」上的机器人,屏幕由参数决定。""" bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_sessions = {fp.hex(): state} bot._uncertain_send_last_check = {} bot._uncertain_send_last_log = {} bot._uncertain_send_last_surface = {} bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=bubble) bot._persist_pending_replies = mock.Mock(return_value=True) bot._log_queue_event = mock.Mock() bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() bot._reset_pending_batch = mock.Mock() bot.extract_chat_text = mock.Mock(return_value=visible) return bot def test_a_waiting_customer_breaks_the_reconciliation_deadlock(self): """待核对期间客户还在等 → 放弃旧草稿去回答他,而不是把他晾在那儿。 这正是"当前这个会话来了新消息却不回复"的根子:任务卡在待核对上, `_check_selected_session` 每轮都在这个状态上 return False,新消息 连读都不会被读到。 """ fp = b"q" * 40 state = { "send_state": "uncertain", "reply_text": "旧草稿", "send_surface_signature": b"before", "uncertain_since": time.time() - 10.0, } bot = self._stalled_bot(fp, state, bubble=False) self.assertEqual( bot._resolve_stalled_send( fp, state, before_surface=b"before", current_surface=b"after", reply_text="旧草稿", ), "unsent", ) bot._reset_pending_batch.assert_called_once_with(fp) # 绝不重发那条旧草稿,只是让正常流程重新读一遍最新的话 bot._commit_staged_exchange.assert_not_called() def test_our_own_last_bubble_settles_the_task_as_sent(self): fp = b"q" * 40 state = { "send_state": "uncertain", "reply_text": "在的", "send_surface_signature": b"before", "uncertain_since": time.time() - 10.0, } bot = self._stalled_bot(fp, state, bubble=True) bot._nothing_left_to_answer = mock.Mock(return_value=True) self.assertEqual( bot._resolve_stalled_send( fp, state, before_surface=b"before", current_surface=b"after", reply_text="在的", ), "sent", ) def test_a_task_is_never_judged_before_the_screen_can_catch_up(self): """刚按下回车就断言"屏幕没变=没发出去",会冤枉刚刚发成功的那一条。""" fp = b"q" * 40 state = { "send_state": "uncertain", "reply_text": "在的", "send_surface_signature": b"same", "send_dispatched_at": time.time(), } bot = self._stalled_bot(fp, state, bubble=None) self.assertEqual( bot._resolve_stalled_send( fp, state, before_surface=b"same", current_surface=b"same", reply_text="在的", ), "", ) bot._reset_pending_batch.assert_not_called() def test_a_task_with_no_dispatch_time_is_left_alone(self): fp = b"q" * 40 state = {"send_state": "uncertain", "reply_text": "在的"} bot = self._stalled_bot(fp, state, bubble=None) self.assertEqual( bot._resolve_stalled_send(fp, state, reply_text="在的"), "" ) def test_an_unreadable_screen_never_becomes_a_second_reply(self): """一个字都读不到时不许推出"没发出去"——那正是连回三条的老路。""" fp = b"q" * 40 state = { "send_state": "uncertain", "reply_text": "在的", "send_surface_signature": b"before", "uncertain_since": time.time() - 600.0, } bot = self._stalled_bot(fp, state, bubble=None, visible="") self.assertEqual( bot._resolve_stalled_send( fp, state, before_surface=b"before", current_surface=b"after", reply_text="在的", ), "", ) bot._reset_pending_batch.assert_not_called() def test_a_reply_found_on_screen_after_the_deadline_counts_as_sent(self): fp = b"q" * 40 state = { "send_state": "uncertain", "reply_text": "您好,这边帮您看", "send_surface_signature": b"before", "uncertain_since": time.time() - 600.0, } bot = self._stalled_bot( fp, state, bubble=None, visible="客户甲 10:00\n在不在\n我 10:01\n您好,这边帮您看", ) self.assertEqual( bot._resolve_stalled_send( fp, state, before_surface=b"before", current_surface=b"after", reply_text="您好,这边帮您看", ), "sent", ) bot._commit_staged_exchange.assert_called_once() def test_a_readable_screen_without_our_reply_frees_the_customer(self): fp = b"q" * 40 state = { "send_state": "uncertain", "reply_text": "您好,这边帮您看", "send_surface_signature": b"before", "uncertain_since": time.time() - 600.0, } bot = self._stalled_bot( fp, state, bubble=None, visible="客户甲 10:00\n在不在", ) self.assertEqual( bot._resolve_stalled_send( fp, state, before_surface=b"before", current_surface=b"after", reply_text="您好,这边帮您看", ), "unsent", ) def test_an_unchanged_screen_with_our_bubble_last_is_not_called_unsent(self): """画面没变却说最后一条是我方发的,两件事对不上,宁可不下"没发出去"。""" fp = b"q" * 40 state = { "send_state": "uncertain", "reply_text": "在的", "send_surface_signature": b"same", "uncertain_since": time.time() - 10.0, } bot = self._stalled_bot(fp, state, bubble=True) bot._nothing_left_to_answer = mock.Mock(return_value=True) self.assertEqual( bot._resolve_stalled_send( fp, state, before_surface=b"same", current_surface=b"same", reply_text="在的", ), "sent", ) bot._reset_pending_batch.assert_not_called() def test_reset_pending_clears_uncertain_tracking_maps(self): bot = WeChatBot.__new__(WeChatBot) fp = b"m" * 40 key = fp.hex() bot._pending_reply_sessions = {key: {"send_state": "uncertain"}} bot._uncertain_send_last_check = {key: 1.0} bot._uncertain_send_last_log = {key: 1.0} bot._uncertain_send_last_surface = {key: b"surface"} bot._persist_pending_replies = mock.Mock(return_value=True) bot._reset_pending_batch(fp) self.assertNotIn(key, bot._uncertain_send_last_check) self.assertNotIn(key, bot._uncertain_send_last_log) self.assertNotIn(key, bot._uncertain_send_last_surface) def test_reset_pending_batch_drops_the_cached_question_text(self): """放弃旧草稿必须连缓存的旧问题一起丢,下一轮才不会拿它再答一遍。 2026-07-31 现场:回执没确认 → 对账「放弃旧草稿改答最新消息」→ 重新 生成时提取恰好失败(人在动鼠标)→ 生成流程复用了缓存里上一轮的 「你多大了」,同一句回复被原样再发一遍,客户刚问的新问题没进模型。 """ bot = WeChatBot.__new__(WeChatBot) fp = b"m" * 40 key = fp.hex() bot._pending_reply_sessions = {key: { "send_state": "uncertain", "chat_text": "高瑞@微信@微信联系人 7/31 18:13\n你多大了", "reply_text": "四十来岁啦,怎么突然问这个?", }} bot._persist_pending_replies = mock.Mock(return_value=True) bot._reset_pending_batch(fp) self.assertNotIn("chat_text", bot._pending_reply_sessions[key]) def test_uncertain_active_task_does_not_block_next_pending_session(self): bot = WeChatBot.__new__(WeChatBot) uncertain_fp = b"a" * 40 next_fp = b"b" * 40 bot._pending_reply_sessions = { uncertain_fp.hex(): { "send_state": "uncertain", "identity_signature": b"active-title", }, next_fp.hex(): { "confirmed_unread": True, "identity_signature": b"other-title", }, } bot._active_session_fp = uncertain_fp bot._active_identity_signature = b"active-title" bot._chat_identity_signature = mock.Mock(return_value=b"active-title") bot._reconcile_uncertain_send = mock.Mock(return_value="uncertain") bot._pending_scan_progress = {} bot._pending_scan_incomplete = False bot._persist_pending_replies = mock.Mock(return_value=True) bot._send_gate_open = mock.Mock(return_value=True) bot._find_pending_session = mock.Mock(return_value=None) self.assertFalse(bot._resume_orphaned_pending_reply()) bot._find_pending_session.assert_called_once_with(next_fp) def test_offscreen_uncertain_head_never_starts_deep_scan(self): bot = WeChatBot.__new__(WeChatBot) uncertain_fp = b"o" * 40 next_fp = b"n" * 40 bot._pending_reply_sessions = { uncertain_fp.hex(): { "send_state": "uncertain", "identity_signature": b"old-title", }, next_fp.hex(): { "confirmed_unread": True, "identity_signature": b"next-title", }, } bot._active_session_fp = None bot._active_identity_signature = None bot._chat_identity_signature = mock.Mock(return_value=b"") bot._pending_scan_progress = {} bot._pending_scan_incomplete = False bot._persist_pending_replies = mock.Mock(return_value=True) bot._send_gate_open = mock.Mock(return_value=True) bot._find_pending_session = mock.Mock(return_value=None) self.assertFalse(bot._resume_orphaned_pending_reply()) bot._find_pending_session.assert_called_once_with(next_fp) def test_offscreen_dispatched_head_never_blocks_normal_pending(self): bot = WeChatBot.__new__(WeChatBot) dispatched_fp = b"s" * 40 next_fp = b"p" * 40 bot._pending_reply_sessions = { dispatched_fp.hex(): { "send_state": "sent_uncommitted", "identity_signature": b"old-title", }, next_fp.hex(): { "confirmed_unread": True, "identity_signature": b"next-title", }, } bot._active_session_fp = None bot._active_identity_signature = None bot._chat_identity_signature = mock.Mock(return_value=b"") bot._pending_scan_progress = {} bot._pending_scan_incomplete = False bot._persist_pending_replies = mock.Mock(return_value=True) bot._send_gate_open = mock.Mock(return_value=True) bot._find_pending_session = mock.Mock(return_value=None) self.assertFalse(bot._resume_orphaned_pending_reply()) bot._find_pending_session.assert_called_once_with(next_fp) def test_active_sent_uncommitted_is_reconciled_before_model_call(self): bot = WeChatBot.__new__(WeChatBot) fp = b"a" * 40 bot._pending_reply_sessions = { fp.hex(): { "send_state": "sent_uncommitted", "reply_text": "已发出的回复", "identity_signature": b"same-title", } } bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot.detect_selected_row = mock.Mock(return_value=-1) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_surface_signature = mock.Mock(return_value=b"surface") bot._ensure_session_archive_key = mock.Mock() bot._selected_tracking_initialized = True bot._active_session_fp = fp bot._active_identity_signature = b"same-title" bot._active_chat_signature = b"surface" bot._reconcile_uncertain_send = mock.Mock(return_value="sent") bot._generate_ai_reply = mock.Mock() bot.send_reply = mock.Mock() self.assertTrue(bot._check_selected_session(np.zeros((40, 40, 4), dtype=np.uint8))) bot._reconcile_uncertain_send.assert_called_once() bot._generate_ai_reply.assert_not_called() bot.send_reply.assert_not_called() def test_selected_pending_title_render_drift_still_reaches_reply_flow(self): bot = WeChatBot.__new__(WeChatBot) fp = b"d" * 40 state = { "batch_ready": True, "confirmed_unread": True, "identity_signature": b"old-title-render", } bot._pending_reply_sessions = {fp.hex(): state} bot._pending_reply_path = "" bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot.detect_selected_row = mock.Mock(return_value=24) bot._session_fingerprint = mock.Mock(return_value=fp) bot._flat_row_requires_visual_proof = mock.Mock(return_value=False) bot._is_tool_selected = mock.Mock(return_value=False) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"new-title-render") bot._chat_surface_signature = mock.Mock(return_value=b"new-chat-surface") bot._selected_tracking_initialized = True bot._active_session_fp = fp bot._active_identity_signature = b"old-title-render" bot._active_chat_signature = b"old-chat-surface" bot._ensure_session_archive_key = mock.Mock() bot._persist_pending_replies = mock.Mock(return_value=True) bot._reconcile_uncertain_send = mock.Mock(return_value="unsent") bot._activate_wx = mock.Mock(return_value=True) bot.extract_chat_text = mock.Mock( return_value="客户甲 10:00:00\n请回复这条新消息" ) bot._has_pending_customer_message = mock.Mock(return_value=True) bot._send_gate_open = mock.Mock(return_value=True) bot._generate_ai_reply = mock.Mock(return_value="已经正常回复") bot.send_reply = mock.Mock(return_value=True) with mock.patch("wechat_bot.time.sleep"): self.assertTrue( bot._check_selected_session( np.zeros((80, 80, 4), dtype=np.uint8) ) ) self.assertEqual(state["identity_signature"], b"new-title-render") bot._generate_ai_reply.assert_called_once() bot.send_reply.assert_called_once_with( "已经正常回复", session_id=fp.hex(), expected_fp=fp, ) def test_enter_without_visible_receipt_never_commits_or_clears(self): bot = WeChatBot.__new__(WeChatBot) fp = b"n" * 40 bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "identity_signature": b"same-title", "generation_surface_signature": b"stable-chat", "last_lines": ["客户甲 10:00:00", "没有真正发出前的问题"], } } bot._pending_reply_path = "" bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat") bot._active_identity_signature = b"same-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._input_geometry_valid = True bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock(side_effect=["", "没有真正发出"]) bot._persist_pending_replies = mock.Mock(return_value=True) bot._visible_last_message_matches = mock.Mock(return_value=False) bot._try_ctrl_enter_for_retained_draft = mock.Mock(return_value=False) bot._record_send = mock.Mock() bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.pyautogui.hotkey"), mock.patch("wechat_bot.pyautogui.press"), mock.patch("wechat_bot.pyperclip.paste", return_value=""), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.time.sleep"), ): self.assertFalse( bot.send_reply("没有真正发出", session_id=fp.hex(), expected_fp=fp) ) self.assertEqual(bot._pending_reply_state(fp)["send_state"], "uncertain") bot._record_send.assert_called_once_with(fp.hex()) bot._commit_staged_exchange.assert_not_called() bot._clear_reply_pending.assert_not_called() def test_ctrl_enter_is_used_only_after_retained_draft_is_proven(self): bot = WeChatBot.__new__(WeChatBot) fp = b"c" * 40 bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "identity_signature": b"same-title", "generation_surface_signature": b"stable-chat", "last_lines": ["客户甲 10:00:00", "兼容发送前的问题"], } } bot._pending_reply_path = "" bot._security_gate_visible = mock.Mock(return_value=False) bot._send_gate_open = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat") bot._active_identity_signature = b"same-title" bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._input_geometry_valid = True bot.input_x = 800 bot.input_y = 700 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._read_input_draft = mock.Mock(side_effect=["", "兼容发送"]) bot._persist_pending_replies = mock.Mock(return_value=True) bot._send_receipt_matches = mock.Mock(side_effect=[False, True]) bot._last_send_receipt_followup = False bot._try_ctrl_enter_for_retained_draft = mock.Mock(return_value=True) bot._visible_reply_has_customer_followup = mock.Mock(return_value=False) bot._record_send = mock.Mock() bot._commit_staged_exchange = mock.Mock() bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.pyautogui.hotkey"), mock.patch("wechat_bot.pyautogui.press"), mock.patch("wechat_bot.pyperclip.paste", return_value=""), mock.patch("wechat_bot.pyperclip.copy"), mock.patch("wechat_bot.time.sleep"), ): self.assertTrue( bot.send_reply("兼容发送", session_id=fp.hex(), expected_fp=fp) ) bot._try_ctrl_enter_for_retained_draft.assert_called_once() bot._commit_staged_exchange.assert_called_once_with(fp.hex()) def test_internal_modal_is_closed_with_escape_before_clicking_x(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot.L = 20 bot.T = 30 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._capture_full_window = mock.Mock( side_effect=[self._modal_surface(), self._modal_surface(False)] ) bot._message_nav_selected = mock.Mock(return_value=True) with ( mock.patch("wechat_bot.safe_set_foreground"), mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=bot.hwnd), mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.pyautogui.click") as click, mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot._dismiss_internal_blocker("测试")) press.assert_called_once_with("esc") click.assert_not_called() def test_non_message_page_is_never_treated_as_internal_modal(self): bot = WeChatBot.__new__(WeChatBot) bot._capture_full_window = mock.Mock(return_value=self._modal_surface()) bot._message_nav_selected = mock.Mock(return_value=False) with mock.patch("wechat_bot.find_blocking_modal_close") as detector: self.assertFalse(bot._dismiss_internal_blocker("测试")) detector.assert_not_called() def test_independent_wecom_document_window_is_not_force_closed(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=200), mock.patch( "wechat_bot.win32process.GetWindowThreadProcessId", return_value=(0, 77), ), mock.patch("wechat_bot.win32gui.IsWindowVisible", return_value=True), mock.patch("wechat_bot.win32gui.GetWindow", return_value=0), mock.patch("wechat_bot.win32gui.GetClassName", return_value="WeWorkWindow"), mock.patch("wechat_bot.win32gui.GetWindowText", return_value="智能文档"), mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.win32gui.PostMessage") as close, ): self.assertFalse(bot._dismiss_owned_blocking_window()) press.assert_not_called() close.assert_not_called() def test_login_verification_child_window_is_never_closed(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=200), mock.patch( "wechat_bot.win32process.GetWindowThreadProcessId", return_value=(0, 77), ), mock.patch("wechat_bot.win32gui.IsWindowVisible", return_value=True), mock.patch("wechat_bot.win32gui.GetWindow", return_value=100), mock.patch("wechat_bot.win32gui.GetClassName", return_value="#32770"), mock.patch("wechat_bot.win32gui.GetWindowText", return_value="企业微信登录验证"), mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.win32gui.PostMessage") as close, ): self.assertFalse(bot._dismiss_owned_blocking_window()) press.assert_not_called() close.assert_not_called() def test_stubborn_owned_dialog_is_blocked_but_not_reclosed_every_poll(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 105.0]), mock.patch("wechat_bot.time.sleep"), mock.patch("wechat_bot.safe_set_foreground", return_value=True), mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=200), mock.patch( "wechat_bot.win32process.GetWindowThreadProcessId", return_value=(0, 77), ), mock.patch("wechat_bot.win32gui.IsWindowVisible", return_value=True), mock.patch("wechat_bot.win32gui.IsWindow", return_value=True), mock.patch("wechat_bot.win32gui.GetWindow", return_value=100), mock.patch("wechat_bot.win32gui.GetClassName", return_value="#32770"), mock.patch("wechat_bot.win32gui.GetWindowText", return_value="发送文档"), mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.win32gui.PostMessage") as close, ): self.assertTrue(bot._dismiss_owned_blocking_window()) self.assertTrue(bot._dismiss_owned_blocking_window()) press.assert_called_once_with("esc") close.assert_called_once_with(200, mock.ANY, 0, 0) def test_stubborn_internal_modal_freezes_poll_without_repeated_escape(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot.L = 10 bot.T = 20 bot._capture_full_window = mock.Mock(return_value=self._modal_surface()) bot._message_nav_selected = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 105.0]), mock.patch("wechat_bot.time.sleep"), mock.patch("wechat_bot.safe_set_foreground", return_value=True), mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100), mock.patch( "wechat_bot.find_blocking_modal_close", return_value=(870, 175), ), mock.patch("wechat_bot.pyautogui.press") as press, mock.patch("wechat_bot.pyautogui.click") as click, ): self.assertTrue(bot._dismiss_internal_blocker("测试")) self.assertTrue(bot._dismiss_internal_blocker("测试")) press.assert_called_once_with("esc") click.assert_called_once_with(880, 195) def test_page_restore_keeps_pending_and_next_poll_reopens_it(self): bot = WeChatBot.__new__(WeChatBot) fp = b"p" * 40 state = { "batch_ready": True, "confirmed_unread": True, "chat_text": "客户甲 10:00:00\n还没有回复的问题", } bot.scale = 1.0 bot._pending_reply_sessions = {fp.hex(): state} bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock( side_effect=[self._nav_surface(False), self._nav_surface(True)] ) bot._ensure_message_workspace = mock.Mock(return_value=True) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock(return_value=np.zeros((64, 80, 4), dtype=np.uint8)) bot._check_selected_session = mock.Mock(return_value=False) bot._resume_orphaned_pending_reply = mock.Mock(return_value=True) bot.send_reply = mock.Mock() bot._poll_once() # 邮件/文档/加载页:只恢复消息入口。 self.assertIs(bot._pending_reply_sessions[fp.hex()], state) bot._resume_orphaned_pending_reply.assert_not_called() bot._poll_once() # 下一轮已回消息页:恢复原已读未回复任务。 bot._resume_orphaned_pending_reply.assert_called_once() self.assertIs(bot._pending_reply_sessions[fp.hex()], state) bot.send_reply.assert_not_called() def test_pending_persist_failure_prevents_unread_click(self): """Never consume the unread badge when its recovery task is not durable.""" bot = WeChatBot.__new__(WeChatBot) fp = b"d" * 40 message_page = self._nav_surface(True) session_page = np.zeros((180, 460, 4), dtype=np.uint8) bot.scale = 1.0 bot.session_item_h = 64 bot.list_region = {"top": 100} bot.list_click_x = 200 bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot._flat_visual_proof_fps = set() bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=message_page) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock(return_value=session_page) bot._check_selected_session = mock.Mock(return_value=False) bot._resume_orphaned_pending_reply = mock.Mock(return_value=False) bot._find_next_unread_session = mock.Mock( return_value=(session_page, 16, fp) ) bot._send_gate_open = mock.Mock(return_value=True) bot._mark_reply_pending = mock.Mock(return_value=False) bot._activate_wx = mock.Mock(return_value=True) bot.click_session = mock.Mock(return_value=True) bot._poll_once() bot._mark_reply_pending.assert_called_once_with( fp, confirmed_unread=True, requires_visual_proof=False, bind_identity=False, ) bot._activate_wx.assert_not_called() bot.click_session.assert_not_called() def test_selected_mismatch_keeps_preclick_pending_on_disk(self): """A post-click render mismatch must not lose the now-read message.""" with tempfile.TemporaryDirectory() as directory: bot = WeChatBot.__new__(WeChatBot) fp = b"m" * 40 message_page = self._nav_surface(True) session_page = np.zeros((180, 460, 4), dtype=np.uint8) bot.scale = 1.0 bot.session_item_h = 64 bot.list_region = {"top": 100} bot.list_click_x = 200 bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot._flat_visual_proof_fps = set() bot._pending_reply_path = os.path.join( directory, "pending_replies.json", ) bot._pending_reply_sessions = {} bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=message_page) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock(return_value=session_page) bot._check_selected_session = mock.Mock(return_value=False) bot._resume_orphaned_pending_reply = mock.Mock(return_value=False) bot._find_next_unread_session = mock.Mock( return_value=(session_page, 16, fp) ) bot._send_gate_open = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) bot._last_click_failure_reason = "selected_fingerprint_mismatch" bot.click_session = mock.Mock(return_value=False) bot._ensure_message_workspace = mock.Mock(return_value=True) bot._poll_once() state = bot._pending_reply_state(fp) self.assertIsNotNone(state) self.assertTrue(state["confirmed_unread"]) self.assertNotIn("identity_signature", state) # 指纹对不上多半是列表在识别和点击之间重排了,等一下重定位再点一次; # 两次都不行才跳过它去服务其他未读会话(这里的桩固定返回同一个目标, # 真实扫描会因为 processed_fp 而换人) self.assertEqual( bot.click_session.call_args_list[:2], [mock.call(16, expected_fp=fp)] * 2, ) with open(bot._pending_reply_path, encoding="utf-8") as handle: persisted = json.load(handle) self.assertTrue(persisted[fp.hex()]["confirmed_unread"]) def test_confirmed_unread_pending_older_than_24_hours_is_restored(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "pending_replies.json") fp = b"o" * 40 with open(path, "w", encoding="utf-8") as handle: json.dump( { fp.hex(): { "confirmed_unread": True, "batch_ready": False, "updated_at": 1.0, } }, handle, ) bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_path = path with mock.patch("wechat_bot.time.time", return_value=200000.0): restored = bot._load_pending_replies() self.assertIn(fp.hex(), restored) self.assertTrue(restored[fp.hex()]["confirmed_unread"]) def test_startup_archive_delta_creates_pending_and_enters_retry(self): """A selected read message newer than its archive snapshot is recoverable.""" bot = WeChatBot.__new__(WeChatBot) fp = b"a" * 40 title = b"customer-title" session_page = np.zeros((180, 460, 4), dtype=np.uint8) visible_text = ( "Agent 09:00:00\nold reply\n" "Customer 09:01:00\nnew question" ) bot.scale = 1.0 bot.session_item_h = 64 bot._selected_tracking_initialized = False bot._active_session_fp = None bot._active_identity_signature = None bot._active_chat_signature = None bot._pending_reply_path = "" bot._pending_reply_sessions = {} bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot.detect_selected_row = mock.Mock(return_value=32) bot._chat_identity_signature = mock.Mock(return_value=title) bot._session_fingerprint = mock.Mock(return_value=fp) bot._flat_row_requires_visual_proof = mock.Mock(return_value=False) bot._is_tool_selected = mock.Mock(return_value=False) bot._ensure_session_archive_key = mock.Mock() bot._chat_surface_signature = mock.Mock(return_value=b"surface") bot._activate_wx = mock.Mock(return_value=True) bot.extract_chat_text = mock.Mock(return_value=visible_text) bot.store = mock.Mock() bot.store.has_record.return_value = True bot.store.last_lines.return_value = [ "Agent 09:00:00", "old reply", ] bot.store.history.return_value = [] bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False) bot._wait_for_message_batch = mock.Mock(return_value=False) bot._generate_ai_reply = mock.Mock(return_value="must not run") bot.send_reply = mock.Mock(return_value=True) with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse(bot._check_selected_session(session_page)) state = bot._pending_reply_state(fp) self.assertIsNotNone(state) self.assertEqual(state["identity_signature"], title) bot._wait_for_message_batch.assert_called_once_with(fp) bot._generate_ai_reply.assert_not_called() bot.send_reply.assert_not_called() click.assert_not_called() def test_restart_binds_bold_unread_pending_to_selected_regular_render(self): """Persisted render metadata recovers one task without changing its key.""" with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "pending_replies.json") unread = np.full((160, 460, 4), 245, dtype=np.uint8) selected = np.full( (160, 460, 4), (210, 130, 45, 255), dtype=np.uint8, ) unread[:, :, 3] = 255 for image in (unread, selected): for y in range(40, 88): for x in range(20, 84): image[y, x, :3] = ( (x * 3) % 255, (y * 5) % 255, (x + y) % 255, ) # The same two glyph centre-lines use WeCom's unread-bold and # selected-regular weights respectively. unread[27:56, 178:187, :3] = 20 unread[34:44, 168:204, :3] = 20 unread[28:56, 224:233, :3] = 20 unread[45:55, 216:246, :3] = 20 selected[29:54, 181:184, :3] = 250 selected[37:40, 171:201, :3] = 250 selected[30:54, 227:230, :3] = 250 selected[48:51, 219:243, :3] = 250 # Same avatar, different first name glyph. It must never inherit # the original task even though its avatar hash is identical. distractor = selected.copy() distractor[48:55, 126:142, :3] = 250 distractor[51:58, 146:158, :3] = 250 producer = WeChatBot.__new__(WeChatBot) producer.scale = 2.0 producer.session_item_h = 128 producer._known_fps = set() producer._known_session_fps = set() producer._pending_reply_path = path producer._pending_reply_sessions = {} unread_fp = producer._session_fingerprint( unread, 64, row_center=True, ) distractor_fp = producer._session_fingerprint( distractor, 64, row_center=True, ) self.assertEqual(unread_fp[:8], distractor_fp[:8]) self.assertNotEqual(unread_fp, distractor_fp) self.assertFalse( producer._live_render_transition_match( unread_fp, distractor_fp, ) ) self.assertTrue( producer._mark_reply_pending( unread_fp, confirmed_unread=True, bind_identity=False, ) ) with open(path, encoding="utf-8") as handle: persisted = json.load(handle) self.assertTrue( persisted[unread_fp.hex()]["render_identities"] ) # Simulate a fresh process: only the pending task and its render # metadata survive; live alias pairs deliberately do not. bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 bot._pending_reply_path = path bot._pending_reply_sessions = bot._load_pending_replies() bot._session_render_ids = { key: set(state.get("render_identities") or []) for key, state in bot._pending_reply_sessions.items() } bot._known_fps = {unread_fp[:8]} bot._known_session_fps = {unread_fp} bot._live_session_fp_aliases = set() bot._selected_tracking_initialized = False bot._active_session_fp = None bot._active_identity_signature = None bot._active_chat_signature = None bot._capture_full_window = mock.Mock( return_value=self._nav_surface(True) ) bot._message_nav_selected = mock.Mock(return_value=True) bot.detect_selected_row = mock.Mock(return_value=64) bot._chat_identity_signature = mock.Mock( return_value=b"original-customer-title" ) bot._flat_row_requires_visual_proof = mock.Mock(return_value=False) bot._is_tool_selected = mock.Mock(return_value=False) bot._ensure_session_archive_key = mock.Mock() bot._chat_surface_signature = mock.Mock(return_value=b"surface") bot._activate_wx = mock.Mock(return_value=True) bot.extract_chat_text = mock.Mock( return_value="Customer 09:01:00\nnew question" ) bot._has_pending_customer_message = mock.Mock(return_value=True) bot.store = mock.Mock() bot._wait_for_message_batch = mock.Mock(return_value=False) bot._generate_ai_reply = mock.Mock(return_value="must not run") bot.send_reply = mock.Mock(return_value=True) with mock.patch("wechat_bot.pyautogui.click") as click: self.assertFalse(bot._check_selected_session(selected)) selected_fp = bot._session_fingerprint( selected, 64, row_center=True, ) restarted_distractor_fp = bot._session_fingerprint( distractor, 64, row_center=True, ) self.assertNotEqual(selected_fp, unread_fp) self.assertTrue(bot._session_fp_matches(selected_fp, unread_fp)) self.assertFalse( bot._session_fp_matches(restarted_distractor_fp, unread_fp) ) self.assertEqual( set(bot._pending_reply_sessions), {unread_fp.hex()}, ) state = bot._pending_reply_sessions[unread_fp.hex()] self.assertEqual( state["identity_signature"], b"original-customer-title", ) bot._wait_for_message_batch.assert_called_once_with(unread_fp) bot._generate_ai_reply.assert_not_called() bot.send_reply.assert_not_called() click.assert_not_called() def test_flat_system_entry_clears_the_preclick_pending_reservation(self): bot = WeChatBot.__new__(WeChatBot) fp = b"f" * 40 message_page = self._nav_surface(True) non_message_page = self._nav_surface(False) session_page = np.zeros((180, 460, 4), dtype=np.uint8) bot.scale = 1.0 bot.session_item_h = 64 bot.list_region = {"top": 100} bot.list_click_x = 200 bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot._flat_visual_proof_fps = {fp.hex()} bot._flat_rejected_session_fps = set() bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock( side_effect=[message_page, message_page, non_message_page, message_page] ) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock(return_value=session_page) bot._check_selected_session = mock.Mock(return_value=False) bot._resume_orphaned_pending_reply = mock.Mock(return_value=False) bot._find_next_unread_session = mock.Mock( side_effect=[(session_page, 32, fp), None] ) bot._send_gate_open = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) # click_session now proves a navigation exit with two post-click frames # and reports the reason to the poller. This mock bypasses that method, # so provide the proved outcome explicitly. bot._last_click_failure_reason = "left_message_workspace" bot.click_session = mock.Mock(return_value=False) bot._ensure_message_workspace = mock.Mock(return_value=True) bot._mark_reply_pending = mock.Mock() bot.send_reply = mock.Mock() bot._poll_once() self.assertIn(fp.hex(), bot._flat_rejected_session_fps) self.assertNotIn(fp.hex(), bot._flat_visual_proof_fps) bot._ensure_message_workspace.assert_called_once_with("系统入口识别") bot._mark_reply_pending.assert_called_once_with( fp, confirmed_unread=True, requires_visual_proof=True, bind_identity=False, ) bot.send_reply.assert_not_called() def test_real_wechat_external_avatar_survives_transient_nav_false(self): """The green @微信 default avatar is a contact, not a system entry.""" # Programmatic copy of the 200% DPI field geometry. Never use # debug_skipped_row0.png as a fixture: production deliberately # overwrites that diagnostic file whenever another row is skipped. sample = np.full((100, 116, 4), 245, dtype=np.uint8) sample[:, :, 3] = 255 selected_blue = np.array((242, 131, 48), dtype=np.uint8) sample[:, 8:, :3] = selected_blue for y in range(4, 96): for x in range(20, 112): sample[y, x, :3] = ( 35 + ((x + y) % 55), 125 + ((2 * x + y) % 105), 25 + ((x + 3 * y) % 65), ) sample[0:10, 16:28, :3] = selected_blue sample[0:10, 104:116, :3] = selected_blue sample[90:100, 16:28, :3] = selected_blue sample[90:100, 104:116, :3] = selected_blue probe = WeChatBot.__new__(WeChatBot) probe.scale = 2.0 probe.session_item_h = 128 # This field sample is WeCom's green default avatar for an external # ``@微信`` contact. It is gradient-rich and is accepted as a real row # even though it resembles some built-in application icons. self.assertFalse(probe._is_flat_icon(sample, sample.shape[0] // 2)) self.assertTrue( probe._is_real_conversation( sample, sample.shape[0] // 2, quiet=True, row_center=True, ) ) bot = WeChatBot.__new__(WeChatBot) customer_fp = b"w" * 40 message_page = self._nav_surface(True) unread_page = np.full((180, 460, 4), 245, dtype=np.uint8) unread_page[:, :, 3] = 255 unread_page[: sample.shape[0], : sample.shape[1]] = sample selected_page = unread_page.copy() # After clicking, the badge disappears and the row/preview becomes # selected. These changes are independent of the navigation highlight. selected_page[8:30, 94:122, :3] = 245 selected_page[0:128, 130:350, :3] = (238, 224, 210) bot.scale = 2.0 bot.session_item_h = 128 bot.list_region = {"top": 100} bot.list_click_x = 200 bot._session_geometry_valid = True bot.L, bot.T, bot.R, bot.B = 0, 0, 1000, 800 bot.input_x, bot.input_y = 760, 700 bot._input_geometry_valid = True bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot._flat_visual_proof_fps = set() bot._flat_verified_session_fps = set() bot._flat_rejected_session_fps = set() bot._active_session_fp = None title_signature = "一个小迷糊@微信".encode("utf-8") bot._active_identity_signature = title_signature bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=message_page) # Poll precheck and click precheck are correct. The first post-click # navigation read is a false negative during repaint; the confirming # read succeeds while the selected conversation and input remain valid. bot._message_nav_selected = mock.Mock( side_effect=[True, True, False, True] ) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock( side_effect=[unread_page, unread_page, selected_page] ) bot._check_selected_session = mock.Mock(return_value=False) bot._resume_orphaned_pending_reply = mock.Mock(return_value=False) bot._find_next_unread_session = mock.Mock( side_effect=[(unread_page, 16, customer_fp), None] ) bot._send_gate_open = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) bot.detect_badge_rows = mock.Mock(return_value=[16]) bot._session_fingerprint = mock.Mock(return_value=customer_fp) bot.detect_selected_row = mock.Mock(return_value=64) bot._is_tool_selected = mock.Mock(return_value=False) bot._chat_identity_signature = mock.Mock( return_value=title_signature ) bot._remember_active_surface = mock.Mock() bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._run_ai_page_guard = mock.Mock(return_value=False) bot._escape_proven_blocker = mock.Mock(return_value=False) bot._ensure_message_workspace = mock.Mock(return_value=True) bot._mark_reply_pending = mock.Mock() bot._wait_for_message_batch = mock.Mock(return_value=True) bot._generate_ai_reply = mock.Mock(return_value="已回复外部联系人") bot.send_reply = mock.Mock(return_value=True) with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.time.sleep"), ): bot._poll_once() self.assertNotIn(customer_fp.hex(), bot._flat_rejected_session_fps) bot._ensure_message_workspace.assert_not_called() bot._mark_reply_pending.assert_any_call( customer_fp, confirmed_unread=True, requires_visual_proof=False, ) bot._generate_ai_reply.assert_called_once_with( customer_fp, confirmed_unread=True, ) bot.send_reply.assert_called_once_with( "已回复外部联系人", session_id=customer_fp.hex(), expected_fp=customer_fp, ) def test_flat_fingerprint_mismatch_is_not_reclassified_as_system_entry(self): """A row-render mismatch must retry, never permanently blacklist a contact.""" bot = WeChatBot.__new__(WeChatBot) fp = b"m" * 40 message_page = self._nav_surface(True) session_page = np.zeros((180, 460, 4), dtype=np.uint8) bot.scale = 1.0 bot.session_item_h = 64 bot.list_region = {"top": 100} bot.list_click_x = 200 bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot._flat_visual_proof_fps = {fp.hex()} bot._flat_rejected_session_fps = set() bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=message_page) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock(return_value=session_page) bot._check_selected_session = mock.Mock(return_value=False) bot._resume_orphaned_pending_reply = mock.Mock(return_value=False) bot._find_next_unread_session = mock.Mock( return_value=(session_page, 16, fp) ) bot._send_gate_open = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) bot._last_click_failure_reason = "selected_fingerprint_mismatch" bot.click_session = mock.Mock(return_value=False) bot._ensure_message_workspace = mock.Mock(return_value=True) bot._mark_reply_pending = mock.Mock() bot._poll_once() self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps) self.assertIn(fp.hex(), bot._flat_visual_proof_fps) bot._ensure_message_workspace.assert_not_called() # 关键是这个联系人没被拉黑、任务照常落盘;至于本轮重试几次由排队策略决定 self.assertEqual( bot._mark_reply_pending.call_args, mock.call( fp, confirmed_unread=True, requires_visual_proof=True, bind_identity=False, ), ) def test_flat_real_row0_same_open_title_enters_visual_reply_flow(self): """A selected real chat can keep the same title when its unread row is clicked.""" bot = WeChatBot.__new__(WeChatBot) fp = b"r" * 40 message_page = self._nav_surface(True) unread_page = np.zeros((180, 460, 4), dtype=np.uint8) selected_page = unread_page.copy() # Model the two visible changes from the real log: the unread badge is # gone and row0 now has selected/updated preview pixels. The chat title # legitimately remains unchanged because this conversation was already # open when its new message arrived. unread_page[12:24, 105:117, :3] = (70, 70, 245) selected_page[0:64, 120:300, :3] = (238, 224, 210) bot.scale = 1.0 bot.session_item_h = 64 bot.list_region = {"top": 100} bot.list_click_x = 200 bot._session_geometry_valid = True bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot._flat_visual_proof_fps = {fp.hex()} bot._flat_verified_session_fps = set() bot._flat_rejected_session_fps = set() bot._active_session_fp = None bot._active_identity_signature = b"same-title" bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=message_page) bot._message_nav_selected = mock.Mock(return_value=True) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock( side_effect=[unread_page, unread_page, selected_page] ) bot._check_selected_session = mock.Mock(return_value=False) bot._resume_orphaned_pending_reply = mock.Mock(return_value=False) bot._find_next_unread_session = mock.Mock( side_effect=[(unread_page, 16, fp), None] ) bot._send_gate_open = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) bot.detect_badge_rows = mock.Mock(return_value=[16]) bot._session_fingerprint = mock.Mock(return_value=fp) bot.detect_selected_row = mock.Mock(return_value=32) bot._is_tool_selected = mock.Mock(return_value=False) bot._chat_identity_signature = mock.Mock(return_value=b"same-title") bot._remember_active_surface = mock.Mock() bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._run_ai_page_guard = mock.Mock(return_value=False) bot._escape_proven_blocker = mock.Mock(return_value=False) bot._ensure_message_workspace = mock.Mock(return_value=True) bot._mark_reply_pending = mock.Mock() bot._wait_for_message_batch = mock.Mock(return_value=True) bot._generate_ai_reply = mock.Mock(return_value="视觉确认后的回复") bot.send_reply = mock.Mock(return_value=True) with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.time.sleep"), ): bot._poll_once() self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps) self.assertIn(fp.hex(), bot._flat_visual_proof_fps) bot._ensure_message_workspace.assert_not_called() bot._mark_reply_pending.assert_any_call( fp, confirmed_unread=True, requires_visual_proof=True, ) bot._generate_ai_reply.assert_called_once_with( fp, confirmed_unread=True, ) bot.send_reply.assert_called_once_with( "视觉确认后的回复", session_id=fp.hex(), expected_fp=fp, ) def test_full_business_page_returns_to_messages_without_blind_escape(self): bot = WeChatBot.__new__(WeChatBot) surface = self._nav_surface(False) bot._message_nav_selected = mock.Mock(return_value=False) bot._security_gate_visible = mock.Mock(return_value=False) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._open_messages_page = mock.Mock(return_value=True) bot._run_ai_page_guard = mock.Mock(return_value=False) with mock.patch("wechat_bot.pyautogui.press") as press: self.assertTrue(bot._ensure_message_workspace("轮询前", full=surface)) press.assert_not_called() bot._open_messages_page.assert_called_once_with( "轮询前检测到当前不在消息页," ) bot._run_ai_page_guard.assert_not_called() def test_open_messages_uses_safe_message_row_in_wide_navigation(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot.scale = 2.0 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._message_page_misses = 3 bot._selected_tracking_initialized = True bot._active_session_fp = b"old" bot._active_chat_signature = b"old" bot._active_identity_signature = b"old" with ( mock.patch("wechat_bot.safe_set_foreground"), mock.patch( "wechat_bot.win32gui.GetWindowRect", return_value=(400, 300, 2600, 1600), ), mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100), mock.patch("wechat_bot.pyautogui.click") as click, mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot._open_messages_page("测试:")) # 回退布局下“消息”行为逻辑 y 65~105,点击行中心 85(物理 170)。 click.assert_called_once_with(468, 470) @staticmethod def _wide_nav_surface_with_profile_block(): """宽文字导航,且“消息”不在第一位:上方还有个人资料块。""" image = np.full((700, 900, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 # 导航列浅蓝灰底色(BGR),宽 300 物理像素;右侧是白色会话列表。 image[:, :300, :3] = (250, 232, 215) # 个人资料块:头像 + 名字文字(深色内容)。 image[40:104, 20:84, :3] = (90, 90, 90) image[56:88, 100:220, :3] = (60, 60, 60) # “消息”行:图标 + 文字。 image[264:300, 40:76, :3] = (238, 126, 36) image[268:296, 100:170, :3] = (60, 60, 60) # “邮件”行。 image[340:376, 40:76, :3] = (120, 120, 120) image[344:372, 100:170, :3] = (60, 60, 60) return image def test_message_nav_band_located_by_text_when_not_first(self): """“消息”上方有个人资料块时,靠 OCR 文字定位它的行。""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 reader = mock.Mock() reader.available = True reader.read_raw = mock.Mock(side_effect=[("高兴亮", 0.9), ("消息1", 0.95)]) bot._name_reader_instance = reader surface = self._wide_nav_surface_with_profile_block() y1, y2, located = bot._message_nav_band(surface) self.assertTrue(located) self.assertLessEqual(y1, 268) self.assertGreaterEqual(y2, 296) # 缓存生效:同尺寸画面不再触发 OCR。 bot._message_nav_band(surface) self.assertEqual(reader.read_raw.call_count, 2) def test_message_nav_band_falls_back_without_wide_text_column(self): """窄图标栏 / 找不到导航分界时回退旧的固定位置,不加载 OCR。""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 image = np.full((700, 900, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 y1, y2, located = bot._message_nav_band(image) self.assertFalse(located) self.assertEqual((y1, y2), (130, 210)) # 回退路径绝不能触碰 OCR 模型加载。 self.assertIsNone(getattr(bot, "_name_reader_instance", None)) def test_message_nav_selected_uses_located_band(self): """选中态校验跟随动态定位的“消息”行,而不是固定 y 65~105。""" bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 image = np.full((700, 900, 4), 242, dtype=np.uint8) image[:, :, 3] = 255 bot._msg_nav_band_cache = ((700, 900), (264, 304, True)) # 高亮画在定位到的行上 → 判定为消息页。 selected = image.copy() selected[264:304, 4:120, :3] = (238, 126, 36) self.assertTrue(bot._message_nav_selected(selected)) # 高亮画在旧的固定位置(65~105 逻辑)→ 不是消息被选中。 other = image.copy() other[130:210, 4:120, :3] = (238, 126, 36) self.assertFalse(bot._message_nav_selected(other)) def test_open_messages_clicks_located_band_center(self): bot = WeChatBot.__new__(WeChatBot) bot.hwnd = 100 bot.scale = 2.0 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() surface = np.full((700, 900, 4), 242, dtype=np.uint8) surface[:, :, 3] = 255 bot._capture_full_window = mock.Mock(return_value=surface) bot._msg_nav_band_cache = ((700, 900), (400, 480, True)) bot._message_nav_selected = mock.Mock(return_value=True) bot._message_page_misses = 0 bot._selected_tracking_initialized = False bot._active_session_fp = None bot._active_chat_signature = None bot._active_identity_signature = None with ( mock.patch("wechat_bot.safe_set_foreground"), mock.patch( "wechat_bot.win32gui.GetWindowRect", return_value=(400, 300, 2600, 1600), ), mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100), mock.patch("wechat_bot.pyautogui.click") as click, mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot._open_messages_page("测试:")) click.assert_called_once_with(468, 740) def test_global_unread_signature_follows_located_band(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot._list_x = 300 image = np.full((700, 900, 4), 245, dtype=np.uint8) image[:, :, 3] = 255 bot._msg_nav_band_cache = ((700, 900), (264, 304, True)) # 红点画在定位到的“消息”行内 → 有全局未读。 with_badge = image.copy() with_badge[270:290, 240:270, :3] = (81, 81, 250) self.assertTrue(bot._global_unread_signature(with_badge)) # 红点画在旧固定位置(该布局下属于别的入口)→ 不算消息未读。 wrong_row = image.copy() wrong_row[140:160, 240:270, :3] = (81, 81, 250) self.assertFalse(bot._global_unread_signature(wrong_row)) def test_unread_group_band_requires_unique_exact_ocr_label(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot._list_x = 300 bot._msg_nav_band_cache = ((700, 900), (264, 304, True)) bot._nav_column_width_px = mock.Mock(return_value=300) reader = mock.Mock() reader.available = True reader.read_layout.return_value = [ {"text": "分组", "x1": 40, "y1": 430, "y2": 458, "score": 0.99}, {"text": "未读", "x1": 90, "y1": 500, "y2": 528, "score": 0.98}, {"text": "5", "x1": 250, "y1": 502, "y2": 526, "score": 0.99}, ] bot._name_reader_instance = reader surface = np.full((700, 900, 4), 245, dtype=np.uint8) surface[:, :, 3] = 255 self.assertEqual( bot._locate_unread_group_band_by_text(surface), (480, 548), ) reader.read_layout.return_value.append( {"text": "未读", "x1": 100, "y1": 610, "y2": 638, "score": 0.97} ) self.assertIsNone(bot._locate_unread_group_band_by_text(surface)) def test_open_unread_group_requires_two_post_click_frames(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot.hwnd = 100 bot._list_x = 300 bot._unread_filter_active = False bot._unread_scan_resume = False bot._unread_filtered_center_fps = set() bot._unread_candidate_names = {} bot._last_unread_discovery_log = ("", 0.0) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._unread_group_band = mock.Mock(return_value=(500, 540)) bot._locate_unread_group_band_by_text = mock.Mock(return_value=(500, 540)) bot._nav_column_width_px = mock.Mock(return_value=300) bot._capture_full_window = mock.Mock( return_value=np.zeros((700, 900, 4), dtype=np.uint8) ) before = np.zeros((120, 100, 4), dtype=np.uint8) filtered = np.ones((120, 100, 4), dtype=np.uint8) bot.capture_session_list = mock.Mock( side_effect=[before, filtered, filtered] ) bot._session_page_signature = mock.Mock( side_effect=[b"ordinary", b"filtered", b"filtered"] ) bot._looks_like_message_list = mock.Mock(return_value=True) bot._unread_group_selected = mock.Mock(return_value=False) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() bot.report_progress = mock.Mock() bot._log_unread_discovery = mock.Mock() with ( mock.patch("wechat_bot.safe_set_foreground", return_value=True), mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100), mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 900, 700)), mock.patch("wechat_bot.pyautogui.click") as click, mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot._open_unread_group()) self.assertTrue(bot._unread_filter_active) click.assert_called_once_with(102, 520) self.assertEqual(bot.capture_session_list.call_count, 3) def test_completed_ordinary_scan_switches_to_hidden_unread_group(self): bot = WeChatBot.__new__(WeChatBot) ordinary = np.zeros((80, 100, 4), dtype=np.uint8) filtered = np.ones((80, 100, 4), dtype=np.uint8) target = (filtered, 40, b"t" * 40) bot._unread_filter_active = False bot._unread_scan_resume = False bot._strict_visual_actions = True bot.capture_session_list = mock.Mock( side_effect=[ordinary, ordinary, filtered] ) bot._target_from_session_image = mock.Mock(return_value=None) bot._deep_unread_scan_allowed = mock.Mock(return_value=True) bot._scroll_session_list_top = mock.Mock() bot._scroll_session_list_page = mock.Mock(return_value=None) bot._session_page_signature = mock.Mock(return_value=b"ordinary") bot._capture_full_window = mock.Mock(return_value=ordinary) bot._global_unread_signature = mock.Mock(return_value=b"five") bot._log_unread_discovery = mock.Mock() def open_filter(_full): bot._unread_filter_active = True return True bot._open_unread_group = mock.Mock(side_effect=open_filter) bot._scan_unread_filter = mock.Mock(return_value=target) self.assertEqual( bot._find_next_unread_session(set(), set(), full=ordinary), target, ) bot._open_unread_group.assert_called_once_with(ordinary) bot._scan_unread_filter.assert_called_once_with(filtered, set(), set()) def test_filtered_unread_list_uses_avatar_row_and_name_without_red_dot(self): bot = WeChatBot.__new__(WeChatBot) bot._unread_filter_active = True bot.identity_by_name = True bot._strict_visual_actions = True bot._flat_visual_proof_fps = set() image = np.zeros((120, 180, 4), dtype=np.uint8) target = WeChatBot._fp_from_name("客户甲@微信") bot._avatar_row_centers = mock.Mock(return_value=[52]) bot._row_display_name = mock.Mock(return_value="客户甲@微信") bot._is_system_entry_name = mock.Mock(return_value=False) bot._session_fingerprint = mock.Mock(return_value=target) bot._flat_session_rejected = mock.Mock(return_value=False) bot._is_real_conversation = mock.Mock(return_value=True) bot._flat_row_requires_visual_proof = mock.Mock(return_value=False) self.assertEqual( bot._target_from_filtered_unread_image(image, set(), set()), (52, target, "客户甲@微信"), ) def test_session_vision_model_link_error_is_not_cached(self): bot = WeChatBot.__new__(WeChatBot) bot._strict_visual_actions = True bot._session_row_ai_cache = {} bot.session_item_h = 64 bot._ui_guard_image_bytes = mock.Mock(return_value=b"row") bot._row_preview_text = mock.Mock(return_value="预览") bot.report_progress = mock.Mock() image = np.zeros((128, 180, 4), dtype=np.uint8) with mock.patch( "ai_chat.classify_wecom_session_row", side_effect=RuntimeError("temporary link error"), ): decision = bot._classify_ambiguous_session_row( image, 64, b"x" * 40, "客户甲", row_center=True, ) self.assertEqual(decision["kind"], "unknown") self.assertEqual(bot._session_row_ai_cache, {}) bot.report_progress.assert_called_once_with( "视觉模型链路失败,未读消息已保留等待重试" ) def test_filtered_unread_row_may_disappear_only_after_exact_title_proof(self): bot = WeChatBot.__new__(WeChatBot) name = "客户甲@微信" target = WeChatBot._fp_from_name(name) bot.scale = 1.0 bot.hwnd = 100 bot.list_region = {"top": 200} bot.list_click_x = 600 bot._session_geometry_valid = True bot._active_session_fp = None bot._unread_filtered_center_fps = {target.hex()} bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock( return_value=np.zeros((300, 600, 4), dtype=np.uint8) ) bot._message_workspace_selected = mock.Mock(return_value=True) latest = np.zeros((160, 180, 4), dtype=np.uint8) bot.capture_session_list = mock.Mock(return_value=latest) bot._pending_reply_state = mock.Mock( return_value={"requires_visual_proof": True} ) bot._is_real_conversation = mock.Mock(return_value=True) bot._session_fingerprint = mock.Mock(return_value=target) bot._chat_identity_signature = mock.Mock(return_value=b"title-render") bot._raw_selected_session_fingerprint = mock.Mock(return_value=None) bot._selected_session_fingerprint = mock.Mock(return_value=None) bot._open_chat_display_name = mock.Mock(return_value=name) bot._chat_surface_signature = mock.Mock(return_value=b"chat-ready") bot._remember_active_surface = mock.Mock() bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.time.sleep"), ): self.assertTrue( bot.click_session(64, expected_fp=target, row_center=True) ) bot._open_chat_display_name.assert_called_once_with() bot._remember_active_surface.assert_called_once_with(target) def test_navigation_width_inference_accepts_custom_band(self): image = np.full((700, 900, 4), 248, dtype=np.uint8) image[:, :, 3] = 255 # “消息”行在 y 264~304(不在第一位),浅蓝底色只画在这一行。 image[264:304, :300, :3] = (250, 232, 215) image[264:304, 300:, :3] = (245, 245, 245) self.assertEqual( infer_navigation_width(image, 2.0, band=(264, 304))[0], 300, ) # 不传 band 时按旧固定行采样,该布局下识别不出宽度。 self.assertLess(infer_navigation_width(image, 2.0)[1], 0.12) def test_unchanged_selected_chat_does_not_activate_or_move_mouse(self): bot = WeChatBot.__new__(WeChatBot) bot.detect_selected_row = mock.Mock(return_value=-1) bot._chat_identity_signature = mock.Mock(return_value=b"identity") bot._chat_surface_signature = mock.Mock(return_value=b"surface") bot._active_session_fp = b"target" bot._active_identity_signature = b"identity" bot._active_chat_signature = b"surface" bot._selected_tracking_initialized = True bot._activate_wx = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) image = np.zeros((64, 80, 4), dtype=np.uint8) self.assertFalse(bot._check_selected_session(image)) bot._activate_wx.assert_not_called() def test_message_batch_collects_updates_then_allows_one_request(self): bot = WeChatBot.__new__(WeChatBot) bot._stop_check = None bot._active_identity_signature = b"identity" bot._chat_identity_signature = mock.Mock(return_value=b"identity") bot._chat_surface_signature = mock.Mock( side_effect=[b"start", b"start", b"second", b"third"] + [b"third"] * 20 ) bot._capture_full_window = mock.Mock(return_value=np.zeros((20, 20, 4), dtype=np.uint8)) bot._message_nav_selected = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) self.assertTrue( bot._wait_for_message_batch( b"target", window_seconds=0.02, poll_seconds=0.005, ) ) bot.wait_for_mouse_idle.assert_called_once() def test_message_batch_aborts_when_chat_identity_changes(self): bot = WeChatBot.__new__(WeChatBot) bot._stop_check = None bot._active_identity_signature = b"identity" bot._chat_identity_signature = mock.Mock(side_effect=[b"identity", b"other"]) bot._chat_surface_signature = mock.Mock(return_value=b"surface") bot._capture_full_window = mock.Mock(return_value=np.zeros((20, 20, 4), dtype=np.uint8)) bot._message_nav_selected = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) self.assertFalse( bot._wait_for_message_batch( b"target", window_seconds=0.05, poll_seconds=0.005, ) ) bot.wait_for_mouse_idle.assert_not_called() def test_message_batch_aborts_when_same_title_switches_to_another_session(self): """A same-title manual switch must not contaminate the original pending batch.""" bot = WeChatBot.__new__(WeChatBot) target = b"target-session" other = b"other-session!" bot._stop_check = None bot._active_session_fp = target bot._active_identity_signature = b"same-visible-title" bot._chat_identity_signature = mock.Mock(return_value=b"same-visible-title") bot._chat_surface_signature = mock.Mock(return_value=b"surface") bot._selected_session_fingerprint = mock.Mock( side_effect=[target, other] ) bot._capture_full_window = mock.Mock( return_value=np.zeros((20, 20, 4), dtype=np.uint8) ) bot._message_nav_selected = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) self.assertFalse( bot._wait_for_message_batch( target, window_seconds=0.05, poll_seconds=0.005, ) ) bot.wait_for_mouse_idle.assert_not_called() def test_message_batch_accepts_title_render_drift_for_same_full_session(self): bot = WeChatBot.__new__(WeChatBot) fp = b"m" * 40 state = { "batch_ready": False, "identity_signature": b"old-render", } bot._pending_reply_sessions = {fp.hex(): state} bot._pending_reply_path = "" bot._stop_check = None bot._active_session_fp = fp bot._active_identity_signature = b"old-render" bot._chat_identity_signature = mock.Mock( side_effect=[b"first-new-render", b"second-new-render"] ) bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface") bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._persist_pending_replies = mock.Mock(return_value=True) bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._activate_wx = mock.Mock(return_value=True) self.assertTrue( bot._wait_for_message_batch( fp, window_seconds=0.0, poll_seconds=0.05, ) ) self.assertEqual(state["identity_signature"], b"second-new-render") def test_message_batch_retry_keeps_original_persisted_deadline(self): """A transient retry must wait only the original window's remainder.""" bot = WeChatBot.__new__(WeChatBot) target = b"t" * 40 bot._pending_reply_path = "" bot._pending_reply_sessions = { target.hex(): {"batch_ready": False}, } bot._active_session_fp = target bot._active_identity_signature = b"identity" bot._chat_identity_signature = mock.Mock(return_value=b"identity") bot._chat_surface_signature = mock.Mock(return_value=b"surface") bot._raw_selected_session_fingerprint = mock.Mock(return_value=target) bot._selected_session_fingerprint = mock.Mock(return_value=target) bot._stop_check = mock.Mock() bot._stop_check.wait.return_value = True with ( mock.patch("wechat_bot.time.time", return_value=100.0), mock.patch("wechat_bot.time.monotonic", return_value=0.0), ): self.assertFalse( bot._wait_for_message_batch( target, window_seconds=7.0, poll_seconds=0.5, ) ) original_deadline = bot._pending_reply_state(target)["batch_deadline_at"] self.assertEqual(original_deadline, 107.0) bot._stop_check.wait.reset_mock() with ( mock.patch("wechat_bot.time.time", return_value=106.8), mock.patch("wechat_bot.time.monotonic", return_value=0.0), ): self.assertFalse( bot._wait_for_message_batch( target, window_seconds=7.0, poll_seconds=0.5, ) ) self.assertEqual( bot._pending_reply_state(target)["batch_deadline_at"], original_deadline, ) self.assertAlmostEqual( bot._stop_check.wait.call_args.args[0], 0.2, places=5, ) def test_marking_batch_ready_clears_persisted_merge_deadline(self): bot = WeChatBot.__new__(WeChatBot) target = b"r" * 40 bot._pending_reply_path = "" bot._pending_reply_sessions = { target.hex(): { "batch_ready": False, "batch_started_at": 100.0, "batch_deadline_at": 120.0, "batch_window_seconds": 20.0, }, } bot._live_render_ids_for = mock.Mock(return_value=set()) self.assertTrue(bot._mark_reply_pending(target, batch_ready=True)) state = bot._pending_reply_state(target) self.assertTrue(state["batch_ready"]) self.assertNotIn("batch_started_at", state) self.assertNotIn("batch_deadline_at", state) self.assertNotIn("batch_window_seconds", state) def test_hidden_unread_is_found_on_third_scanned_page(self): bot = WeChatBot.__new__(WeChatBot) pages = [ np.full((40, 40, 4), value, dtype=np.uint8) for value in (10, 20, 30, 40) ] bot.capture_session_list = mock.Mock(side_effect=[pages[0], pages[1]]) bot._target_from_session_image = mock.Mock( side_effect=[None, None, None, (12, b"customer")] ) bot._deep_unread_scan_allowed = mock.Mock(return_value=True) bot._scroll_session_list_top = mock.Mock() bot._scroll_session_list_page = mock.Mock(side_effect=[pages[2], pages[3]]) found = bot._find_next_unread_session(set(), set(), full=pages[0]) self.assertEqual(found[1:], (12, b"customer")) self.assertIs(found[0], pages[3]) self.assertEqual(bot._scroll_session_list_page.call_count, 2) def test_deep_unread_scan_resumes_after_time_budget_instead_of_restarting(self): bot = WeChatBot.__new__(WeChatBot) pages = [ np.full((40, 40, 4), value, dtype=np.uint8) for value in (10, 20) ] bot._unread_scan_resume = False bot.capture_session_list = mock.Mock( side_effect=[pages[0], pages[0], pages[1]] ) bot._target_from_session_image = mock.Mock(return_value=None) bot._deep_unread_scan_allowed = mock.Mock(return_value=True) bot._scroll_session_list_top = mock.Mock() bot._scroll_session_list_page = mock.Mock( side_effect=[pages[1], None] ) with ( mock.patch("wechat_bot.SESSION_SCAN_MAX_SECONDS", 0.0), mock.patch("wechat_bot.time.monotonic", return_value=100.0), ): self.assertIsNone( bot._find_next_unread_session(set(), set(), full=pages[0]) ) self.assertTrue(bot._unread_scan_resume) # First pass went to the top once; it deliberately did not jump back. self.assertEqual(bot._scroll_session_list_top.call_count, 1) bot._scroll_session_list_top.reset_mock() with mock.patch("wechat_bot.time.monotonic", return_value=101.0): self.assertIsNone( bot._find_next_unread_session(set(), set(), full=pages[1]) ) self.assertFalse(bot._unread_scan_resume) # The resumed pass started at page[1] and only returned to top at bottom. bot._scroll_session_list_top.assert_called_once() def test_deep_unread_resume_is_not_cancelled_by_scan_cooldown(self): """A paused forward scan must continue while the global unread still exists.""" bot = WeChatBot.__new__(WeChatBot) page = np.full((40, 40, 4), 20, dtype=np.uint8) bot._unread_scan_resume = True bot.capture_session_list = mock.Mock(return_value=page) bot._target_from_session_image = mock.Mock(return_value=None) bot._deep_unread_scan_allowed = mock.Mock(return_value=False) bot._global_unread_signature = mock.Mock(return_value=b"still-unread") bot._scroll_session_list_top = mock.Mock() bot._scroll_session_list_page = mock.Mock(return_value=None) self.assertIsNone( bot._find_next_unread_session(set(), set(), full=page) ) bot._scroll_session_list_page.assert_called_once_with(page) self.assertFalse(bot._unread_scan_resume) def test_read_pending_conversation_is_reopened_by_fingerprint(self): bot = WeChatBot.__new__(WeChatBot) fp = b"p" * 40 bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "confirmed_unread": True, "chat_text": "客户甲 10:00:00\n待回复内容", "identity_signature": b"same-title-old-render", } } bot._pending_reply_path = "" bot._active_session_fp = b"a" * 40 bot._active_identity_signature = b"another-title" bot._send_gate_open = mock.Mock(return_value=True) page = np.zeros((200, 100, 4), dtype=np.uint8) bot._find_pending_session = mock.Mock(return_value=(page, 64)) def open_target(*_args, **_kwargs): bot._active_session_fp = fp bot._active_identity_signature = b"same-title-new-render" return True bot.click_session = mock.Mock(side_effect=open_target) bot._chat_identity_signature = mock.Mock( side_effect=[b"another-title", b"same-title-new-render"] ) bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._persist_pending_replies = mock.Mock(return_value=True) bot._generate_ai_reply = mock.Mock(return_value="继续回复") bot._activate_wx = mock.Mock(return_value=True) bot.send_reply = mock.Mock(return_value=True) with ( mock.patch("wechat_bot.time.monotonic", return_value=100.0), mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot._resume_orphaned_pending_reply()) bot.click_session.assert_called_once_with( 64, expected_fp=fp, row_center=True, ) bot._generate_ai_reply.assert_called_once_with( fp, confirmed_unread=True, ) bot.send_reply.assert_called_once_with( "继续回复", session_id=fp.hex(), expected_fp=fp, ) self.assertEqual( bot._pending_reply_state(fp)["identity_signature"], b"same-title-new-render", ) def test_pending_search_rejects_same_identity_on_different_pages(self): bot = WeChatBot.__new__(WeChatBot) fp = b"p" * 40 pages = [ np.full((80, 80, 4), value, dtype=np.uint8) for value in (10, 20, 30) ] bot._scroll_session_list_top = mock.Mock() bot.capture_session_list = mock.Mock(return_value=pages[0]) bot._pending_rows_on_page = mock.Mock( side_effect=[[12], [], [44]] ) bot._scroll_session_list_page = mock.Mock( side_effect=[pages[1], pages[2], None] ) self.assertIsNone(bot._find_pending_session(fp)) self.assertGreaterEqual(bot._scroll_session_list_top.call_count, 2) def test_pending_search_deduplicates_same_row_on_overlapping_pages(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 1.0 bot.session_item_h = 32 fp = b"o" * 40 first = np.full((120, 100, 4), 245, dtype=np.uint8) first[:, :, 3] = 255 first[18:34, 8:28, :3] = (30, 80, 150) first[50:58, 35:82, :3] = 20 first[84:104, 12:30, :3] = (90, 40, 180) second = np.full_like(first, 245) second[:, :, 3] = 255 shift = 20 second[: first.shape[0] - shift] = first[shift:] second[first.shape[0] - shift :, 40:70, :3] = 60 bot._scroll_session_list_top = mock.Mock() bot.capture_session_list = mock.Mock(side_effect=[first, first]) bot._pending_rows_on_page = mock.Mock( side_effect=[[72], [52], [72]] ) bot._scroll_session_list_page = mock.Mock( side_effect=[second, None] ) found = bot._find_pending_session(fp) self.assertIs(found[0], first) self.assertEqual(found[1], 72) def test_pending_row_scan_covers_unknown_scrolled_phase(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.session_item_h = 128 target = b"a" * 8 + b"n" * 32 page = np.zeros((300, 460, 4), dtype=np.uint8) true_center = 23 bot.detect_selected_row = mock.Mock(return_value=-1) bot.detect_badge_rows = mock.Mock(return_value=[]) bot._session_fingerprint = mock.Mock(return_value=b"x" * 40) bot._raw_session_fingerprint = mock.Mock( side_effect=lambda _img, y, row_center=True: ( target[:8] if y == true_center else b"z" * 8 ) ) bot._session_name_fingerprint = mock.Mock( side_effect=lambda _img, y, row_center=True: ( target[8:] if y == true_center else b"q" * 32 ) ) bot._is_real_conversation = mock.Mock( side_effect=lambda _img, y, **_kwargs: y == true_center ) self.assertEqual( bot._pending_rows_on_page(page, target), [true_center], ) self.assertTrue( any( call.kwargs.get("allow_flat") is True for call in bot._is_real_conversation.call_args_list ) ) def test_pending_search_scans_all_pages_then_replays_unique_page(self): bot = WeChatBot.__new__(WeChatBot) fp = b"q" * 40 pages = [ np.full((80, 80, 4), value, dtype=np.uint8) for value in (10, 20, 30) ] bot._scroll_session_list_top = mock.Mock() bot.capture_session_list = mock.Mock( side_effect=[pages[0], pages[0]] ) bot._pending_rows_on_page = mock.Mock( side_effect=[[], [], [44], [46]] ) bot._scroll_session_list_page = mock.Mock( side_effect=[ pages[1], pages[2], None, pages[1], pages[2], ] ) found = bot._find_pending_session(fp) self.assertIs(found[0], pages[2]) self.assertEqual(found[1], 46) def test_pending_search_resumes_after_budget_without_restarting_from_top(self): """Long lists must make forward progress across polling turns.""" bot = WeChatBot.__new__(WeChatBot) fp = b"r" * 40 pages = [ np.full((80, 80, 4), value, dtype=np.uint8) for value in (10, 20, 30) ] bot._scroll_session_list_top = mock.Mock() bot.capture_session_list = mock.Mock( # First pass starts at page 0. The resumed pass captures page 1; # replay after a complete scan starts at page 0 again. side_effect=[pages[0], pages[1], pages[0]] ) bot._pending_rows_on_page = mock.Mock( side_effect=[[], [], [44], [46]] ) bot._scroll_session_list_page = mock.Mock( side_effect=[pages[1], pages[2], None, pages[1], pages[2]] ) # Process page 0, scroll to page 1, then exhaust this turn's budget. with mock.patch( "wechat_bot.time.monotonic", side_effect=[0.0, 46.0], ): self.assertIsNone(bot._find_pending_session(fp)) self.assertTrue(bot._pending_scan_incomplete) bot._scroll_session_list_top.assert_called_once() bot._scroll_session_list_top.reset_mock() with mock.patch("wechat_bot.time.monotonic", return_value=100.0): found = bot._find_pending_session(fp) self.assertIs(found[0], pages[2]) self.assertEqual(found[1], 46) # The resumed scan does not jump to the top before continuing. It only # returns to the top once the global uniqueness scan is complete. bot._scroll_session_list_top.assert_called_once() def test_same_avatar_with_different_title_never_resumes_cached_reply(self): bot = WeChatBot.__new__(WeChatBot) fp = b"p" * 40 other_fp = b"q" * 40 bot._pending_reply_sessions = { fp.hex(): { "batch_ready": True, "confirmed_unread": True, "chat_text": "客户甲 10:00:00\n待回复内容", "identity_signature": b"customer-a-title", } } bot._active_session_fp = b"another2" bot._active_identity_signature = b"another-title" bot._send_gate_open = mock.Mock(return_value=True) page = np.zeros((200, 100, 4), dtype=np.uint8) bot._find_pending_session = mock.Mock(return_value=(page, 64)) bot.click_session = mock.Mock(return_value=True) bot._chat_identity_signature = mock.Mock( side_effect=[b"another-title", b"customer-b-title"] ) bot._raw_selected_session_fingerprint = mock.Mock(return_value=other_fp) bot._selected_session_fingerprint = mock.Mock(return_value=other_fp) bot._generate_ai_reply = mock.Mock(return_value="绝不能发送") bot._activate_wx = mock.Mock(return_value=True) bot.send_reply = mock.Mock(return_value=True) with mock.patch("wechat_bot.time.monotonic", return_value=100.0): self.assertFalse(bot._resume_orphaned_pending_reply()) bot._generate_ai_reply.assert_not_called() bot.send_reply.assert_not_called() def test_full_selected_fingerprint_overrides_title_render_hash_drift(self): bot = WeChatBot.__new__(WeChatBot) fp = b"t" * 40 state = {"identity_signature": b"old-render"} bot._pending_reply_sessions = {fp.hex(): state} bot._pending_reply_path = "" bot._active_session_fp = fp bot._active_identity_signature = b"old-render" bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._persist_pending_replies = mock.Mock(return_value=True) self.assertTrue( bot._chat_target_matches( fp, b"old-render", current_identity=b"new-render", ) ) self.assertTrue( bot._accept_selected_title_render( fp, state, b"new-render", ) ) self.assertEqual(state["identity_signature"], b"new-render") self.assertEqual(bot._active_identity_signature, b"new-render") def test_same_title_never_overrides_a_different_full_selected_fingerprint(self): bot = WeChatBot.__new__(WeChatBot) target = b"t" * 40 other = b"o" * 40 bot._raw_selected_session_fingerprint = mock.Mock(return_value=other) bot._selected_session_fingerprint = mock.Mock(return_value=other) self.assertFalse( bot._chat_target_matches( target, b"same-title", current_identity=b"same-title", ) ) def test_title_mismatch_without_selected_row_proof_remains_blocked(self): bot = WeChatBot.__new__(WeChatBot) target = b"t" * 40 bot._raw_selected_session_fingerprint = mock.Mock(return_value=None) bot._selected_session_fingerprint = mock.Mock(return_value=None) self.assertFalse( bot._chat_target_matches( target, b"old-render", current_identity=b"new-render", ) ) def test_legacy_pending_identity_cannot_rebind_from_a_full_selected_row(self): bot = WeChatBot.__new__(WeChatBot) legacy_fp = b"l" * 16 current_fp = b"c" * 40 state = {"identity_signature": b"legacy-title"} bot._raw_selected_session_fingerprint = mock.Mock( return_value=current_fp ) bot._selected_session_fingerprint = mock.Mock(return_value=current_fp) self.assertFalse( bot._chat_target_matches( legacy_fp, b"legacy-title", current_identity=b"new-title-render", ) ) self.assertFalse( bot._accept_selected_title_render( legacy_fp, state, b"new-title-render", ) ) self.assertEqual(state["identity_signature"], b"legacy-title") def test_title_render_rebind_failure_rolls_back_and_blocks(self): bot = WeChatBot.__new__(WeChatBot) fp = b"r" * 40 state = { "identity_signature": b"old-render", "updated_at": 123.0, } bot._pending_reply_sessions = {fp.hex(): state} bot._active_session_fp = fp bot._active_identity_signature = b"old-render" bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp) bot._selected_session_fingerprint = mock.Mock(return_value=fp) bot._persist_pending_replies = mock.Mock(return_value=False) self.assertFalse( bot._accept_selected_title_render( fp, state, b"new-render", ) ) self.assertEqual(state["identity_signature"], b"old-render") self.assertEqual(state["updated_at"], 123.0) self.assertEqual(bot._active_identity_signature, b"old-render") def test_pending_task_freezes_original_chat_title_signature(self): bot = WeChatBot.__new__(WeChatBot) fp = b"pending3" bot._pending_reply_sessions = {} bot._active_identity_signature = b"customer-a-title" bot._mark_reply_pending(fp, confirmed_unread=True) state = bot._pending_reply_state(fp) self.assertEqual(state["identity_signature"], b"customer-a-title") self.assertTrue(state["confirmed_unread"]) def test_no_global_unread_does_not_scroll_the_list(self): bot = WeChatBot.__new__(WeChatBot) page = np.zeros((40, 40, 4), dtype=np.uint8) bot.capture_session_list = mock.Mock(return_value=page) bot._target_from_session_image = mock.Mock(return_value=None) bot._deep_unread_scan_allowed = mock.Mock(return_value=False) bot._scroll_session_list_top = mock.Mock() self.assertIsNone(bot._find_next_unread_session(set(), set(), full=page)) bot._scroll_session_list_top.assert_not_called() def test_click_relocates_unread_by_fingerprint_before_acting(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.hwnd = 100 bot.list_region = {"top": 200} bot.list_click_x = 600 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) latest = np.zeros((500, 180, 4), dtype=np.uint8) bot.capture_session_list = mock.Mock(return_value=latest) bot.detect_badge_rows = mock.Mock(return_value=[300, 80]) bot._session_fingerprint = mock.Mock( side_effect=lambda _img, y: b"target" if y == 80 else b"other" ) bot._chat_identity_signature = mock.Mock(side_effect=[b"before", b"after"]) bot._selected_session_fingerprint = mock.Mock(return_value=b"target") bot._remember_active_surface = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._run_ai_page_guard = mock.Mock(return_value=False) bot._escape_proven_blocker = mock.Mock(return_value=False) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click") as click, mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot.click_session(300, expected_fp=b"target")) # badge y=80 + 16*2 的头像中心,再加列表 top=200。 click.assert_called_once_with(600, 312) def test_click_two_nav_misses_accepts_raw_target_with_chat_evidence(self): bot = WeChatBot.__new__(WeChatBot) target = b"t" * 40 message_page = self._nav_surface(True) other_page = self._nav_surface(False) bot.scale = 1.0 bot.hwnd = 100 bot.list_region = {"top": 200} bot.list_click_x = 600 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock( side_effect=[message_page, other_page, other_page] ) latest = np.zeros((200, 180, 4), dtype=np.uint8) bot.capture_session_list = mock.Mock(return_value=latest) bot.detect_badge_rows = mock.Mock(return_value=[32]) bot._session_fingerprint = mock.Mock(return_value=target) bot._chat_identity_signature = mock.Mock( side_effect=[b"same-title", b"same-title"] ) bot._chat_surface_signature = mock.Mock(return_value=b"chat-ready") bot._active_session_fp = None bot._raw_selected_session_fingerprint = mock.Mock(return_value=target) bot._selected_session_fingerprint = mock.Mock(return_value=None) bot._pending_reply_state = mock.Mock(return_value=None) bot._flat_visual_proof_fps = {target.hex()} bot._flat_verified_session_fps = set() # Reproduce an earlier false rejection from the same listener process. # Strong raw-row/title/chat evidence must heal it immediately. bot._flat_rejected_session_fps = {target.hex()} bot._remember_active_surface = mock.Mock() bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot.click_session(32, expected_fp=target)) self.assertEqual(bot._last_click_failure_reason, "") self.assertNotIn(target.hex(), bot._flat_rejected_session_fps) self.assertIn(target.hex(), bot._flat_verified_session_fps) self.assertNotIn(target.hex(), bot._flat_visual_proof_fps) bot._raw_selected_session_fingerprint.assert_called_once_with() bot._selected_session_fingerprint.assert_not_called() bot._chat_surface_signature.assert_called_once_with() bot._remember_active_surface.assert_called_once_with(target) def test_click_two_nav_misses_without_raw_selected_row_proves_page_exit(self): bot = WeChatBot.__new__(WeChatBot) target = b"target" message_page = self._nav_surface(True) other_page = self._nav_surface(False) bot.scale = 1.0 bot.hwnd = 100 bot.list_region = {"top": 200} bot.list_click_x = 600 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock( side_effect=[message_page, other_page, other_page] ) latest = np.zeros((200, 180, 4), dtype=np.uint8) bot.capture_session_list = mock.Mock(return_value=latest) bot.detect_badge_rows = mock.Mock(return_value=[32]) bot._session_fingerprint = mock.Mock(return_value=target) bot._chat_identity_signature = mock.Mock(return_value=b"before") bot._active_session_fp = None bot._raw_selected_session_fingerprint = mock.Mock(return_value=None) bot._selected_session_fingerprint = mock.Mock(return_value=target) bot._chat_surface_signature = mock.Mock(return_value=b"chat-ready") bot._remember_active_surface = mock.Mock() bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.time.sleep"), ): self.assertFalse(bot.click_session(32, expected_fp=target)) self.assertEqual(bot._last_click_failure_reason, "left_message_workspace") self.assertEqual(bot._capture_full_window.call_count, 3) bot._raw_selected_session_fingerprint.assert_called_once_with() bot._selected_session_fingerprint.assert_not_called() bot._chat_surface_signature.assert_not_called() bot._remember_active_surface.assert_not_called() def test_click_ignores_one_transient_post_click_navigation_frame(self): bot = WeChatBot.__new__(WeChatBot) target = b"target" message_page = self._nav_surface(True) transient_page = self._nav_surface(False) bot.scale = 1.0 bot.hwnd = 100 bot.list_region = {"top": 200} bot.list_click_x = 600 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock( side_effect=[message_page, transient_page, message_page] ) latest = np.zeros((200, 180, 4), dtype=np.uint8) bot.capture_session_list = mock.Mock(return_value=latest) bot.detect_badge_rows = mock.Mock(return_value=[32]) bot._session_fingerprint = mock.Mock(return_value=target) bot._chat_identity_signature = mock.Mock( side_effect=[b"before", b"after"] ) bot._active_session_fp = None bot._selected_session_fingerprint = mock.Mock(return_value=target) bot._remember_active_surface = mock.Mock() bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.time.sleep"), ): self.assertTrue(bot.click_session(32, expected_fp=target)) self.assertEqual(bot._last_click_failure_reason, "") self.assertEqual(bot._capture_full_window.call_count, 3) bot._remember_active_surface.assert_called_once_with(target) def test_click_can_reopen_flat_avatar_pending_for_visual_confirmation(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot._session_geometry_valid = True bot.list_region = {"top": 200} bot.list_click_x = 600 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) latest = np.zeros((500, 460, 4), dtype=np.uint8) expected = b"f" * 40 bot.capture_session_list = mock.Mock(return_value=latest) bot._session_fingerprint = mock.Mock(return_value=expected) bot._pending_reply_sessions = { expected.hex(): {"requires_visual_proof": True} } bot.store = mock.Mock() bot.store.has_record.return_value = False bot._is_real_conversation = mock.Mock( side_effect=lambda *_args, **kwargs: bool(kwargs.get("allow_flat")) ) bot._chat_identity_signature = mock.Mock( side_effect=[b"before-title", b"after-title"] ) bot._active_session_fp = None bot._selected_session_fingerprint = mock.Mock(return_value=expected) bot._remember_active_surface = mock.Mock() bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click") as click, mock.patch("wechat_bot.time.sleep"), ): self.assertTrue( bot.click_session(64, expected_fp=expected, row_center=True) ) self.assertTrue( bot._is_real_conversation.call_args.kwargs["allow_flat"] ) click.assert_called_once_with(600, 264) def test_click_fails_closed_when_selected_fingerprint_cannot_be_read(self): bot = WeChatBot.__new__(WeChatBot) bot.scale = 2.0 bot.hwnd = 100 bot.list_region = {"top": 200} bot.list_click_x = 600 bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) latest = np.zeros((500, 180, 4), dtype=np.uint8) bot.capture_session_list = mock.Mock(return_value=latest) bot.detect_badge_rows = mock.Mock(return_value=[80]) bot._session_fingerprint = mock.Mock(return_value=b"target") bot._is_real_conversation = mock.Mock(return_value=True) bot._chat_identity_signature = mock.Mock(side_effect=[b"before", b"after"]) bot._selected_session_fingerprint = mock.Mock(return_value=None) bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot._run_ai_page_guard = mock.Mock(return_value=False) bot._escape_proven_blocker = mock.Mock(return_value=False) bot._begin_bot_mouse = mock.Mock() bot._end_bot_mouse = mock.Mock() with ( mock.patch("wechat_bot.pyautogui.click"), mock.patch("wechat_bot.time.sleep"), ): self.assertFalse(bot.click_session(80, expected_fp=b"target")) def test_generate_reply_passes_archive_history_and_stages_whole_batch(self): bot = WeChatBot.__new__(WeChatBot) fp = b"12345678" history = [ {"role": "user", "content": "上次的问题"}, {"role": "assistant", "content": "上次的回复"}, ] batch = ( "客户甲 7/29 10:00:01\n第一段\n" "客户甲 7/29 10:00:08\n第二段\n" "客户甲 7/29 10:00:18\n第三段" ) bot.get_session_history = mock.Mock(return_value=history) bot.extract_context_for = mock.Mock(return_value=batch) bot._stage_exchange = mock.Mock() bot._run_ai_page_guard = mock.Mock(return_value=False) with ( mock.patch("ai_config.AI_ENABLED", True), mock.patch("ai_config.AI_USE_VISION", False), mock.patch("ai_config.AI_CONTEXT_ENABLED", True), mock.patch("ai_chat.call_ai_text", return_value="统一回复") as call_ai, mock.patch( "registration_store.process_registration_reply", return_value=("统一回复", None), ), mock.patch("wechat_bot.time.sleep"), ): self.assertEqual(bot._generate_ai_reply(fp), "统一回复") call_ai.assert_called_once_with(batch, history=history) staged_user_text = bot._stage_exchange.call_args.args[1] self.assertEqual(staged_user_text, "第一段\n第二段\n第三段") # ── 一个卡住的待回复任务不能饿死其它会话 ───────────────────────────────── # `_poll_once` 见到 `_resume_orphaned_pending_reply()` 返回 True 就整轮 return。 # 只要「没做成任何事」也返回 True,一个永远认不回来的任务就会把未读扫描永久 # 挡掉,表现就是「回复几次之后再也不回了」。 def _resume_bot(self, **state_fields): """搭一个只有一个已读未回复任务的机器人,恢复链路全部打桩。""" bot = WeChatBot.__new__(WeChatBot) fp = b"r" * 40 state = {"confirmed_unread": True, "identity_signature": b"title"} state.update(state_fields) bot._pending_reply_sessions = {fp.hex(): state} bot._pending_reply_path = "" bot._pending_scan_progress = {} bot._pending_scan_incomplete = False bot._live_session_fp_aliases = set() bot._active_session_fp = None bot._active_identity_signature = None bot._chat_identity_signature = mock.Mock(return_value=b"title") bot._persist_pending_replies = mock.Mock(return_value=True) bot._send_gate_open = mock.Mock(return_value=True) bot._find_pending_session = mock.Mock(return_value=None) bot.click_session = mock.Mock(return_value=True) bot._chat_target_matches = mock.Mock(return_value=True) bot._accept_selected_title_render = mock.Mock(return_value=True) bot._reconcile_uncertain_send = mock.Mock(return_value="unsent") bot._mark_reply_pending = mock.Mock(return_value=True) bot._wait_for_message_batch = mock.Mock(return_value=True) bot._generate_ai_reply = mock.Mock(return_value="回复") bot._activate_wx = mock.Mock(return_value=True) bot.send_reply = mock.Mock(return_value=True) bot._pending_reply_state = ( lambda key: bot._pending_reply_sessions.get(bytes(key).hex()) ) return bot, fp, state def test_unlocatable_pending_task_leaves_the_round_free_for_new_unread(self): """扫描完整个列表也认不回来时,绝不能吃掉本轮的未读处理。""" bot, _fp, state = self._resume_bot() self.assertFalse(bot._resume_orphaned_pending_reply()) self.assertEqual(state["resume_failures"], 1) def test_an_unfinished_scan_keeps_its_progress_without_blocking_unread(self): """翻页扫描没走完要保留进度,但同样不能挡住未读扫描。""" bot, _fp, state = self._resume_bot() def scan(_target): bot._pending_scan_incomplete = True return None bot._find_pending_session = mock.Mock(side_effect=scan) self.assertFalse(bot._resume_orphaned_pending_reply()) # 分页扫描本身是在推进,不该被记成失败而提前退避。 self.assertNotIn("resume_failures", state) def test_failed_reopen_does_not_consume_the_polling_round(self): bot, _fp, state = self._resume_bot() bot._find_pending_session = mock.Mock( return_value=(np.zeros((8, 8, 4), np.uint8), 40) ) bot.click_session = mock.Mock(return_value=False) self.assertFalse(bot._resume_orphaned_pending_reply()) self.assertEqual(state["resume_failures"], 1) def test_repeated_resume_failures_back_off_instead_of_retrying_every_round(self): bot, _fp, _state = self._resume_bot() base = wechat_bot.SESSION_SCAN_COOLDOWN_SECONDS self.assertEqual(bot._resume_cooldown_seconds({}), base) self.assertEqual(bot._resume_cooldown_seconds({"resume_failures": 1}), base * 2) self.assertEqual(bot._resume_cooldown_seconds({"resume_failures": 3}), base * 8) # 退避有上限,任务不会被无限期搁置。 self.assertEqual( bot._resume_cooldown_seconds({"resume_failures": 99}), bot._RESUME_BACKOFF_MAX_SECONDS, ) def test_a_successful_resume_clears_the_backoff(self): bot, fp, state = self._resume_bot(resume_failures=4) bot._find_pending_session = mock.Mock( return_value=(np.zeros((8, 8, 4), np.uint8), 40) ) with mock.patch("wechat_bot.time.sleep"): self.assertTrue(bot._resume_orphaned_pending_reply()) bot.send_reply.assert_called_once_with( "回复", session_id=fp.hex(), expected_fp=fp, ) self.assertNotIn("resume_failures", state) def test_stuck_pending_task_no_longer_starves_the_unread_scan(self): """端到端:卡住的任务存在时,本轮仍然必须去扫描新的未读会话。""" bot, _fp, _state = self._resume_bot() message_page = self._nav_surface(True) bot.scale = 1.0 bot.false_pos_rows = set() bot.safe_window_mode = True bot._did_initial_cleanup = True bot.wait_for_mouse_idle = mock.Mock(return_value=True) bot._dismiss_owned_blocking_window = mock.Mock(return_value=False) bot._ensure_visible = mock.Mock(return_value=True) bot._security_gate_visible = mock.Mock(return_value=False) bot._capture_full_window = mock.Mock(return_value=message_page) bot._refresh_message_geometry = mock.Mock() bot._dismiss_internal_blocker = mock.Mock(return_value=False) bot.capture_session_list = mock.Mock( return_value=np.zeros((64, 80, 4), dtype=np.uint8) ) bot._check_selected_session = mock.Mock(return_value=False) bot._find_next_unread_session = mock.Mock(return_value=None) # 真正会卡死的形态:列表很长,翻页扫描一轮走不完,于是每一轮都重扫。 def scan(_target): bot._pending_scan_incomplete = True return None bot._find_pending_session = mock.Mock(side_effect=scan) bot._poll_once() bot._find_next_unread_session.assert_called_once() # ── 每个会话的第一条消息必须回复 ───────────────────────────────────────── # 判断“是否还在跟同一个会话”一旦用裸 `!=`,同一个联系人只要头像哈希漂几位 # (或只是未读粗体换成选中常规体),就会被当成刚切过来的新会话,本轮到达的 # 消息被并进基线吞掉,客户要再发一条才会得到回复。 _NAME_FP = b"n" * 32 # 实测同一联系人漂移:78781818… → 78700818…(相差 2 位,容差 6 位) _AVATAR_FP = bytes([0x78, 0x78, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00]) _AVATAR_FP_DRIFTED = bytes([0x78, 0x70, 0x08, 0x18, 0x00, 0x00, 0x00, 0x00]) def _selected_session_bot(self, current_fp, tracked_fp, *, customer_waiting): """搭一个“当前会话画面已变化”的机器人,回复链路全部打桩。""" bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_sessions = {} bot._pending_reply_path = "" bot._live_session_fp_aliases = set() bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True)) bot._message_nav_selected = mock.Mock(return_value=True) bot.detect_selected_row = mock.Mock(return_value=24) bot._session_fingerprint = mock.Mock(return_value=current_fp) bot._flat_row_requires_visual_proof = mock.Mock(return_value=False) bot._is_tool_selected = mock.Mock(return_value=False) bot._raw_selected_session_fingerprint = mock.Mock(return_value=current_fp) bot._selected_session_fingerprint = mock.Mock(return_value=current_fp) bot._chat_identity_signature = mock.Mock(return_value=b"title") bot._chat_surface_signature = mock.Mock(return_value=b"new-surface") bot._ensure_session_archive_key = mock.Mock() bot._persist_pending_replies = mock.Mock(return_value=True) bot._reconcile_uncertain_send = mock.Mock(return_value="unsent") bot._selected_tracking_initialized = tracked_fp is not None bot._active_session_fp = tracked_fp bot._active_identity_signature = b"title" bot._active_chat_signature = b"old-surface" bot._activate_wx = mock.Mock(return_value=True) bot.extract_chat_text = mock.Mock( return_value="客户甲 10:00:00\n第一条消息" ) bot._has_pending_customer_message = mock.Mock(return_value=customer_waiting) bot._wait_for_message_batch = mock.Mock(return_value=True) bot._send_gate_open = mock.Mock(return_value=True) bot._generate_ai_reply = mock.Mock(return_value="这是回复") bot.send_reply = mock.Mock(return_value=True) states = {} bot._pending_reply_state = lambda key: states.get(bytes(key)) def mark(target, **kwargs): states.setdefault(bytes(target), {}).update(kwargs) return True bot._mark_reply_pending = mock.Mock(side_effect=mark) return bot def test_avatar_hash_drift_no_longer_swallows_the_first_new_message(self): """头像哈希漂 2 位仍是同一个人,第一条消息就必须回复。""" current = self._AVATAR_FP_DRIFTED + self._NAME_FP tracked = self._AVATAR_FP + self._NAME_FP # 文字判定故意给 False:跟踪中的会话只要画面变化就应进入回复链路, # 不该退回“建基线”把消息吞掉。 bot = self._selected_session_bot(current, tracked, customer_waiting=False) with mock.patch("wechat_bot.time.sleep"): self.assertTrue(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8))) bot._generate_ai_reply.assert_called_once() bot.send_reply.assert_called_once_with( "这是回复", session_id=current.hex(), expected_fp=current, ) def test_proven_render_alias_no_longer_swallows_the_first_new_message(self): """未读粗体与选中常规体已被证明是同一人,同样不能吞掉第一条消息。""" current = bytes([0x11] * 8) + self._NAME_FP tracked = bytes([0xEE] * 8) + self._NAME_FP bot = self._selected_session_bot(current, tracked, customer_waiting=False) bot._live_session_fp_aliases = { tuple(sorted((current.hex(), tracked.hex()))) } with mock.patch("wechat_bot.time.sleep"): self.assertTrue(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8))) bot._generate_ai_reply.assert_called_once() def test_a_different_contact_still_only_establishes_a_baseline(self): """名称哈希不同就是另一个人,必须停在建基线,绝不能串聊天。""" current = self._AVATAR_FP + b"n" * 32 tracked = self._AVATAR_FP + b"m" * 32 bot = self._selected_session_bot(current, tracked, customer_waiting=False) bot.store = mock.Mock() bot.store.has_record = mock.Mock(return_value=False) with mock.patch("wechat_bot.time.sleep"): self.assertFalse(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8))) bot._generate_ai_reply.assert_not_called() bot.send_reply.assert_not_called() self.assertEqual(bot._active_session_fp, current) def test_session_without_an_archive_recovers_its_first_unanswered_message(self): """还没建档的会话,第一条没人回过的客户消息不能被基线吞掉。""" current = self._AVATAR_FP + self._NAME_FP bot = self._selected_session_bot(current, None, customer_waiting=True) bot.store = mock.Mock() bot.store.has_record = mock.Mock(return_value=False) bot.store.last_lines = mock.Mock( side_effect=AssertionError("无档案时不得调用 last_lines,会建出空档案") ) with mock.patch("wechat_bot.time.sleep"): self.assertTrue(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8))) bot._generate_ai_reply.assert_called_once() bot.send_reply.assert_called_once() def test_session_without_an_archive_baselines_when_the_agent_spoke_last(self): """屏幕上最后一条是我方发的,说明没人在等回复,仍然只建基线。""" current = self._AVATAR_FP + self._NAME_FP bot = self._selected_session_bot(current, None, customer_waiting=False) bot.store = mock.Mock() bot.store.has_record = mock.Mock(return_value=False) with mock.patch("wechat_bot.time.sleep"): self.assertFalse(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8))) bot._generate_ai_reply.assert_not_called() bot.send_reply.assert_not_called() # ── 挂载哪个窗口 ───────────────────────────────────────────────────────── # 企业微信是多进程架构,子进程会创建 ClassName 和标题都一样的顶层窗口, # 它平时隐藏、且完全不渲染内容。挂到它上面会既读不到界面,又被 _ensure_visible # 强行显示成一整块黑屏。 @staticmethod def _black_window(width: int, height: int) -> np.ndarray: return np.zeros((height, width, 4), dtype=np.uint8) @staticmethod def _rendered_window(width: int, height: int) -> np.ndarray: """深色主题主界面:整体偏暗,但有大量明暗结构。""" img = np.full((height, width, 4), 45, dtype=np.uint8) img[:, ::7] = 210 img[::5, :] = 120 return img def _patched_windows(self, windows, capture): """windows: [(hwnd, rect, visible, 进程启动时间)],按 Z 序排列。""" rects = {hwnd: rect for hwnd, rect, _v, _s in windows} visibles = {hwnd: vis for hwnd, _r, vis, _s in windows} starts = {hwnd: start for hwnd, _r, _v, start in windows} def enum_windows(callback, extra): for hwnd, _rect, _vis, _start in windows: callback(hwnd, extra) return True patchers = ( mock.patch("wechat_bot.win32gui.EnumWindows", side_effect=enum_windows), mock.patch("wechat_bot.win32gui.GetClassName", return_value="WeWorkWindow"), mock.patch( "wechat_bot.win32gui.GetWindowRect", side_effect=lambda hwnd: rects[hwnd], ), mock.patch( "wechat_bot.win32gui.IsWindowVisible", side_effect=lambda hwnd: visibles[hwnd], ), mock.patch( "wechat_bot._window_process_start_time", side_effect=lambda hwnd: starts[hwnd], ), mock.patch("wechat_bot.capture_window_region", side_effect=capture), ) with ExitStack() as stack: for patcher in patchers: stack.enter_context(patcher) return stack.pop_all() def test_blank_child_window_never_wins_over_the_rendered_main_window(self): shell, main_window = 0x00480B1A, 0x000308E0 windows = [ # 空壳窗口排在 Z 序最前,FindWindow 会先返回它。 (shell, (0, 0, 2000, 1300), True, 1000.0), (main_window, (342, 428, 2598, 1746), True, 900.0), ] def capture(hwnd, _x, _y, width, height): if hwnd == shell: return self._black_window(width, height) return self._rendered_window(width, height) with self._patched_windows(windows, capture) as _patches: self.assertEqual(find_wx_hwnd(), main_window) def test_lone_hidden_window_is_kept_without_probing_pixels(self): """主面板收进托盘时只有一个候选,必须原样返回,不能因为“截图是黑的”被否掉。""" tray = 0x000308E0 windows = [(tray, (342, 428, 2598, 1746), False, 900.0)] capture = mock.Mock(side_effect=AssertionError("单个候选不应触发截图探测")) with self._patched_windows(windows, capture) as _patches: self.assertEqual(find_wx_hwnd(), tray) capture.assert_not_called() def test_all_blank_candidates_prefer_the_visible_earlier_process(self): """两个窗口都没渲染时(主面板也在托盘里),可见且进程更早的才是主界面。""" shell, main_window = 0x00480B1A, 0x000308E0 windows = [ (shell, (0, 0, 2000, 1300), False, 1000.0), (main_window, (342, 428, 2598, 1746), True, 900.0), ] def capture(_hwnd, _x, _y, width, height): return self._black_window(width, height) with self._patched_windows(windows, capture) as _patches: self.assertEqual(find_wx_hwnd(), main_window) def test_render_probe_separates_dark_theme_from_an_empty_shell(self): rect = (342, 428, 2598, 1746) width, height = rect[2] - rect[0], rect[3] - rect[1] with ( mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=rect), mock.patch( "wechat_bot.capture_window_region", return_value=self._rendered_window(width, height), ), ): self.assertTrue(window_looks_rendered(0x1)) with ( mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=rect), mock.patch( "wechat_bot.capture_window_region", return_value=self._black_window(width, height), ), ): self.assertFalse(window_looks_rendered(0x1)) # 纯色填充同样不是主界面(没有任何界面结构)。 with ( mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=rect), mock.patch( "wechat_bot.capture_window_region", return_value=np.full((height, width, 4), 200, dtype=np.uint8), ), ): self.assertFalse(window_looks_rendered(0x1)) def test_render_probe_survives_a_window_that_dies_mid_capture(self): with ( mock.patch( "wechat_bot.win32gui.GetWindowRect", return_value=(342, 428, 2598, 1746), ), mock.patch( "wechat_bot.capture_window_region", side_effect=RuntimeError("窗口尺寸异常"), ), ): self.assertFalse(window_looks_rendered(0x1)) class NothingLeftToAnswerTextProofTest(TestCase): """气泡方向看错边时,复制文字必须能把客户的新消息救回来(2026-07-31 复盘)。 现场时间线:客户刚收到我方一条长回复、马上又追了一句很短的话;底部取样把 两个气泡圈在一起,长回复的墨量让整段被判成「右侧收尾」,任务被清掉、红点 又已被点掉,客户从此收不到回复。 """ CUSTOMER_TAIL = ( "高兴亮 7/31 17:12:11\n" "在这儿呢,刚看到你的消息,有什么事你说\n" "高瑞@微信@微信联系人 7/31 17:19:30\n" "哈哈哈" ) AGENT_TAIL = ( "高瑞@微信@微信联系人 7/31 17:11:49\n" "你在干嘛\n" "高兴亮 7/31 17:12:11\n" "在这儿呢,刚看到你的消息,有什么事你说" ) def _bot(self, fp, state, extracted_text): bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_sessions = {fp.hex(): state} bot._known_outgoing_speakers = mock.Mock(return_value={"高兴亮"}) bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True) bot.extract_chat_text = mock.Mock(return_value=extracted_text) bot._commit_staged_exchange = mock.Mock() bot._reset_pending_batch = mock.Mock() bot._persist_pending_replies = mock.Mock(return_value=True) bot._clear_reply_pending = mock.Mock() bot._remember_active_surface = mock.Mock() bot._log_queue_event = mock.Mock() return bot def test_customer_tail_overrides_the_outgoing_bubble_and_keeps_the_task(self): fp = b"g" * 40 state = {"confirmed_unread": True} bot = self._bot(fp, state, self.CUSTOMER_TAIL) with redirect_stdout(io.StringIO()): self.assertFalse(bot._nothing_left_to_answer(fp)) # 没传文字时必须自己取证,而不是只信像素 bot.extract_chat_text.assert_called_once_with(screens=1) bot._clear_reply_pending.assert_not_called() def test_explicit_chat_text_skips_the_extra_extraction(self): fp = b"g" * 40 bot = self._bot(fp, {"confirmed_unread": True}, "") with redirect_stdout(io.StringIO()): self.assertFalse(bot._nothing_left_to_answer(fp, self.CUSTOMER_TAIL)) bot.extract_chat_text.assert_not_called() bot._clear_reply_pending.assert_not_called() def test_agent_tail_still_settles_and_clears_the_task(self): fp = b"g" * 40 bot = self._bot(fp, {"confirmed_unread": True}, self.AGENT_TAIL) with redirect_stdout(io.StringIO()): self.assertTrue(bot._nothing_left_to_answer(fp)) bot._clear_reply_pending.assert_called_once_with(fp) def test_delivered_reply_is_archived_before_answering_the_followup(self): fp = b"g" * 40 state = { "confirmed_unread": False, "staged_reply_text": "在这儿呢,刚看到你的消息,有什么事你说", } bot = self._bot(fp, state, self.CUSTOMER_TAIL) with redirect_stdout(io.StringIO()): self.assertFalse(bot._nothing_left_to_answer(fp)) # 我方那句已经在屏幕上:先落档,再把任务转回「客户又有新消息」 bot._commit_staged_exchange.assert_called_once_with(fp.hex()) bot._reset_pending_batch.assert_called_once_with(fp) self.assertTrue(state.get("confirmed_unread")) bot._clear_reply_pending.assert_not_called() class PendingDisplayNameBindingTest(TestCase): """点击前标题还停在上一个会话时,任务不得错绑上一个客户的名字。""" def test_display_name_waits_until_the_title_matches_the_target(self): bot = WeChatBot.__new__(WeChatBot) bot._pending_reply_sessions = {} bot.identity_by_name = True bot._persist_pending_replies = mock.Mock(return_value=True) bot._log_queue_event = mock.Mock() bot._chat_identity_signature = mock.Mock(return_value=b"") fp = WeChatBot._fp_from_name("一个小迷糊@微信") # 面板还停在上一个会话「高瑞@微信」时入队:名字对不上号,不绑 bot._session_label = mock.Mock(return_value="高瑞@微信") bot._mark_reply_pending(fp, confirmed_unread=True, bind_identity=False) state = bot._pending_reply_sessions[fp.hex()] self.assertNotIn("display_name", state) # 面板切换完成后,下一次调用补绑正确的名字 bot._session_label = mock.Mock(return_value="一个小迷糊@微信") bot._mark_reply_pending(fp, batch_ready=True) self.assertEqual(state.get("display_name"), "一个小迷糊@微信") class ManualPendingCancellationTest(TestCase): def _bot(self, state): bot = WeChatBot.__new__(WeChatBot) fp = b"c" * 40 key = fp.hex() bot._pending_lock = threading.RLock() bot._pending_reply_sessions = {key: state} bot._pending_scan_progress = {key: {"page": 1}} bot._pending_exchanges = {key: ("问题", "回复")} bot._cancelled_reply_sessions = set() bot._persist_pending_replies_unlocked = mock.Mock(return_value=True) bot._queue_log_instance = mock.Mock() bot._active_session_fp = fp bot._active_hold_key = key bot._active_hold_since = 1.0 bot._active_hold_expired = True bot._uncertain_send_last_check = {} bot._uncertain_send_last_log = {} bot._uncertain_send_last_surface = {} return bot, fp, key def test_live_manual_delete_removes_state_and_blocks_inflight_recreation(self): bot, fp, key = self._bot({ "display_name": "一个小迷糊@微信", "generation_surface_signature": b"surface", }) result = bot.cancel_pending_replies([key]) self.assertEqual(result["deleted"], [key]) self.assertNotIn(key, bot._pending_reply_sessions) self.assertNotIn(key, bot._pending_exchanges) self.assertTrue(bot._reply_task_cancelled(fp)) self.assertFalse(bot._mark_reply_pending(fp, batch_ready=True)) self.assertEqual(bot._active_chat_signature, b"surface") def test_manual_delete_protects_task_after_send_dispatch_reservation(self): bot, _fp, key = self._bot({ "display_name": "甲", "send_state": "sending", "send_dispatched_at": time.time(), }) result = bot.cancel_pending_replies([key]) self.assertEqual(result["protected"], [key]) self.assertIn(key, bot._pending_reply_sessions) self.assertFalse(bot._cancelled_reply_sessions) def test_cancelled_inflight_task_is_rejected_before_any_send_action(self): bot, fp, key = self._bot({"display_name": "甲"}) bot._cancelled_reply_sessions.add(key) self.assertFalse(bot.send_reply("不会发送", expected_fp=fp)) self.assertEqual(bot.last_send_failure_reason(), "任务已被手动删除") def setUpModule(): queue_log_redirect.start() def tearDownModule(): queue_log_redirect.stop() if __name__ == "__main__": main()