Files
kefu/wechat_rpa/test_voice_visual_scheduling.py
T
2026-09-21 10:34:06 +08:00

175 lines
9.8 KiB
Python

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