8391 lines
378 KiB
Python
8391 lines
378 KiB
Python
# -*- 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()
|