"""Synthetic assistant-review integration; no GUI actions, network or real queues.""" import json from pathlib import Path import tempfile import threading import time import unittest from unittest import mock from wechat_bot import WeChatBot class VisualAssistantTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) self.bot = WeChatBot.__new__(WeChatBot) self.fp = b'v' * 40 self.key = self.fp.hex() self.bot._pending_lock = threading.RLock() self.bot._pending_reply_path = str(self.root / 'pending.json') self.bot._review_assistant_identity = ('account-one', 42) self.bot._review_assistant_discarded_drafts = {} self.bot._pending_scan_progress = {self.key: {'old': True}} self.bot._pending_exchanges = {self.key: ('old question', 'old draft')} self.bot._queue_log_instance = mock.Mock() self.bot._stop_check = threading.Event() self.bot.enqueue_detected = mock.Mock(return_value=(True, 'ok')) self.bot._db_source = mock.Mock() self.bot._review_assistant_settings_loader = lambda: {} self.assistant = mock.Mock() self.assistant.enabled.return_value = True self.assistant.is_recipient.return_value = False self.assistant.ensure_review.return_value = 'review-one' self.assistant.reconcile.return_value = { 'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2', 'server_id': '11', 'rowid': 12, 'send_time': time.time(), 'dedup_key': 'human-event'} self.assistant.resumed.return_value = True self.assistant.has_pending.return_value = False self.bot._get_review_assistant = mock.Mock(return_value=self.assistant) self.state = { 'created_at': time.time(), 'updated_at': time.time(), 'display_name': 'Synthetic customer', 'account': 'account-one', 'conv_id': 'S:1_2', 'awaiting_review': True, 'stage': 'manual_review', 'review_origin': 'global', 'reply_text': 'old AI draft', 'staged_reply_text': 'old AI draft', 'staged_user_text': 'old question', 'last_pasted_draft': 'old AI draft', 'review_requested_at': time.time()-2, } self.bot._pending_reply_sessions = {self.key: self.state} human = {'is_self': True, 'sender_id': 'account-one', 'dedup_key': 'human-event', 'server_id': '11', 'rowid': 12, 'send_time': time.time(), 'content': 'human answer'} self.context = {'account': 'account-one', 'conv_id': 'S:1_2', 'display_name': 'Synthetic customer', 'messages': [human], 'last_message': human, 'text': 'Synthetic transcript'} self.bot._db_source.get_conversation_context_by_id.side_effect = lambda *a, **kw: self.context self.bot._db_source.get_conversation_context.side_effect = lambda *a, **kw: self.context for operation in ('click', 'press', 'hotkey', 'write'): self.enterContext(mock.patch('wechat_bot.pyautogui.' + operation, side_effect=AssertionError('No desktop actions in assistant reconciliation'))) def poll(self): self.bot._review_assistant_poll_at = 0 self.bot.poll_review_assistant() def test_proven_human_reply_discards_old_task_without_approving_or_sending(self): self.poll() self.assertNotIn(self.key, self.bot._pending_reply_sessions) self.assertNotIn(self.key, self.bot._pending_exchanges) self.assertNotIn(self.key, self.bot._pending_scan_progress) self.assertFalse(self.state.get('review_approved')) self.assertEqual(self.bot._queue_log_instance.append.call_args.args[2], '已审核') self.bot.enqueue_detected.assert_not_called() data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8')) self.assertEqual(data['pending'], {}) self.assertEqual(data['assistant_discarded_drafts']['account-one\0S:1_2']['draft'], 'old AI draft') def test_no_evidence_preserves_review_and_persists_review_id(self): self.assistant.reconcile.return_value = None self.poll() self.assertIs(self.bot._pending_reply_sessions[self.key], self.state) self.assertTrue(self.state['awaiting_review']) data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8')) self.assertEqual(data[self.key]['assistant_review_id'], 'review-one') self.assistant.enqueue_notifications.assert_called_once() def test_wrong_account_or_conversation_or_review_evidence_cannot_clear_task(self): for field in ('account', 'conv_id', 'review_id'): with self.subTest(field=field): evidence = dict(self.assistant.reconcile.return_value) self.assistant.reconcile.return_value = {**evidence, field: 'different'} self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.assistant.reconcile.return_value = evidence def test_unknown_failed_or_sending_are_never_auto_completed(self): for field in ('send_state', 'stage'): for value in ('sending', 'uncertain', 'unknown', 'failed', 'sent_uncommitted'): with self.subTest(field=field, value=value): original = dict(self.state) self.state[field] = value self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.state.clear() self.state.update(original) self.assistant.reconcile.assert_not_called() def test_completed_review_does_not_erase_a_concurrent_replacement(self): replacement = {**self.state, 'model_task_id': 'new-task', 'reply_text': 'new-draft'} def reconcile(*args): self.bot._pending_reply_sessions[self.key] = replacement return {'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2'} self.assistant.reconcile.side_effect = reconcile self.poll() self.assertIs(self.bot._pending_reply_sessions[self.key], replacement) def test_new_database_event_arriving_during_read_is_retained(self): def reconcile(*args): self.state['database_event'] = {'message_id': 'new-unseen-event'} return {'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2'} self.assistant.reconcile.side_effect = reconcile self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) def test_persistence_failure_restores_review_and_old_exchange(self): with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]): self.poll() self.assertIs(self.bot._pending_reply_sessions[self.key], self.state) self.assertIn(self.key, self.bot._pending_exchanges) self.assertEqual(self.bot._review_assistant_discarded_drafts, {}) self.bot._queue_log_instance.append.assert_not_called() def test_new_customer_message_after_human_reply_starts_fresh_task(self): newer = {'is_self': False, 'dedup_key': 'next-question', 'content': 'new question', 'send_time': time.time()+1} self.context['messages'].append(newer) self.context['last_message'] = newer self.poll() self.bot.enqueue_detected.assert_called_once() self.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['message_id'], 'next-question') self.assertNotIn('review_approved', self.bot.enqueue_detected.call_args.kwargs) def test_retired_draft_only_matches_exact_original_and_verified_identity(self): self.poll() next_state = {'account': 'account-one', 'conv_id': 'S:1_2'} self.assertTrue(self.bot._is_discarded_review_draft(next_state, 'old AI draft')) self.assertFalse(self.bot._is_discarded_review_draft(next_state, 'edited human draft')) self.assertFalse(self.bot._is_discarded_review_draft(next_state, ' old AI draft')) self.assertFalse(self.bot._is_discarded_review_draft({**next_state, 'account': 'other'}, 'old AI draft')) self.assertFalse(self.bot._is_discarded_review_draft({**next_state, 'send_state': 'uncertain'}, 'old AI draft')) def test_retired_draft_and_review_binding_survive_restart(self): self.assistant.reconcile.return_value = None self.poll() restored = self.bot._load_pending_replies()[self.key] self.assertEqual(restored['assistant_review_id'], 'review-one') self.assertEqual(restored['account'], 'account-one') self.assertEqual(restored['conv_id'], 'S:1_2') self.bot._review_assistant_discarded_drafts = {'account-one\0S:1_2': { 'account': 'account-one', 'conv_id': 'S:1_2', 'draft': 'old AI draft'}} self.bot._persist_pending_replies() self.bot._load_pending_replies() self.assertTrue(self.bot._review_assistant_discarded_drafts) def test_resumed_skips_blanket_only_and_new_risk_still_requires_review(self): self.bot.send_mode = 'review' self.bot._reply_needs_review = mock.Mock(return_value=(True, 'new high risk')) decision = self.bot._reply_review_decision(self.state, 'new question', 'new answer') self.assertEqual(decision, (False, True, 'new high risk')) self.bot._reply_needs_review.assert_called_once_with(self.state, 'new question', 'new answer') self.assistant.resumed.return_value = False self.assertEqual(self.bot._reply_review_decision(self.state, 'q', 'a'), (True, True, 'new high risk')) def test_assistant_contact_is_filtered_by_exact_conversation(self): self.assistant.is_recipient.return_value = True self.assertTrue(self.bot._review_assistant_recipient(self.state)) self.assistant.is_recipient.assert_called_once_with('S:1_2') self.context['account'] = 'wrong' self.assertFalse(self.bot._review_assistant_recipient(self.state)) def test_missing_database_and_cross_account_context_cannot_complete(self): self.context['account'] = 'different' self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.bot._db_source = None self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.assistant.ensure_review.assert_not_called() def test_disabled_config_never_imports_native_or_starts_coordinator(self): del self.bot._get_review_assistant self.bot.hwnd = 123 with mock.patch('wechat_bot.win32process.GetWindowThreadProcessId', side_effect=AssertionError('No native probe')): self.assertIsNone(self.bot._get_review_assistant(verify=True)) def test_stop_suppresses_read_and_notification(self): self.bot._stop_check.set() self.poll() self.bot._get_review_assistant.assert_not_called() self.assistant.enqueue_notifications.assert_not_called() def test_notification_registration_requires_durable_state(self): with mock.patch.object(self.bot, '_persist_pending_replies', return_value=False): self.bot._register_assistant_review(self.fp) self.assistant.ensure_review.assert_not_called() self.assistant.enqueue_notifications.assert_not_called() def test_pending_review_is_not_bypassed_by_new_safe_message(self): self.bot.send_mode = 'auto' self.assistant.has_pending.return_value = True self.bot._reply_needs_review = mock.Mock(return_value=(False, '')) blanket, required, reason = self.bot._reply_review_decision({}, 'new question', 'safe answer') self.assertFalse(blanket) self.assertTrue(required) self.assertIn('待审核', reason) self.bot._reply_needs_review.assert_called_once() def test_late_mobile_sync_does_not_hide_new_customer_message(self): human = dict(self.context['last_message']) human.update(send_time=3, rowid=5) newer = {'is_self': False, 'dedup_key': 'next-question', 'content': 'new question', 'send_time': 4, 'rowid': 4, 'account': 'account-one', 'conv_id': 'S:1_2'} self.context['messages'] = [newer, human] self.context['last_message'] = human self.assistant.reconcile.return_value.update(send_time=3, rowid=5) self.poll() self.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['message_id'], 'next-question') def test_send_attempt_failure_prevents_dispatch_and_local_completion_keeps_approval(self): self.assistant.record_automated_attempt.side_effect = OSError('synthetic disk failure') self.assertFalse(self.bot._record_assistant_automated_attempt(self.state, 'new draft')) self.state['review_approved'] = True self.bot._record_assistant_automated_send(self.state) self.assistant.complete_local_review.assert_called_once_with(self.state) self.assistant.record_automated_send.assert_not_called() # No real server receipt. def test_discarded_draft_marker_is_consumed_once(self): self.poll() current = {'account': 'account-one', 'conv_id': 'S:1_2'} self.assertTrue(self.bot._is_discarded_review_draft(current, 'old AI draft')) self.assertTrue(self.bot._retire_discarded_review_marker(current)) self.assertFalse(self.bot._is_discarded_review_draft(current, 'old AI draft')) self.bot._load_pending_replies() self.assertEqual(self.bot._review_assistant_discarded_drafts, {}) def test_draft_marker_retirement_failure_retains_protection(self): self.poll() current = {'account': 'account-one', 'conv_id': 'S:1_2'} with mock.patch.object(self.bot, '_persist_pending_replies', return_value=False): self.assertFalse(self.bot._retire_discarded_review_marker(current)) self.assertTrue(self.bot._is_discarded_review_draft(current, 'old AI draft')) class SharedVisualAssistantTests(unittest.TestCase): """Use the real durable coordinator, keeping all transports mocked.""" setUp = VisualAssistantTests.setUp poll = VisualAssistantTests.poll def use_real_coordinator(self): from review_assistant import ReviewAssistantCoordinator self.bot._review_assistant_identity = ('123', 42) self.state['account'] = '123' self.context['account'] = '123' incoming = {'account': '123', 'conv_id': 'S:1_2', 'is_self': False, 'sender_id': '2', 'dedup_key': 'incoming-one', 'server_id': '10', 'rowid': 1, 'send_time': time.time()-1, 'content': 'original question'} self.context['messages'] = [incoming] self.context['last_message'] = incoming coordinator = ReviewAssistantCoordinator(root=self.root, account='123', pid=42, settings_loader=lambda: {'review_assistant_accounts': {'123': { 'enabled': True, 'recipient_conv_id': 'M:9'}}}) coordinator.enqueue_notifications = mock.Mock() self.addCleanup(coordinator.close) self.bot._get_review_assistant.return_value = coordinator self.bot._register_assistant_review(self.fp) self.assertTrue(self.state.get('assistant_review_id')) return coordinator def add_human(self, *, content='human phone reply'): outgoing = {'account': '123', 'conv_id': 'S:1_2', 'is_self': True, 'sender_id': '123', 'dedup_key': 'human-two', 'server_id': '11', 'rowid': 2, 'send_time': time.time()+.1, 'content': content} self.context['messages'].append(outgoing) self.context['last_message'] = outgoing def test_real_shared_human_completion_then_new_risk(self): coordinator = self.use_real_coordinator() self.add_human() self.poll() self.assertNotIn(self.key, self.bot._pending_reply_sessions) self.assertTrue(coordinator.resumed('S:1_2')) self.bot.send_mode = 'review' self.bot._reply_needs_review = mock.Mock(return_value=(True, 'new risk')) self.assertEqual(self.bot._reply_review_decision({'account':'123', 'conv_id':'S:1_2'}, 'q', 'a'), (False, True, 'new risk')) def test_real_shared_automated_attempt_is_not_human_reply(self): coordinator = self.use_real_coordinator() coordinator.record_automated_attempt(self.state) self.add_human(content='old AI draft') self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.assertFalse(coordinator.resumed('S:1_2')) def test_real_shared_local_approval_closes_reminder_without_resuming(self): coordinator = self.use_real_coordinator() self.state['review_approved'] = True self.bot._record_assistant_automated_send(self.state) self.assertFalse(coordinator.has_pending('S:1_2')) self.assertFalse(coordinator.resumed('S:1_2')) def test_real_shared_completion_retries_original_episode_after_disk_failure(self): coordinator = self.use_real_coordinator() review_id = self.state['assistant_review_id'] self.add_human() with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]): self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.assertTrue(coordinator.resumed('S:1_2')) self.poll() self.assertNotIn(self.key, self.bot._pending_reply_sessions) self.assertEqual(self.state['assistant_review_id'], review_id) def test_explicit_cancel_closes_shared_episode_without_native_io(self): coordinator = self.use_real_coordinator() self.bot._forget_uncertain_tracking = mock.Mock() result = self.bot.cancel_pending_replies([self.key]) self.assertEqual(result['deleted'], [self.key]) self.assertFalse(coordinator.has_pending('S:1_2')) self.assertFalse(coordinator.resumed('S:1_2')) def test_explicit_handoff_closes_shared_episode_without_approval(self): coordinator = self.use_real_coordinator() result = self.bot.handoff_pending_replies([self.key]) self.assertEqual(result['handed_off'], [self.key]) self.assertFalse(coordinator.has_pending('S:1_2')) self.assertFalse(coordinator.resumed('S:1_2')) def test_failed_queue_cancel_keeps_shared_episode(self): coordinator = self.use_real_coordinator() self.bot._forget_uncertain_tracking = mock.Mock() with mock.patch.object(self.bot, '_persist_pending_replies_unlocked', return_value=False): result = self.bot.cancel_pending_replies([self.key]) self.assertFalse(result['deleted']) self.assertTrue(coordinator.has_pending('S:1_2')) def test_stopped_client_cancel_closes_reminder_in_queue_data_directory(self): from gui_runtime import delete_pending_reply_file coordinator = self.use_real_coordinator() with mock.patch('queue_log.QueueLog'): result = delete_pending_reply_file([self.key], self.bot._pending_reply_path) self.assertEqual(result['deleted'], [self.key]) self.assertFalse(coordinator.has_pending('S:1_2')) def test_failed_reminder_cancel_is_reported_without_claiming_success(self): from gui_runtime import cancel_assistant_review_for_state with mock.patch('review_assistant.ReviewAssistantCoordinator') as factory, mock.patch('builtins.print') as log: factory.return_value.cancel_review.return_value = False self.assertFalse(cancel_assistant_review_for_state({'account':'123', 'assistant_review_id':'r'}, self.root)) self.assertTrue(log.called) if __name__ == '__main__': unittest.main()