Files
kefu/wechat_rpa/test_visual_session_exclusions.py
T
2026-09-21 10:34:06 +08:00

288 lines
18 KiB
Python

"""Synthetic visual exclusion checks; never touch a window, queue file or network."""
import copy
import socket
import threading
import time
import unittest
from unittest import mock
from wechat_bot import WeChatBot
from reply_session_policy import APPLICATION_REASON, GROUP_REASON
class VisualSessionExclusionTests(unittest.TestCase):
def setUp(self):
self.bot = WeChatBot.__new__(WeChatBot)
self.fp = self.bot._fp_from_name('合成会话')
self.key = self.fp.hex()
self.state = {'display_name': '合成会话', 'stage': 'queued', 'created_at': time.time()}
self.bot._pending_reply_sessions = {self.key: self.state}
self.bot._pending_lock = threading.RLock()
self.bot._persist_pending_replies = mock.Mock(return_value=True)
self.bot._forget_uncertain_tracking = mock.Mock()
self.bot._queue_log_instance = mock.Mock()
self.bot.report_progress = mock.Mock()
self.bot._open_chat_display_name = mock.Mock(return_value='合成会话(8)')
self.bot._chat_identity_signature = mock.Mock(return_value=b'header')
self.bot._capture_full_window = mock.Mock(return_value=object())
self.bot._message_workspace_selected = mock.Mock(return_value=True)
self.bot._raw_selected_session_fingerprint = mock.Mock(return_value=self.fp)
self.bot._selected_session_fingerprint = mock.Mock(return_value=self.fp)
self.bot._chat_target_matches = mock.Mock(return_value=True)
self.bot._chat_surface_signature = mock.Mock(return_value=b'surface')
self.bot._active_session_fp = self.fp
self.enterContext(mock.patch('wechat_bot.time.sleep'))
self.enterContext(mock.patch.object(socket, 'create_connection', side_effect=AssertionError('network forbidden')))
for function in ('click', 'press', 'hotkey', 'write', 'moveTo'):
self.enterContext(mock.patch('wechat_bot.pyautogui.' + function, side_effect=AssertionError('desktop action forbidden')))
def test_application_exact_name_rejects_queue_before_model_or_window_read(self):
self.assertFalse(self.bot._mark_reply_pending(self.fp, display_name='企 小 码 会话管理'))
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
self.bot._open_chat_display_name.assert_not_called()
self.assertEqual(self.bot._last_session_exclusion_reason, APPLICATION_REASON)
self.assertTrue(self.bot._is_system_entry_name('企小码会话管理'))
def test_external_group_identity_cannot_enqueue(self):
accepted, reason = self.bot.enqueue_detected(self.key, display_name='合成会话',
database_event={'account': 'synthetic-account', 'conv_id': 'R:synthetic', 'message_id': 'synthetic'})
self.assertFalse(accepted)
self.assertEqual(reason, GROUP_REASON)
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
self.bot._open_chat_display_name.assert_not_called()
def test_group_metadata_stops_model_wrapper_without_generating(self):
self.state['database_event'] = {'conv_id': 'R:synthetic'}
self.bot._generate_ai_reply_impl = mock.Mock()
self.assertIsNone(self.bot._generate_ai_reply(self.fp, chat_text='synthetic content'))
self.bot._generate_ai_reply_impl.assert_not_called()
self.assertTrue(self.bot._generation_logged)
def test_excluded_application_stops_send_before_any_input(self):
self.state['display_name'] = '企小码会话管理'
self.assertFalse(self.bot._send_reply_inner('synthetic reply', expected_fp=self.fp))
self.assertEqual(self.bot.last_send_failure_reason(), APPLICATION_REASON)
def test_stable_group_title_requires_two_headers_then_skips_without_reopening(self):
self.assertTrue(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertEqual(self.bot._open_chat_display_name.call_count, 2)
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
self.bot.wait_for_mouse_idle = mock.Mock(side_effect=AssertionError('must not reopen cached group'))
self.assertFalse(self.bot.click_session(20, expected_fp=self.fp))
self.bot.wait_for_mouse_idle.assert_not_called()
self.assertEqual(self.bot._last_click_failure_reason, 'excluded_session')
def test_selected_identity_mismatch_does_not_remove_previous_task(self):
self.bot._raw_selected_session_fingerprint.return_value = self.bot._fp_from_name('其他合成客户')
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
self.bot._persist_pending_replies.assert_not_called()
def test_title_change_between_frames_does_not_remove_previous_task(self):
self.bot._open_chat_display_name.side_effect = ['合成会话(8)', '其他客户@微信']
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIn(self.key, self.bot._pending_reply_sessions)
def test_header_identity_change_between_frames_is_not_group_proof(self):
self.bot._chat_identity_signature.side_effect = [b'first', b'first', b'second']
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIn(self.key, self.bot._pending_reply_sessions)
def test_member_like_personal_nickname_is_not_excluded(self):
self.state['display_name'] = '合成会话(8)'
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIn(self.key, self.bot._pending_reply_sessions)
def test_known_personal_database_id_does_not_use_title_heuristics_or_old_group_cache(self):
self.state['database_event'] = {'conv_id': 'M:synthetic-person'}
self.bot._excluded_session_cache = {self.key: {'reason': GROUP_REASON, 'until': time.monotonic() + 60}}
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.bot._open_chat_display_name.assert_not_called()
self.assertIn(self.key, self.bot._pending_reply_sessions)
def test_wechat_label_does_not_count_as_group_even_with_member_suffix(self):
self.bot._open_chat_display_name.return_value = '合成会话@微信(8)'
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIn(self.key, self.bot._pending_reply_sessions)
def test_no_group_inference_from_body_or_customer_name_containing_group_word(self):
self.state.update(display_name='群芳', chat_text='企小码会话管理 群聊 群公告 (8)', last_lines=['群聊'])
self.bot._open_chat_display_name.return_value = '群芳@微信'
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIn(self.key, self.bot._pending_reply_sessions)
def test_protected_delivery_evidence_is_preserved_for_every_exclusion(self):
cases = [{'send_state': value} for value in ('sending', 'uncertain', 'unknown', 'sent_uncommitted', 'failed')]
cases += [{'stage': value} for value in ('sending', 'receipt_check', 'uncertain', 'unknown', 'sent_uncommitted', 'failed')]
cases += [{'send_dispatched_at': 123.0}]
for fields in cases:
with self.subTest(fields=fields):
state = {'display_name': '企小码会话管理', 'stage': 'queued', 'reply_text': 'synthetic evidence', **fields}
before = copy.deepcopy(state)
self.bot._pending_reply_sessions = {self.key: state}
self.assertTrue(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertEqual(self.bot._pending_reply_sessions[self.key], before)
self.bot._persist_pending_replies.assert_not_called()
def test_startup_system_purge_keeps_uncertain_transaction(self):
self.state.update(display_name='企小码会话管理', send_state='uncertain', stage='receipt_check')
before = copy.deepcopy(self.state)
self.assertEqual(self.bot._purge_system_pending_tasks(), 0)
self.assertEqual(self.bot._pending_reply_sessions[self.key], before)
def test_expired_group_cache_is_rechecked_instead_of_permanent_blacklist(self):
self.bot._excluded_session_cache = {self.key: {'reason': GROUP_REASON, 'until': time.monotonic() - 1}}
self.bot._open_chat_display_name.return_value = '合成会话@微信'
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.bot._open_chat_display_name.assert_called_once()
def test_vanished_unread_row_requires_separate_list_name_and_stable_header(self):
self.bot._raw_selected_session_fingerprint.return_value = b''
self.bot._selected_session_fingerprint.return_value = b''
self.assertTrue(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertEqual(self.bot._open_chat_display_name.call_count, 2)
def test_mixed_unread_list_skips_application_and_group_then_selects_next_customer(self):
customer = self.bot._fp_from_name('合成客户乙')
self.bot.identity_by_name = True
self.bot.detect_badge_rows = mock.Mock(return_value=[10, 20, 30])
self.bot._row_display_name = mock.Mock(side_effect=['企小码会话管理', '合成会话', '合成客户乙'])
self.bot._session_fingerprint = mock.Mock(side_effect=lambda image, row: {20: self.fp, 30: customer}[row])
self.bot._excluded_session_cache = {self.key: {'reason': GROUP_REASON, 'until': time.monotonic() + 60}}
self.bot._flat_session_rejected = mock.Mock(return_value=False)
self.bot._is_real_conversation = mock.Mock(return_value=True)
self.bot._flat_row_requires_visual_proof = mock.Mock(return_value=False)
skipped = set()
self.assertEqual(self.bot._target_from_session_image(object(), set(), skipped), (30, customer))
self.assertIn(self.fp, skipped)
self.assertIn(self.bot._fp_from_name('企小码会话管理'), skipped)
self.bot._open_chat_display_name.assert_not_called()
def test_vanished_selection_with_other_application_header_keeps_customer(self):
self.bot._raw_selected_session_fingerprint.return_value = b''
self.bot._selected_session_fingerprint.return_value = b''
self.bot._open_chat_display_name.return_value = '企小码会话管理'
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
self.bot._persist_pending_replies.assert_not_called()
self.assertNotIn(self.key, self.bot._excluded_session_cache)
def test_vanished_selection_with_other_group_member_header_keeps_customer(self):
self.bot._raw_selected_session_fingerprint.return_value = b''
self.bot._selected_session_fingerprint.return_value = b''
self.bot._open_chat_display_name.return_value = '其他合成群(8人)'
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
self.bot._persist_pending_replies.assert_not_called()
self.assertNotIn(self.key, self.bot._excluded_session_cache)
def test_vanished_selection_rebinds_second_header_before_excluding(self):
self.bot._raw_selected_session_fingerprint.return_value = b''
self.bot._selected_session_fingerprint.return_value = b''
self.bot._open_chat_display_name.side_effect = ['合成会话(8人)', '其他合成群(8人)']
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
self.bot._persist_pending_replies.assert_not_called()
def test_unknown_task_name_reads_list_before_explicit_member_suffix(self):
self.state['display_name'] = ''
self.bot._open_chat_display_name.return_value = '普通客户(2人)'
self.bot.capture_session_list = mock.Mock(return_value=object())
self.bot.detect_selected_row = mock.Mock(return_value=10)
self.bot._session_fingerprint = mock.Mock(return_value=self.fp)
self.bot._row_display_name = mock.Mock(return_value='普通客户(2人)')
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.bot._row_display_name.assert_called_once()
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
self.bot._persist_pending_replies.assert_not_called()
def test_task_replacement_during_header_confirmation_is_not_removed_or_cached(self):
replacement = {'display_name': '合成新任务', 'stage': 'queued', 'database_event': {'conv_id': 'M:synthetic-new'}}
def title():
if self.bot._open_chat_display_name.call_count == 2:
self.bot._pending_reply_sessions[self.key] = replacement
return '合成会话(8)'
self.bot._open_chat_display_name.side_effect = title
self.assertTrue(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIs(self.bot._pending_reply_sessions[self.key], replacement)
self.bot._persist_pending_replies.assert_not_called()
self.assertNotIn(self.key, self.bot._excluded_session_cache)
def test_partial_legacy_fingerprint_does_not_prove_unrelated_current_title(self):
for legacy in (b'l' * 8, b'l' * 16):
with self.subTest(length=len(legacy)):
state = {'display_name': '旧合成客户', 'stage': 'queued'}
self.bot._pending_reply_sessions = {legacy.hex(): state}
self.bot._raw_selected_session_fingerprint.return_value = b'other'
self.bot._open_chat_display_name.return_value = '企小码会话管理'
self.assertFalse(self.bot._skip_excluded_reply_session(legacy, inspect_open=True))
self.assertIs(self.bot._pending_reply_sessions[legacy.hex()], state)
self.bot._persist_pending_replies.assert_not_called()
def _configure_mixed_poll(self):
bot = self.bot
customer = bot._fp_from_name('合成下一客户')
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot.false_pos_rows = set()
bot.session_item_h = 64
bot.list_region = {'top': 0}
bot.list_click_x = 0
false_calls = ('_dismiss_owned_blocking_window', '_security_gate_visible', '_dismiss_internal_blocker',
'_check_selected_session', '_resume_orphaned_pending_reply', '_hold_for_unfinished_active_session',
'_reply_awaiting_review', '_foreign_draft_hold_active', '_nothing_left_to_answer')
true_calls = ('wait_for_mouse_idle', '_ensure_visible', '_message_nav_selected', '_send_gate_open',
'_begin_reply_pending_from_unread', '_activate_wx', '_mark_reply_pending',
'_session_accepts_replies', '_wait_for_message_batch', 'send_reply')
for name in false_calls:
setattr(bot, name, mock.Mock(return_value=False))
for name in true_calls:
setattr(bot, name, mock.Mock(return_value=True))
for name in ('_drop_stale_review_flags', '_clear_workspace_recovery_failures', '_refresh_message_geometry',
'_set_task_stage', '_log_queue_event', '_log_send_outcome'):
setattr(bot, name, mock.Mock())
bot.capture_session_list = mock.Mock(return_value=object())
bot._find_next_unread_session = mock.Mock(side_effect=[(object(), 10, self.fp), (object(), 20, customer), None])
bot._save_task_visual_evidence = mock.Mock(return_value='')
bot._row_center_from_badge = mock.Mock(return_value=20)
bot._session_label = mock.Mock(return_value='合成下一客户')
bot._generate_ai_reply = mock.Mock(return_value='合成回复')
return customer
def test_post_click_exclusion_continues_current_round_to_next_customer(self):
customer = self._configure_mixed_poll()
def click(row, expected_fp=None, **kwargs):
if expected_fp == self.fp:
self.bot._last_click_failure_reason = 'excluded_session'
return False
self.bot._last_click_failure_reason = ''
return True
self.bot.click_session = mock.Mock(side_effect=click)
self.bot._poll_once()
self.assertEqual(self.bot._find_next_unread_session.call_count, 3)
self.bot.send_reply.assert_called_once_with('合成回复', session_id=customer.hex(), expected_fp=customer)
self.bot._generate_ai_reply.assert_called_once_with(customer, confirmed_unread=True)
def test_post_click_queue_exclusion_continues_current_round_to_next_customer(self):
customer = self._configure_mixed_poll()
self.bot.click_session = mock.Mock(return_value=True)
def mark(fp, **kwargs):
if fp == self.fp:
self.bot._excluded_session_cache = {self.key: {'reason': GROUP_REASON, 'until': time.monotonic() + 60}}
return False
return True
self.bot._mark_reply_pending.side_effect = mark
self.bot._poll_once()
self.assertEqual(self.bot._find_next_unread_session.call_count, 3)
self.bot.send_reply.assert_called_once_with('合成回复', session_id=customer.hex(), expected_fp=customer)
self.bot._generate_ai_reply.assert_called_once_with(customer, confirmed_unread=True)
def test_ocr_failure_is_not_a_reason_to_drop_customer(self):
self.bot._open_chat_display_name.side_effect = RuntimeError('synthetic OCR unavailable')
self.assertFalse(self.bot._skip_excluded_reply_session(self.fp, inspect_open=True))
self.assertIn(self.key, self.bot._pending_reply_sessions)
if __name__ == '__main__':
unittest.main()