"""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()