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

284 lines
14 KiB
Python

"""Synthetic database/media tests. No customer data, network or audio/model calls."""
import sqlite3
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from voice_messages import VoiceMessageReader, cached_voice_fields, verified_transcript, voice_batch_status
from wxwork_db import WXWorkDB
UUID = '12345678-1234-5678-1234-123456789abc'
class VoiceDatabaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
data = self.root / '100' / 'Data'
data.mkdir(parents=True)
self.conn = sqlite3.connect(data / 'message.db')
self.addCleanup(self.conn.close)
self.conn.execute('CREATE TABLE message_table(sender_id TEXT, conversation_id TEXT,content_type INT,send_time REAL,content TEXT,server_id TEXT)')
self.conn.execute('CREATE TABLE msg_voice2text(message_id INTEGER PRIMARY KEY,voice_id TEXT,text TEXT)')
self.conn.execute("INSERT INTO message_table VALUES('200','M:200',16,100,?,'123')", ('binary payload ' + UUID,))
self.conn.commit()
def reader(self):
db = WXWorkDB(str(self.root), {}, str(self.root / 'cache'))
self.addCleanup(db.close)
return db
def parsed(self, db):
return db.get_new_messages(0)[0]
def test_reads_server_id_cache_as_real_text_preserving_voice_type(self):
self.conn.execute("INSERT INTO msg_voice2text VALUES(123,?,'合成测试:明天上午联系')", (UUID,))
self.conn.commit()
m = self.parsed(self.reader())
self.assertEqual(m['content'], '[语音转文字] 合成测试:明天上午联系')
self.assertEqual(m['content_type'], 16)
self.assertTrue(verified_transcript(m))
def test_row_id_is_not_server_id_and_binary_payload_never_reaches_model(self):
self.conn.execute("INSERT INTO msg_voice2text VALUES(1,?,'wrong text')", (UUID,))
self.conn.commit()
m = self.parsed(self.reader())
self.assertEqual(m['content'], '[语音待转文字]')
self.assertFalse(m['voice_transcribed'])
self.assertNotIn('binary', m['content'])
def test_missing_table_and_empty_text_are_not_transcriptions(self):
self.conn.execute('DROP TABLE msg_voice2text')
self.conn.commit()
self.assertFalse(self.parsed(self.reader())['voice_transcribed'])
def test_cache_collision_across_conversations_is_rejected(self):
self.conn.execute("INSERT INTO message_table VALUES('201','M:201',16,100,'voice','123')")
self.conn.execute("INSERT INTO msg_voice2text VALUES(123,?,'wrong conversation possible')", (UUID,))
self.conn.commit()
fields = cached_voice_fields(self.conn, '100', 'M:200', '123', UUID)
self.assertFalse(fields['voice_transcribed'])
def test_same_server_id_in_other_account_is_not_used(self):
foreign = sqlite3.connect(':memory:')
self.addCleanup(foreign.close)
foreign.execute('CREATE TABLE msg_voice2text(message_id INTEGER PRIMARY KEY,voice_id TEXT,text TEXT)')
foreign.execute("INSERT INTO msg_voice2text VALUES(123,'','other account text')")
self.assertFalse(cached_voice_fields(self.conn, '100', 'M:200', '123', UUID)['voice_transcribed'])
def voice(self, **changes):
msg = {'account': '100', 'conv_id': 'M:200', 'server_id': '123', 'dedup_key': '100:M:200:server:123',
'is_self': False, 'content_type': 16, 'content': '[语音待转文字]',
'voice_refs': [UUID], 'voice_status': 'missing'}
msg.update(changes)
return msg
def setup_audio(self):
root = self.root / '100' / 'Cache' / 'Voice'
root.mkdir(parents=True)
audio = root / (UUID + '.silk')
audio.write_bytes(b'synthetic only; recognizer mocked')
return root, audio
def test_exact_current_account_path_enqueues_then_returns_transcript(self):
_, audio = self.setup_audio()
service = mock.Mock()
service.request.side_effect = [{'status': 'pending'}, {'status': 'ready', 'text': '离线合成识别结果'}]
factory = mock.Mock(return_value=service)
reader = VoiceMessageReader(self.root, factory, background_index=False)
self.addCleanup(reader.close)
m = self.voice()
reader.prepare([m])
self.assertEqual(m['voice_status'], 'pending')
service.request.assert_called_once_with('100', 'M:200', '123', str(audio))
reader.prepare([m])
self.assertTrue(verified_transcript(m))
self.assertEqual(m['content'], '[语音转文字] 离线合成识别结果')
def test_ambiguous_files_and_other_account_do_not_enqueue(self):
root, _ = self.setup_audio()
(root / (UUID + '.amr')).write_bytes(b'synthetic')
factory = mock.Mock()
reader = VoiceMessageReader(self.root, factory, background_index=False)
reader.prepare([self.voice()])
reader.prepare([self.voice(account='101')])
factory.assert_not_called()
def test_cache_index_is_background_and_does_not_hold_database_reader(self):
import threading
self.setup_audio()
entered, release = threading.Event(), threading.Event()
service = mock.Mock()
service.request.return_value = {'status': 'ready', 'text': '后台索引完成'}
reader = VoiceMessageReader(self.root, lambda **kw: service)
self.addCleanup(reader.close)
original = reader._build_index
def slow(root):
entered.set()
release.wait(2)
return original(root)
reader._build_index = slow
m = self.voice()
start = time.monotonic()
reader.prepare([m])
self.assertLess(time.monotonic() - start, .15)
self.assertTrue(entered.wait(1))
service.request.assert_not_called()
release.set()
deadline = time.monotonic() + 2
while time.monotonic() < deadline and not verified_transcript(m):
reader.prepare([m])
time.sleep(.01)
self.assertTrue(verified_transcript(m))
def test_refresh_does_not_regress_ready_voice_during_slow_model_call(self):
import threading
self.setup_audio()
service = mock.Mock()
service.request.return_value = {'status': 'ready', 'text': '先前已核验转写'}
reader = VoiceMessageReader(self.root, lambda **kw: service, background_index=False)
self.addCleanup(reader.close)
reader.prepare([self.voice()])
reader._background_index = True
cached = reader.indexes['100'][1]
reader.indexes['100'] = (time.monotonic()-11, cached)
entered, release = threading.Event(), threading.Event()
original = reader._build_index
def slow(root):
entered.set()
release.wait(2)
return original(root)
reader._build_index = slow
try:
for _ in range(3):
m = self.voice()
reader.prepare([m])
self.assertTrue(verified_transcript(m))
self.assertTrue(entered.wait(1))
service.request.return_value = {'status': 'error', 'error_code': 'audio_changed'}
m = self.voice()
reader.prepare([m])
self.assertEqual(m['voice_status'], 'pending')
self.assertFalse(verified_transcript(m))
finally:
release.set()
def test_old_history_and_voice_before_human_reply_are_not_enqueued(self):
self.setup_audio()
factory = mock.Mock()
VoiceMessageReader(self.root, factory, background_index=False).prepare([self.voice(), {'is_self': True, 'content': 'answered'}])
factory.assert_not_called()
def test_empty_asr_result_is_error_not_answer(self):
self.setup_audio()
service = mock.Mock()
service.request.return_value = {'status': 'ready', 'text': ' '}
m = self.voice()
VoiceMessageReader(self.root, lambda **kw: service, background_index=False).prepare([m])
self.assertEqual(m['voice_status'], 'error')
self.assertFalse(verified_transcript(m))
def test_mixed_batch_waits_for_all_voices_and_rejects_foreign_binding(self):
m = self.voice()
context = {'account': '100', 'conv_id': 'M:200', 'messages': [m, {'content_type': 2, 'content': '补充文字'}]}
self.assertEqual(voice_batch_status(context)['status'], 'pending')
m.update(voice_status='ready', voice_transcribed=True, content='[语音转文字] 测试', voice_binding='101\0M:200\0123')
self.assertEqual(voice_batch_status(context)['status'], 'pending')
m['voice_binding'] = '100\0M:200\0' + '123'
self.assertEqual(voice_batch_status(context)['status'], 'ready')
def test_cache_update_on_same_message_becomes_visible_without_new_incoming(self):
db = self.reader()
m = self.parsed(db)
self.assertFalse(m['voice_transcribed'])
self.conn.execute("INSERT INTO msg_voice2text VALUES(123,?,'稍后到达的转写')", (UUID,))
self.conn.commit()
db._last_refresh = 0
db._last_check = 0
db._last_refresh_check = 0
# Poll interval is a real cache contract; advance only its clock in this isolated fixture.
with mock.patch('wxwork_db.time.monotonic', return_value=time.monotonic() + 10):
db._refresh_if_needed()
# Exact ID query in production; unique-name query in visual source.
name = db._conv_display_name('100', 'M:200')
context = db.get_conversation_context(name)
self.assertTrue(verified_transcript(context['last_message']))
class VisualVoiceTests(unittest.TestCase):
def setUp(self):
from wechat_bot import WeChatBot
self.bot = WeChatBot.__new__(WeChatBot)
self.fp = b'v' * 40
self.state = {}
self.bot._pending_reply_state = mock.Mock(side_effect=lambda fp: self.state if fp == self.fp else None)
self.bot._set_task_stage = mock.Mock()
self.bot._persist_pending_replies = mock.Mock(return_value=True)
self.bot._register_assistant_review = mock.Mock()
self.voice = {'account': '100', 'conv_id': 'M:200', 'server_id': '123', 'dedup_key': 'key',
'content_type': 16, 'voice_status': 'pending', 'content': '[语音待转文字]'}
self.bot._last_database_read = {'fp_hex': self.fp.hex(), 'account': '100', 'conv_id': 'M:200',
'messages': [self.voice], 'text': 'synthetic voice'}
def test_waits_without_manual_state_then_allows_transcribed_text(self):
self.assertTrue(self.bot._wait_for_database_voice(self.fp))
self.assertEqual(self.bot._set_task_stage.call_args.args[1], 'voice_transcribing')
self.assertFalse(self.state.get('manual_required'))
self.voice.update(voice_status='ready', voice_transcribed=True, content='[语音转文字] 合成', voice_binding='100\0M:200\0'+'123')
self.assertFalse(self.bot._wait_for_database_voice(self.fp))
def test_timeout_notifies_manual_instead_of_sending_guess(self):
self.bot._wait_for_database_voice(self.fp)
self.state['voice_wait_started_at'] -= 181
self.assertTrue(self.bot._wait_for_database_voice(self.fp))
self.assertTrue(self.state['manual_required'])
self.bot._register_assistant_review.assert_called_once_with(self.fp)
self.assertEqual(self.state['reply_text'], '')
def test_fails_asr_to_manual_and_ignores_other_conversation_snapshot(self):
self.voice['voice_status'] = 'error'
self.voice['voice_error_code'] = 'empty_audio'
self.assertTrue(self.bot._wait_for_database_voice(self.fp))
self.assertIn('empty_audio', self.state['manual_reason'])
self.assertFalse(self.bot._wait_for_database_voice(b'x'*40))
class VisualDatabaseBindingTests(unittest.TestCase):
def setUp(self):
from wechat_bot import WeChatBot
self.bot = WeChatBot.__new__(WeChatBot)
self.fp = b'v' * 40
self.state = {'account': '100', 'conv_id': 'M:200', 'display_name': '测试客户'}
self.context = {'account': '100', 'conv_id': 'M:200', 'text': '合成文本', 'messages': [], 'last_message': {}}
self.bot._pending_reply_state = mock.Mock(return_value=self.state)
self.bot._open_chat_display_name = mock.Mock(return_value='测试客户')
self.bot._db_source = mock.Mock()
self.bot._db_source.get_conversation_context.return_value = self.context
self.bot.report_operation = mock.Mock()
def test_legacy_task_uses_bound_account_and_conversation_without_database_event(self):
self.assertEqual(self.bot._database_chat_text(self.fp), '合成文本')
args = self.bot._db_source.get_conversation_context.call_args.kwargs
self.assertEqual((args['account'], args['conv_id']), ('100', 'M:200'))
def test_returned_foreign_account_is_rejected_even_if_reader_ignored_filter(self):
self.context['account'] = '101'
self.assertEqual(self.bot._database_chat_text(self.fp), '')
self.assertIsNone(self.bot._last_database_read)
def test_conflicting_event_or_live_identity_never_queries_another_account(self):
self.state['database_event'] = {'account': '101', 'conv_id': 'M:200'}
self.assertEqual(self.bot._database_chat_text(self.fp), '')
self.bot._db_source.get_conversation_context.assert_not_called()
self.state.pop('database_event')
self.bot._review_assistant_identity = ('101', 42)
self.assertEqual(self.bot._database_chat_text(self.fp), '')
self.bot._db_source.get_conversation_context.assert_not_called()
if __name__ == '__main__':
unittest.main()