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