284 lines
14 KiB
Python
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()
|