"""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', 'completed_by_human': True} 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') tombstone = next(iter(data['manual_handoffs'].values())) self.assertTrue(tombstone['completed_by_human']) self.assertTrue(tombstone['evidence']['completed_by_human']) self.assertEqual(tombstone['state']['stage'], 'manual_review') self.assertTrue(tombstone['resumed_at']) self.assertEqual(self.bot._manual_handoff_key(self.fp), '') 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_active_sending_cancel_or_unrecognized_send_state_cannot_complete(self): for overrides in ({'send_state': 'sending'}, {'stage': 'sending'}, {'send_state': 'unexpected'}, {'stage': 'receipt_check'}, {'cancel_requested': True}, {'stage': 'cancelled'}): with self.subTest(overrides=overrides): original = dict(self.state) self.state.update(overrides) 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_terminal_states_require_explicit_human_completion_evidence(self): for value in ('uncertain', 'unknown', 'failed', 'sent_uncommitted'): with self.subTest(value=value): self.state.update(send_state=value, stage='receipt_check') self.assistant.reconcile.return_value.pop('completed_by_human', None) self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.assertEqual(self.assistant.reconcile.call_count, 4) def test_all_ui_attention_states_register_and_notify(self): from review_state import task_review_state cases = [{'manual_required': True}, {'stage': 'error', 'task_error': 'no model answer'}, {'foreign_draft_text': 'human draft'}, {'stage': 'manual_takeover'}, {'stage': 'failed'}, {'send_state': 'failed'}, {'stage': 'receipt_check', 'send_state': 'unknown'}, {'stage': 'manual_review', 'awaiting_review': True}] for extra in cases: with self.subTest(extra=extra): self.state.clear() self.state.update(account='account-one', conv_id='S:1_2', **extra) flags = task_review_state(self.state) self.assertTrue(flags['awaitingReview'] or flags['needsHuman']) self.bot._register_assistant_review(self.fp) self.assertEqual(self.state['assistant_review_id'], 'review-one') self.assertEqual(self.assistant.ensure_review.call_count, len(cases)) self.assertEqual(self.assistant.enqueue_notifications.call_count, len(cases)) def test_failed_review_id_save_rolls_back_and_does_not_enqueue(self): with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]): self.bot._register_assistant_review(self.fp) self.assertNotIn('assistant_review_id', self.state) self.assistant.enqueue_notifications.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', 'completed_by_human': True} 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', 'completed_by_human': True} 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, 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.assertEqual(self.bot._manual_handoffs, {}) 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, 'rowid': 13} 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.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['rowid'], 13) 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_real_manual_task_without_draft_completes_after_human_reply(self): self.state.update(awaiting_review=False, manual_required=True, stage='error', reply_text='', staged_reply_text='', last_pasted_draft='', task_error='voice transcription unavailable') coordinator = self.use_real_coordinator() self.assertTrue(coordinator.has_pending('S:1_2')) self.add_human() self.poll() self.assertNotIn(self.key, self.bot._pending_reply_sessions) self.assertTrue(coordinator.resumed('S:1_2')) self.assertEqual(self.bot._queue_log_instance.append.call_args.args[2], '人工已完成') self.bot.enqueue_detected.assert_not_called() def test_real_terminal_send_requires_distinct_human_and_keeps_original_evidence(self): coordinator = self.use_real_coordinator() self.state.update(awaiting_review=False, stage='receipt_check', send_state='unknown', send_dispatched_at=time.time()-2, send_id='original-send-id') 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')) human = dict(self.context['messages'][-1]) human.update(content='different verified human response', rowid=3, server_id='12', dedup_key='human-three', send_time=time.time()+.2) self.context['messages'].append(human) self.context['last_message'] = human self.poll() self.assertNotIn(self.key, self.bot._pending_reply_sessions) data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8')) tombstone = next(iter(data['manual_handoffs'].values())) self.assertEqual(tombstone['state']['send_state'], 'unknown') self.assertEqual(tombstone['state']['send_id'], 'original-send-id') self.assertEqual(tombstone['evidence']['server_id'], '12') self.assertFalse(tombstone['delivery_confirmed']) self.bot._load_pending_replies() self.assertEqual(self.bot._manual_handoff_key(self.fp), '') self.bot.enqueue_detected.assert_not_called() def test_real_failed_send_and_local_persistence_failure_keep_task_until_durable(self): coordinator = self.use_real_coordinator() self.state.update(awaiting_review=False, send_state='failed', stage='failed', manual_required=True) 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.assertEqual(self.bot._manual_handoffs, {}) self.assertEqual(self.bot._review_assistant_discarded_drafts, {}) self.poll() self.assertNotIn(self.key, self.bot._pending_reply_sessions) tombstone = next(iter(self.bot._manual_handoffs.values())) self.assertEqual(tombstone['state']['send_state'], 'failed') self.assertTrue(tombstone['evidence']['completed_by_human']) self.assertTrue(coordinator.resumed('S:1_2')) def test_real_terminal_automated_reply_never_completes(self): coordinator = self.use_real_coordinator() self.state.update(awaiting_review=False, send_state='uncertain', stage='receipt_check') coordinator.record_automated_attempt({**self.state, 'reply_text': 'automated different response'}) self.add_human(content='automated different response') self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.assertFalse(coordinator.resumed('S:1_2')) def test_real_assistant_conversation_cannot_complete_customer_task(self): coordinator = self.use_real_coordinator() self.add_human() self.context['messages'][-1]['conv_id'] = 'M:9' self.poll() self.assertIn(self.key, self.bot._pending_reply_sessions) self.assertFalse(coordinator.resumed('S:1_2')) 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()