"""Synthetic visual voice scheduling; no desktop, messages, ASR or network calls.""" import threading import tempfile from pathlib import Path import time import unittest from unittest import mock from wechat_bot import WeChatBot class VisualVoiceSchedulingTests(unittest.TestCase): def setUp(self): self.bot = WeChatBot.__new__(WeChatBot) self.fp = b'v' * 40 self.key = self.fp.hex() self.state = {'stage': 'queued', 'created_at': time.time(), 'batch_ready': True} self.bot._pending_reply_sessions = {self.key: self.state} self.bot._pending_lock = threading.RLock() self.bot._active_session_fp = self.fp self.bot._persist_pending_replies = mock.Mock(return_value=True) self.bot._skip_excluded_reply_session = mock.Mock(return_value=False) self.bot._consume_database_update = mock.Mock() self.bot._generate_ai_reply_impl = mock.Mock(return_value='verified fresh answer') self.bot._log_queue_event = mock.Mock() self.bot._register_assistant_review = mock.Mock() self.bot._set_task_stage = mock.Mock(side_effect=self.stage) self.voice = {'account': '100', 'conv_id': 'M:200', 'server_id': '123', 'content_type': 16, 'voice_status': 'pending', 'dedup_key': 'voice:123', 'is_self': False, 'content': '[语音待转文字]'} self.bot._last_database_read = {'fp_hex': self.key, 'account': '100', 'conv_id': 'M:200', 'messages': [self.voice], 'text': 'synthetic voice'} def stage(self, fp, stage, **kwargs): self.bot._pending_reply_sessions[fp.hex()].update(stage=stage, stage_error=kwargs.get('error', '')) def test_pending_voice_yields_active_hold_and_keeps_precise_status(self): self.assertTrue(self.bot._wait_for_database_voice(self.fp)) self.assertEqual(self.state['stage'], 'voice_transcribing') self.assertFalse(self.bot._hold_for_unfinished_active_session()) self.bot._note_resume_failure(self.state) self.bot._log_generation_gap(self.fp) self.assertNotIn('resume_failures', self.state) self.assertEqual(self.state['stage'], 'voice_transcribing') self.bot._log_queue_event.assert_not_called() def test_delay_is_consumed_before_any_generation_then_retries_after_deadline(self): self.bot._wait_for_database_voice(self.fp) self.assertIsNone(self.bot._generate_ai_reply(self.fp)) self.bot._generate_ai_reply_impl.assert_not_called() self.bot._consume_database_update.assert_not_called() self.state['ready_at'] = time.time() - 1 self.assertEqual(self.bot._generate_ai_reply(self.fp), 'verified fresh answer') self.bot._generate_ai_reply_impl.assert_called_once() def test_voice_wait_does_not_prevent_resuming_another_queued_customer(self): self.bot._wait_for_database_voice(self.fp) other_fp = b'o' * 40 self.bot._pending_reply_sessions[other_fp.hex()] = {'stage': 'queued', 'created_at': 1} self.bot._chat_identity_signature = mock.Mock(return_value=b'') self.bot._session_is_unrepliable = mock.Mock(return_value=False) self.bot._send_gate_open = mock.Mock(return_value=True) self.bot._find_pending_session = mock.Mock(return_value=None) self.bot._expire_unreachable_pending = mock.Mock() self.assertFalse(self.bot._resume_orphaned_pending_reply()) self.bot._find_pending_session.assert_called_once_with(other_fp) self.assertEqual(self.state['stage'], 'voice_transcribing') self.assertNotIn('resume_failures', self.state) def test_error_and_late_success_never_bypass_manual_task(self): self.voice.update(voice_status='error', voice_error_code='low_confidence') self.assertTrue(self.bot._wait_for_database_voice(self.fp)) self.assertTrue(self.state['manual_required']) self.assertEqual(self.state['stage'], 'error') self.voice.update(voice_status='ready', voice_transcribed=True, voice_binding='100\0M:200\0'+'123', content='[语音转文字] late text') self.assertIsNone(self.bot._generate_ai_reply(self.fp)) self.bot._generate_ai_reply_impl.assert_not_called() self.assertFalse(self.bot._hold_for_unfinished_active_session()) self.bot._note_resume_failure(self.state) self.bot._log_generation_gap(self.fp) self.assertEqual(self.state['stage'], 'error') self.assertNotIn('resume_failures', self.state) self.bot._log_queue_event.assert_not_called() def test_manual_task_never_sends_cached_draft(self): self.state.update(manual_required=True, stage='error', reply_text='old answer') self.bot._send_reply_inner = mock.Mock(side_effect=AssertionError('must not send')) self.assertFalse(self.bot.send_reply('old answer', expected_fp=self.fp)) self.assertFalse(self.bot.send_reply('old answer')) # Active chat fallback is guarded too. self.bot._send_reply_inner.assert_not_called() self.assertIn('人工', self.bot.last_send_failure_reason()) def test_explicit_retry_or_completed_task_can_run_normally(self): self.state.update(manual_required=True, stage='error') self.assertIsNone(self.bot._generate_ai_reply(self.fp)) self.state.update(manual_required=False, stage='queued') self.assertEqual(self.bot._generate_ai_reply(self.fp), 'verified fresh answer') self.assertFalse(self.state.get('review_approved')) def test_missing_db_snapshot_preserves_voice_identity_and_deadline(self): self.bot._wait_for_database_voice(self.fp) original_key = self.state['voice_wait_key'] original_start = self.state['voice_wait_started_at'] self.state.update(ready_at=0, chat_text='[语音待转文字]') self.bot._last_database_read = None model = mock.Mock(return_value='must not reach model') def generation(*args, **kwargs): return None if self.bot._wait_for_database_voice(self.fp) else model() self.bot._generate_ai_reply_impl.side_effect = generation self.assertIsNone(self.bot._generate_ai_reply(self.fp)) model.assert_not_called() self.assertEqual(self.state['voice_wait_key'], original_key) self.assertEqual(self.state['voice_wait_started_at'], original_start) self.assertEqual(self.state['stage'], 'voice_transcribing') self.state.update(ready_at=0, voice_wait_started_at=time.time()-181) self.assertIsNone(self.bot._generate_ai_reply(self.fp)) self.assertTrue(self.state['manual_required']) self.assertEqual(self.state['stage'], 'error') model.assert_not_called() def test_only_exact_refresh_can_clear_wait_when_voice_no_longer_pending(self): self.bot._wait_for_database_voice(self.fp) self.bot._last_database_read = {'fp_hex': (b'x'*40).hex(), 'messages': []} self.assertTrue(self.bot._wait_for_database_voice(self.fp)) self.assertIn('voice_wait_key', self.state) self.bot._last_database_read = {'fp_hex': self.key, 'messages': []} self.assertFalse(self.bot._wait_for_database_voice(self.fp)) self.assertNotIn('voice_wait_key', self.state) def test_manual_and_voice_wait_identity_survive_real_queue_roundtrip(self): with tempfile.TemporaryDirectory() as temp: self.bot._pending_reply_path = str(Path(temp)/'pending.json') del self.bot._persist_pending_replies self.state.update(manual_required=True, manual_reason='voice needs checking', voice_wait_key='exact-voice', voice_wait_started_at=time.time()-181, ready_at=time.time()+2, stage='error') self.assertTrue(self.bot._persist_pending_replies()) restored = self.bot._load_pending_replies()[self.key] for field in ('manual_required','manual_reason','voice_wait_key','voice_wait_started_at','ready_at'): self.assertEqual(restored[field], self.state[field]) self.assertTrue(self.bot._assistant_manual_task(restored)) restored.update(manual_required=False, stage='voice_transcribing') self.assertTrue(self.bot._voice_task_waiting(restored)) def test_legal_explicit_retry_clears_wait_and_manual_fields_and_rolls_back_failure(self): fields = dict(manual_required=True, manual_reason='needs checking', voice_wait_key='voice', voice_wait_started_at=time.time()-180, ready_at=time.time()+2) self.bot._queue_log_instance = mock.Mock() self.bot._persist_pending_replies_unlocked = mock.Mock(return_value=False) self.state.update(fields, stage='retry_wait') result = self.bot.retry_pending_replies([self.key]) self.assertFalse(result['retried']) restored = self.bot._pending_reply_sessions[self.key] for key,value in fields.items(): self.assertEqual(restored[key],value) self.bot._persist_pending_replies_unlocked.return_value = True result = self.bot.retry_pending_replies([self.key]) self.assertEqual(result['retried'],[self.key]) for field in fields: self.assertNotIn(field, self.bot._pending_reply_sessions[self.key]) self.assertEqual(self.bot._pending_reply_sessions[self.key]['stage'],'queued') def test_foreign_composer_keeps_existing_clear_and_retry_path(self): for fields in ({'foreign_draft_text': 'human typing'}, {'stage': 'manual_takeover'}): self.state.clear() self.state.update(fields) self.assertFalse(self.bot._assistant_manual_task(self.state)) self.assertEqual(self.bot._generate_ai_reply(self.fp), 'verified fresh answer') self.assertEqual(self.bot._generate_ai_reply_impl.call_count, 2) if __name__ == '__main__': unittest.main()