288 lines
18 KiB
Python
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()
|