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