CI: expand test suite coverage

these new test cover issues such as thread safety, VAD thresholding,
message routing, error handling etc. that weren't covered by existing
tests. Mocking is used to avoid dependencies on GPU, ONNX etc.
This commit is contained in:
Aaron Boxer
2026-04-17 09:05:34 -04:00
parent e41324bf03
commit b1cd51ac8a
6 changed files with 1131 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
+387
View File
@@ -0,0 +1,387 @@
import json
import queue
import threading
import time
import unittest
from unittest.mock import MagicMock, patch
import numpy as np
from whisper_live.backend.base import ServeClientBase
class ConcreteServeClient(ServeClientBase):
"""Concrete subclass for testing the abstract base class."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.language = "en"
def transcribe_audio(self, input_sample):
return None
def handle_transcription_output(self, result, duration):
pass
class TestServeClientBaseInit(unittest.TestCase):
def test_default_values(self):
ws = MagicMock()
client = ConcreteServeClient(client_uid="test-uid", websocket=ws)
self.assertEqual(client.client_uid, "test-uid")
self.assertEqual(client.send_last_n_segments, 10)
self.assertAlmostEqual(client.no_speech_thresh, 0.45)
self.assertFalse(client.clip_audio)
self.assertEqual(client.same_output_threshold, 10)
self.assertIsNone(client.frames_np)
self.assertAlmostEqual(client.timestamp_offset, 0.0)
self.assertFalse(client.exit)
self.assertEqual(client.transcript, [])
def test_custom_values(self):
ws = MagicMock()
q = queue.Queue()
client = ConcreteServeClient(
client_uid="uid2",
websocket=ws,
send_last_n_segments=5,
no_speech_thresh=0.6,
clip_audio=True,
same_output_threshold=20,
translation_queue=q,
)
self.assertEqual(client.send_last_n_segments, 5)
self.assertAlmostEqual(client.no_speech_thresh, 0.6)
self.assertTrue(client.clip_audio)
self.assertEqual(client.same_output_threshold, 20)
self.assertIs(client.translation_queue, q)
class TestAddFrames(unittest.TestCase):
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(client_uid="test", websocket=self.ws)
def test_first_frame_initializes_buffer(self):
frame = np.array([0.1, 0.2, 0.3], dtype=np.float32)
self.client.add_frames(frame)
np.testing.assert_array_equal(self.client.frames_np, frame)
def test_subsequent_frames_concatenated(self):
frame1 = np.array([0.1, 0.2], dtype=np.float32)
frame2 = np.array([0.3, 0.4], dtype=np.float32)
self.client.add_frames(frame1)
self.client.add_frames(frame2)
expected = np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32)
np.testing.assert_array_equal(self.client.frames_np, expected)
def test_buffer_trimmed_at_45_seconds(self):
# 45 seconds + 1 sample at 16kHz = 720001 samples
self.client.frames_np = np.zeros(45 * 16000 + 1, dtype=np.float32)
self.client.add_frames(np.array([1.0], dtype=np.float32))
# after trimming 30s, buffer should be ~15s + 1 original + 1 new
expected_len = (45 * 16000 + 1) - (30 * 16000) + 1
self.assertEqual(self.client.frames_np.shape[0], expected_len)
self.assertAlmostEqual(self.client.frames_offset, 30.0)
def test_timestamp_offset_updated_on_trim(self):
self.client.frames_np = np.zeros(45 * 16000 + 1, dtype=np.float32)
self.client.timestamp_offset = 5.0 # behind frames_offset after trim
self.client.add_frames(np.array([1.0], dtype=np.float32))
# timestamp_offset should be bumped to at least frames_offset
self.assertGreaterEqual(self.client.timestamp_offset, self.client.frames_offset)
class TestAddFramesThreadSafety(unittest.TestCase):
def test_concurrent_add_frames(self):
ws = MagicMock()
client = ConcreteServeClient(client_uid="test", websocket=ws)
errors = []
def add_many():
try:
for _ in range(100):
client.add_frames(np.random.randn(160).astype(np.float32))
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=add_many) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(errors, [])
self.assertIsNotNone(client.frames_np)
class TestGetAudioChunkForProcessing(unittest.TestCase):
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(client_uid="test", websocket=self.ws)
def test_empty_buffer_returns_empty(self):
self.client.frames_np = np.array([], dtype=np.float32)
chunk, duration = self.client.get_audio_chunk_for_processing()
self.assertEqual(duration, 0.0)
self.assertEqual(chunk.shape[0], 0)
def test_full_buffer_no_offset(self):
audio = np.random.randn(16000).astype(np.float32) # 1 second
self.client.frames_np = audio
chunk, duration = self.client.get_audio_chunk_for_processing()
self.assertAlmostEqual(duration, 1.0)
np.testing.assert_array_equal(chunk, audio)
def test_with_offset(self):
audio = np.random.randn(32000).astype(np.float32) # 2 seconds
self.client.frames_np = audio
self.client.timestamp_offset = 1.0 # skip first second
chunk, duration = self.client.get_audio_chunk_for_processing()
self.assertAlmostEqual(duration, 1.0)
self.assertEqual(chunk.shape[0], 16000)
class TestClipAudioIfNoValidSegment(unittest.TestCase):
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(
client_uid="test", websocket=self.ws, clip_audio=True
)
def test_clips_when_chunk_exceeds_25s(self):
# 30 seconds of audio with no valid segments
self.client.frames_np = np.zeros(30 * 16000, dtype=np.float32)
self.client.timestamp_offset = 0.0
self.client.frames_offset = 0.0
self.client.clip_audio_if_no_valid_segment()
# offset should have advanced to leave ~5s of remaining audio
expected_offset = (30 * 16000 / 16000) - 5
self.assertAlmostEqual(self.client.timestamp_offset, expected_offset, places=1)
def test_no_clip_when_short(self):
self.client.frames_np = np.zeros(10 * 16000, dtype=np.float32)
self.client.timestamp_offset = 0.0
self.client.frames_offset = 0.0
self.client.clip_audio_if_no_valid_segment()
self.assertAlmostEqual(self.client.timestamp_offset, 0.0)
class TestPrepareSegments(unittest.TestCase):
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(
client_uid="test", websocket=self.ws, send_last_n_segments=3
)
def test_empty_transcript_no_last(self):
segments = self.client.prepare_segments()
self.assertEqual(segments, [])
def test_empty_transcript_with_last(self):
last = {"start": "0.000", "end": "1.000", "text": "hello", "completed": False}
segments = self.client.prepare_segments(last_segment=last)
self.assertEqual(len(segments), 1)
self.assertEqual(segments[0]["text"], "hello")
def test_fewer_than_n_segments(self):
self.client.transcript = [
{"start": "0.000", "end": "1.000", "text": "a", "completed": True},
{"start": "1.000", "end": "2.000", "text": "b", "completed": True},
]
segments = self.client.prepare_segments()
self.assertEqual(len(segments), 2)
def test_more_than_n_segments_truncated(self):
self.client.transcript = [
{"start": f"{i}.000", "end": f"{i+1}.000", "text": f"seg{i}", "completed": True}
for i in range(10)
]
segments = self.client.prepare_segments()
self.assertEqual(len(segments), 3)
self.assertEqual(segments[0]["text"], "seg7")
def test_last_segment_appended(self):
self.client.transcript = [
{"start": "0.000", "end": "1.000", "text": "a", "completed": True},
]
last = {"start": "1.000", "end": "2.000", "text": "in progress", "completed": False}
segments = self.client.prepare_segments(last_segment=last)
self.assertEqual(len(segments), 2)
self.assertEqual(segments[-1]["text"], "in progress")
class TestFormatSegment(unittest.TestCase):
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(client_uid="test", websocket=self.ws)
def test_format(self):
seg = self.client.format_segment(1.234, 5.678, "hello world", completed=True)
self.assertEqual(seg["start"], "1.234")
self.assertEqual(seg["end"], "5.678")
self.assertEqual(seg["text"], "hello world")
self.assertTrue(seg["completed"])
def test_format_not_completed(self):
seg = self.client.format_segment(0.0, 1.0, "text")
self.assertFalse(seg["completed"])
class TestSendTranscriptionToClient(unittest.TestCase):
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(client_uid="test-uid", websocket=self.ws)
def test_sends_json(self):
segments = [{"start": "0.000", "end": "1.000", "text": "hi", "completed": True}]
self.client.send_transcription_to_client(segments)
self.ws.send.assert_called_once()
sent = json.loads(self.ws.send.call_args[0][0])
self.assertEqual(sent["uid"], "test-uid")
self.assertEqual(len(sent["segments"]), 1)
def test_send_failure_logged_not_raised(self):
self.ws.send.side_effect = ConnectionError("broken pipe")
# should not raise
self.client.send_transcription_to_client([])
class TestDisconnect(unittest.TestCase):
def test_sends_disconnect_message(self):
ws = MagicMock()
client = ConcreteServeClient(client_uid="uid1", websocket=ws)
client.disconnect()
sent = json.loads(ws.send.call_args[0][0])
self.assertEqual(sent["uid"], "uid1")
self.assertEqual(sent["message"], "DISCONNECT")
class TestCleanup(unittest.TestCase):
def test_sets_exit_flag(self):
ws = MagicMock()
client = ConcreteServeClient(client_uid="uid1", websocket=ws)
self.assertFalse(client.exit)
client.cleanup()
self.assertTrue(client.exit)
class TestUpdateSegments(unittest.TestCase):
"""Tests for the core update_segments() logic."""
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(
client_uid="test",
websocket=self.ws,
no_speech_thresh=0.45,
same_output_threshold=3,
)
self.client.frames_np = np.zeros(16000 * 5, dtype=np.float32)
def _make_segment(self, start, end, text, no_speech_prob=0.0):
seg = MagicMock()
seg.start = start
seg.end = end
seg.text = text
seg.no_speech_prob = no_speech_prob
return seg
def test_single_segment_becomes_last(self):
segs = [self._make_segment(0.0, 1.0, " hello")]
last = self.client.update_segments(segs, duration=2.0)
self.assertIsNotNone(last)
self.assertIn("hello", last["text"])
self.assertFalse(last["completed"])
self.assertEqual(len(self.client.transcript), 0)
def test_multiple_segments_completes_all_but_last(self):
segs = [
self._make_segment(0.0, 1.0, " first"),
self._make_segment(1.0, 2.0, " second"),
]
last = self.client.update_segments(segs, duration=3.0)
self.assertEqual(len(self.client.transcript), 1)
self.assertTrue(self.client.transcript[0]["completed"])
self.assertIn("first", self.client.transcript[0]["text"])
self.assertIsNotNone(last)
self.assertIn("second", last["text"])
def test_high_no_speech_prob_skipped(self):
segs = [
self._make_segment(0.0, 1.0, " noise", no_speech_prob=0.9),
self._make_segment(1.0, 2.0, " also noise", no_speech_prob=0.9),
]
last = self.client.update_segments(segs, duration=3.0)
self.assertEqual(len(self.client.transcript), 0)
self.assertIsNone(last)
def test_segment_with_start_gte_end_skipped(self):
segs = [
self._make_segment(1.0, 0.5, " backwards"),
self._make_segment(1.5, 2.0, " normal"),
]
last = self.client.update_segments(segs, duration=3.0)
self.assertEqual(len(self.client.transcript), 0)
self.assertIsNotNone(last)
def test_repeated_output_triggers_completion(self):
seg = self._make_segment(0.0, 1.0, " repeated")
for _ in range(self.client.same_output_threshold + 2):
last = self.client.update_segments([seg], duration=2.0)
# after enough repeats, should be added to transcript
self.assertTrue(len(self.client.transcript) >= 1)
def test_translation_queue_receives_completed(self):
q = queue.Queue()
self.client.translation_queue = q
segs = [
self._make_segment(0.0, 1.0, " first"),
self._make_segment(1.0, 2.0, " second"),
]
self.client.update_segments(segs, duration=3.0)
self.assertFalse(q.empty())
item = q.get_nowait()
self.assertIn("first", item["text"])
def test_timestamp_offset_advances(self):
segs = [
self._make_segment(0.0, 1.0, " first"),
self._make_segment(1.0, 2.0, " second"),
]
self.client.update_segments(segs, duration=3.0)
self.assertGreater(self.client.timestamp_offset, 0.0)
class TestGetSegmentHelpers(unittest.TestCase):
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(client_uid="test", websocket=self.ws)
def test_get_segment_no_speech_prob_attr(self):
seg = MagicMock()
seg.no_speech_prob = 0.3
self.assertAlmostEqual(self.client.get_segment_no_speech_prob(seg), 0.3)
def test_get_segment_no_speech_prob_fallback(self):
seg = MagicMock(spec=[]) # no attributes
self.assertEqual(self.client.get_segment_no_speech_prob(seg), 0)
def test_get_segment_start_uses_start(self):
seg = MagicMock()
seg.start = 1.5
self.assertAlmostEqual(self.client.get_segment_start(seg), 1.5)
def test_get_segment_end_uses_end(self):
seg = MagicMock()
seg.end = 3.0
self.assertAlmostEqual(self.client.get_segment_end(seg), 3.0)
def test_get_segment_start_fallback_to_start_ts(self):
seg = MagicMock(spec=["start_ts"])
seg.start_ts = 2.0
self.assertAlmostEqual(self.client.get_segment_start(seg), 2.0)
if __name__ == "__main__":
unittest.main()
+257
View File
@@ -0,0 +1,257 @@
import json
import time
import unittest
from unittest.mock import patch, MagicMock, PropertyMock
from whisper_live.client import Client, TranscriptionTeeClient
class TestClientStatusMessages(unittest.TestCase):
"""Tests for Client.handle_status_messages() and on_message() branches."""
@patch("whisper_live.client.websocket.WebSocketApp")
@patch("whisper_live.client.pyaudio.PyAudio")
def setUp(self, mock_pyaudio, mock_websocket):
mock_pyaudio.return_value.open.return_value = MagicMock()
self.client = Client(host="localhost", port=9090, lang="en")
def tearDown(self):
self.client.close_websocket()
def test_wait_status(self):
msg = {"uid": self.client.uid, "status": "WAIT", "message": 5.0}
self.client.handle_status_messages(msg)
self.assertTrue(self.client.waiting)
def test_error_status(self):
msg = {"uid": self.client.uid, "status": "ERROR", "message": "model not found"}
self.client.handle_status_messages(msg)
self.assertTrue(self.client.server_error)
def test_warning_status_no_side_effects(self):
msg = {"uid": self.client.uid, "status": "WARNING", "message": "fallback backend"}
self.client.handle_status_messages(msg)
self.assertFalse(self.client.server_error)
self.assertFalse(self.client.waiting)
def test_on_message_wrong_uid_ignored(self):
msg = json.dumps({"uid": "wrong-uid", "segments": [{"start": 0, "end": 1, "text": "hi", "completed": True}]})
self.client.on_message(MagicMock(), msg)
self.assertEqual(len(self.client.transcript), 0)
def test_on_message_disconnect(self):
self.client.recording = True
msg = json.dumps({"uid": self.client.uid, "message": "DISCONNECT"})
self.client.on_message(MagicMock(), msg)
self.assertFalse(self.client.recording)
def test_on_message_server_ready(self):
msg = json.dumps({
"uid": self.client.uid,
"message": "SERVER_READY",
"backend": "faster_whisper",
})
self.client.on_message(MagicMock(), msg)
self.assertTrue(self.client.recording)
self.assertEqual(self.client.server_backend, "faster_whisper")
def test_on_message_language_detection(self):
msg = json.dumps({
"uid": self.client.uid,
"language": "fr",
"language_prob": 0.95,
})
self.client.on_message(MagicMock(), msg)
self.assertEqual(self.client.language, "fr")
class TestClientTranslationFlow(unittest.TestCase):
"""Tests for the translation-related client functionality."""
@patch("whisper_live.client.websocket.WebSocketApp")
@patch("whisper_live.client.pyaudio.PyAudio")
def setUp(self, mock_pyaudio, mock_websocket):
mock_pyaudio.return_value.open.return_value = MagicMock()
self.client = Client(
host="localhost",
port=9090,
lang="en",
enable_translation=True,
target_language="es",
)
# simulate SERVER_READY so server_backend is set
ready_msg = json.dumps({
"uid": self.client.uid,
"message": "SERVER_READY",
"backend": "faster_whisper",
})
self.client.on_message(MagicMock(), ready_msg)
def tearDown(self):
self.client.close_websocket()
def test_on_open_includes_translation_fields(self):
mock_ws = MagicMock()
self.client.on_open(mock_ws)
sent = json.loads(mock_ws.send.call_args[0][0])
self.assertTrue(sent["enable_translation"])
self.assertEqual(sent["target_language"], "es")
def test_translated_segments_processed(self):
msg = json.dumps({
"uid": self.client.uid,
"translated_segments": [
{"start": "0.000", "end": "1.000", "text": "Hola mundo", "completed": True},
],
})
self.client.on_message(MagicMock(), msg)
self.assertEqual(len(self.client.translated_transcript), 1)
self.assertEqual(self.client.translated_transcript[0]["text"], "Hola mundo")
def test_translation_callback_invoked(self):
callback = MagicMock()
self.client.translation_callback = callback
msg = json.dumps({
"uid": self.client.uid,
"translated_segments": [
{"start": "0.000", "end": "1.000", "text": "Hola", "completed": True},
],
})
self.client.on_message(MagicMock(), msg)
callback.assert_called_once()
def test_translation_callback_exception_handled(self):
callback = MagicMock(side_effect=RuntimeError("callback broke"))
self.client.translation_callback = callback
msg = json.dumps({
"uid": self.client.uid,
"translated_segments": [
{"start": "0.000", "end": "1.000", "text": "Hola", "completed": True},
],
})
# should not raise
self.client.on_message(MagicMock(), msg)
class TestClientTranscriptionCallback(unittest.TestCase):
"""Tests for the transcription callback feature."""
@patch("whisper_live.client.websocket.WebSocketApp")
@patch("whisper_live.client.pyaudio.PyAudio")
def setUp(self, mock_pyaudio, mock_websocket):
mock_pyaudio.return_value.open.return_value = MagicMock()
self.callback = MagicMock()
self.client = Client(
host="localhost",
port=9090,
lang="en",
transcription_callback=self.callback,
)
ready_msg = json.dumps({
"uid": self.client.uid,
"message": "SERVER_READY",
"backend": "faster_whisper",
})
self.client.on_message(MagicMock(), ready_msg)
def tearDown(self):
self.client.close_websocket()
def test_callback_receives_text_and_segments(self):
msg = json.dumps({
"uid": self.client.uid,
"segments": [
{"start": "0.000", "end": "1.000", "text": "Hello", "completed": True},
],
})
self.client.on_message(MagicMock(), msg)
self.callback.assert_called_once()
text_arg, segments_arg = self.callback.call_args[0]
self.assertIn("Hello", text_arg)
self.assertIsInstance(segments_arg, list)
def test_callback_exception_does_not_crash(self):
self.callback.side_effect = ValueError("boom")
msg = json.dumps({
"uid": self.client.uid,
"segments": [
{"start": "0.000", "end": "1.000", "text": "Test", "completed": True},
],
})
# should not raise
self.client.on_message(MagicMock(), msg)
class TestClientSrtWriting(unittest.TestCase):
"""Tests for Client.write_srt_file() edge cases."""
@patch("whisper_live.client.websocket.WebSocketApp")
@patch("whisper_live.client.pyaudio.PyAudio")
def setUp(self, mock_pyaudio, mock_websocket):
mock_pyaudio.return_value.open.return_value = MagicMock()
self.client = Client(host="localhost", port=9090, lang="en")
self.client.server_backend = "faster_whisper"
def tearDown(self):
self.client.close_websocket()
import os
for f in ["test_out.srt"]:
if os.path.exists(f):
os.remove(f)
def test_write_srt_empty_transcript_with_last_segment(self):
self.client.transcript = []
self.client.last_segment = {"start": "0.000", "end": "1.000", "text": "final"}
self.client.write_srt_file("test_out.srt")
self.assertEqual(len(self.client.transcript), 1)
self.assertEqual(self.client.transcript[0]["text"], "final")
def test_write_srt_appends_last_segment_if_different(self):
self.client.transcript = [{"start": "0.000", "end": "1.000", "text": "first"}]
self.client.last_segment = {"start": "1.000", "end": "2.000", "text": "second"}
self.client.write_srt_file("test_out.srt")
self.assertEqual(len(self.client.transcript), 2)
def test_write_srt_no_duplicate_last_segment(self):
self.client.transcript = [{"start": "0.000", "end": "1.000", "text": "same"}]
self.client.last_segment = {"start": "0.000", "end": "1.000", "text": "same"}
self.client.write_srt_file("test_out.srt")
self.assertEqual(len(self.client.transcript), 1)
class TestWaitBeforeDisconnect(unittest.TestCase):
"""Tests for Client.wait_before_disconnect()."""
@patch("whisper_live.client.websocket.WebSocketApp")
@patch("whisper_live.client.pyaudio.PyAudio")
def setUp(self, mock_pyaudio, mock_websocket):
mock_pyaudio.return_value.open.return_value = MagicMock()
self.client = Client(host="localhost", port=9090, lang="en")
def tearDown(self):
self.client.close_websocket()
def test_raises_if_no_response(self):
self.client.last_response_received = None
with self.assertRaises(AssertionError):
self.client.wait_before_disconnect()
def test_returns_immediately_if_timeout_elapsed(self):
self.client.last_response_received = time.time() - 100
self.client.disconnect_if_no_response_for = 15
start = time.time()
self.client.wait_before_disconnect()
elapsed = time.time() - start
self.assertLess(elapsed, 1.0)
class TestTeeClientEdgeCases(unittest.TestCase):
"""Edge cases for TranscriptionTeeClient."""
def test_empty_clients_raises(self):
with self.assertRaises(Exception):
TranscriptionTeeClient([])
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
import json
import time
import unittest
from unittest import mock
from unittest.mock import MagicMock, patch
from whisper_live.server import TranscriptionServer, BackendType, ClientManager
class TestClientManagerAddRemove(unittest.TestCase):
def setUp(self):
self.cm = ClientManager(max_clients=2, max_connection_time=60)
def test_add_and_get_client(self):
ws = MagicMock()
client = MagicMock()
self.cm.add_client(ws, client)
self.assertIs(self.cm.get_client(ws), client)
def test_get_nonexistent_client(self):
ws = MagicMock()
self.assertFalse(self.cm.get_client(ws))
def test_remove_client_calls_cleanup(self):
ws = MagicMock()
client = MagicMock()
self.cm.add_client(ws, client)
self.cm.remove_client(ws)
client.cleanup.assert_called_once()
self.assertNotIn(ws, self.cm.clients)
self.assertNotIn(ws, self.cm.start_times)
def test_remove_nonexistent_client_no_error(self):
ws = MagicMock()
self.cm.remove_client(ws) # should not raise
class TestClientManagerServerFull(unittest.TestCase):
def setUp(self):
self.cm = ClientManager(max_clients=1, max_connection_time=60)
def test_not_full_returns_false(self):
ws = MagicMock()
options = {"uid": "test"}
self.assertFalse(self.cm.is_server_full(ws, options))
def test_full_sends_wait_and_returns_true(self):
ws1 = MagicMock()
self.cm.add_client(ws1, MagicMock())
ws2 = MagicMock()
options = {"uid": "new-client"}
self.assertTrue(self.cm.is_server_full(ws2, options))
ws2.send.assert_called_once()
sent = json.loads(ws2.send.call_args[0][0])
self.assertEqual(sent["status"], "WAIT")
self.assertEqual(sent["uid"], "new-client")
class TestClientManagerTimeout(unittest.TestCase):
def setUp(self):
self.cm = ClientManager(max_clients=4, max_connection_time=10)
def test_not_timed_out(self):
ws = MagicMock()
client = MagicMock()
self.cm.add_client(ws, client)
self.assertFalse(self.cm.is_client_timeout(ws))
def test_timed_out(self):
ws = MagicMock()
client = MagicMock()
self.cm.add_client(ws, client)
self.cm.start_times[ws] = time.time() - 20
self.assertTrue(self.cm.is_client_timeout(ws))
client.disconnect.assert_called_once()
class TestClientManagerGetWaitTime(unittest.TestCase):
def test_no_clients_returns_zero(self):
cm = ClientManager(max_clients=4, max_connection_time=600)
self.assertEqual(cm.get_wait_time(), 0)
def test_single_client_wait_time(self):
cm = ClientManager(max_clients=4, max_connection_time=600)
ws = MagicMock()
cm.add_client(ws, MagicMock())
cm.start_times[ws] = time.time() - 300
wait = cm.get_wait_time()
self.assertAlmostEqual(wait, 5.0, places=0)
def test_multiple_clients_returns_minimum(self):
cm = ClientManager(max_clients=4, max_connection_time=600)
ws1, ws2 = MagicMock(), MagicMock()
cm.add_client(ws1, MagicMock())
cm.add_client(ws2, MagicMock())
cm.start_times[ws1] = time.time() - 100
cm.start_times[ws2] = time.time() - 500
wait = cm.get_wait_time()
# ws2 has 100s remaining = ~1.67 minutes
self.assertAlmostEqual(wait, 100 / 60, places=0)
class TestBackendType(unittest.TestCase):
def test_valid_types(self):
valid = BackendType.valid_types()
self.assertIn("faster_whisper", valid)
self.assertIn("tensorrt", valid)
self.assertIn("openvino", valid)
def test_is_valid(self):
self.assertTrue(BackendType.is_valid("faster_whisper"))
self.assertFalse(BackendType.is_valid("nonexistent"))
def test_type_checks(self):
self.assertTrue(BackendType.FASTER_WHISPER.is_faster_whisper())
self.assertFalse(BackendType.FASTER_WHISPER.is_tensorrt())
self.assertTrue(BackendType.TENSORRT.is_tensorrt())
self.assertTrue(BackendType.OPENVINO.is_openvino())
def test_enum_from_string(self):
bt = BackendType("faster_whisper")
self.assertEqual(bt, BackendType.FASTER_WHISPER)
def test_invalid_enum_raises(self):
with self.assertRaises(ValueError):
BackendType("invalid_backend")
class TestTranscriptionServerInit(unittest.TestCase):
def test_defaults(self):
server = TranscriptionServer()
self.assertIsNone(server.client_manager)
self.assertTrue(server.use_vad)
self.assertFalse(server.single_model)
self.assertIsNone(server.batch_config)
def test_run_invalid_backend_raises(self):
server = TranscriptionServer()
with self.assertRaises(ValueError):
server.run(host="localhost", port=9090, backend="nonexistent")
def test_run_invalid_trt_path_raises(self):
server = TranscriptionServer()
with self.assertRaises(ValueError):
server.run(
host="localhost",
port=9090,
backend="tensorrt",
whisper_tensorrt_path="/nonexistent/path",
)
class TestTranscriptionServerGetAudio(unittest.TestCase):
def setUp(self):
self.server = TranscriptionServer()
def test_end_of_audio_returns_false(self):
ws = MagicMock()
ws.recv.return_value = b"END_OF_AUDIO"
result = self.server.get_audio_from_websocket(ws)
self.assertFalse(result)
def test_valid_audio_returns_numpy(self):
import numpy as np
ws = MagicMock()
audio = np.array([0.1, 0.2, 0.3], dtype=np.float32)
ws.recv.return_value = audio.tobytes()
result = self.server.get_audio_from_websocket(ws)
np.testing.assert_array_almost_equal(result, audio)
class TestTranscriptionServerHandleNewConnection(unittest.TestCase):
def setUp(self):
self.server = TranscriptionServer()
self.server.client_manager = ClientManager(max_clients=4, max_connection_time=600)
self.server.cache_path = "~/.cache/whisper-live/"
self.server.backend = BackendType.FASTER_WHISPER
@mock.patch("websockets.WebSocketCommonProtocol")
def test_invalid_json_returns_false(self, mock_ws):
mock_ws.recv.return_value = "not valid json {{"
result = self.server.handle_new_connection(mock_ws, None, None, False)
self.assertFalse(result)
@mock.patch("websockets.WebSocketCommonProtocol")
def test_server_full_returns_false(self, mock_ws):
# Fill server
for i in range(4):
self.server.client_manager.add_client(MagicMock(), MagicMock())
mock_ws.recv.return_value = json.dumps({
"uid": "test",
"language": "en",
"task": "transcribe",
"model": "tiny.en",
})
result = self.server.handle_new_connection(mock_ws, None, None, False)
self.assertFalse(result)
class TestTranscriptionServerCleanup(unittest.TestCase):
def setUp(self):
self.server = TranscriptionServer()
self.server.client_manager = ClientManager(max_clients=4, max_connection_time=600)
def test_cleanup_removes_client(self):
ws = MagicMock()
client = MagicMock()
self.server.client_manager.add_client(ws, client)
self.server.cleanup(ws)
self.assertNotIn(ws, self.server.client_manager.clients)
client.cleanup.assert_called_once()
if __name__ == "__main__":
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
import os
import tempfile
import unittest
from io import StringIO
from unittest.mock import patch
from whisper_live.utils import format_time, create_srt_file, print_transcript
class TestFormatTime(unittest.TestCase):
def test_zero(self):
self.assertEqual(format_time(0), "00:00:00,000")
def test_seconds_only(self):
self.assertEqual(format_time(5.0), "00:00:05,000")
def test_fractional_seconds(self):
self.assertEqual(format_time(1.5), "00:00:01,500")
def test_minutes(self):
self.assertEqual(format_time(65.0), "00:01:05,000")
def test_hours(self):
self.assertEqual(format_time(3661.123), "01:01:01,123")
def test_millisecond_precision(self):
self.assertEqual(format_time(0.001), "00:00:00,001")
def test_large_value(self):
# float precision: int((86399.999 - 86399) * 1000) may be 998 or 999
result = format_time(86399.999)
self.assertIn(result, ("23:59:59,998", "23:59:59,999"))
def test_rounding_edge(self):
result = format_time(0.9999)
# 0.9999 -> int(s%60)=0, milliseconds=int(0.9999*1000)=999
self.assertEqual(result, "00:00:00,999")
class TestCreateSrtFile(unittest.TestCase):
def test_single_segment(self):
segments = [{"start": "0.000", "end": "1.500", "text": "Hello world"}]
with tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False) as f:
path = f.name
try:
create_srt_file(segments, path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("1\n", content)
self.assertIn("00:00:00,000 --> 00:00:01,500", content)
self.assertIn("Hello world", content)
finally:
os.remove(path)
def test_multiple_segments(self):
segments = [
{"start": "0.000", "end": "1.000", "text": "First"},
{"start": "1.000", "end": "2.500", "text": "Second"},
{"start": "2.500", "end": "4.000", "text": "Third"},
]
with tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False) as f:
path = f.name
try:
create_srt_file(segments, path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("1\n", content)
self.assertIn("2\n", content)
self.assertIn("3\n", content)
self.assertIn("First", content)
self.assertIn("Third", content)
finally:
os.remove(path)
def test_empty_segments(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False) as f:
path = f.name
try:
create_srt_file([], path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, "")
finally:
os.remove(path)
def test_unicode_text(self):
segments = [{"start": "0.000", "end": "1.000", "text": "日本語テスト"}]
with tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False) as f:
path = f.name
try:
create_srt_file(segments, path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("日本語テスト", content)
finally:
os.remove(path)
class TestPrintTranscript(unittest.TestCase):
@patch("sys.stdout", new_callable=StringIO)
def test_print_plain_text(self, mock_stdout):
text = ["Hello", " world"]
print_transcript(text)
output = mock_stdout.getvalue()
self.assertIn("Hello world", output)
@patch("sys.stdout", new_callable=StringIO)
def test_print_with_timestamps(self, mock_stdout):
text = [
{"start": 0.0, "end": 1.0, "text": "Hello"},
{"start": 1.0, "end": 2.0, "text": "world"},
]
print_transcript(text, timestamps=True)
output = mock_stdout.getvalue()
self.assertIn("[0.0 -> 1.0]", output)
self.assertIn("Hello", output)
@patch("sys.stdout", new_callable=StringIO)
def test_print_translated(self, mock_stdout):
text = ["Bonjour", "le monde"]
print_transcript(text, translated=True)
output = mock_stdout.getvalue()
self.assertIn("Bonjour le monde", output)
@patch("sys.stdout", new_callable=StringIO)
def test_print_empty(self, mock_stdout):
print_transcript([])
output = mock_stdout.getvalue()
# empty text joined is empty string, should not crash
self.assertEqual(output.strip(), "")
if __name__ == "__main__":
unittest.main()
+131
View File
@@ -0,0 +1,131 @@
import unittest
from unittest.mock import patch, MagicMock
import numpy as np
import torch
from whisper_live.vad import VoiceActivityDetection, VoiceActivityDetector
class TestVoiceActivityDetectionValidation(unittest.TestCase):
"""Tests for VoiceActivityDetection input validation without requiring the ONNX model."""
@patch.object(VoiceActivityDetection, "__init__", lambda self, **kw: None)
def setUp(self):
self.vad = VoiceActivityDetection()
self.vad.sample_rates = [8000, 16000]
def test_1d_input_unsqueezed(self):
x = torch.randn(512)
x_out, sr_out = self.vad._validate_input(x, 16000)
self.assertEqual(x_out.dim(), 2)
self.assertEqual(sr_out, 16000)
def test_3d_input_raises(self):
x = torch.randn(1, 1, 512)
with self.assertRaises(ValueError):
self.vad._validate_input(x, 16000)
def test_unsupported_sample_rate_raises(self):
x = torch.randn(1, 512)
with self.assertRaises(ValueError):
self.vad._validate_input(x, 44100)
def test_too_short_audio_raises(self):
x = torch.randn(1, 1)
with self.assertRaises(ValueError):
self.vad._validate_input(x, 16000)
def test_downsample_multiple_of_16k(self):
x = torch.randn(1, 512 * 3)
x_out, sr_out = self.vad._validate_input(x, 48000)
self.assertEqual(sr_out, 16000)
self.assertEqual(x_out.shape[1], 512)
class TestVoiceActivityDetectionStateReset(unittest.TestCase):
"""Tests for VoiceActivityDetection.reset_states()."""
@patch.object(VoiceActivityDetection, "__init__", lambda self, **kw: None)
def setUp(self):
self.vad = VoiceActivityDetection()
def test_reset_creates_correct_shapes(self):
self.vad.reset_states(batch_size=4)
self.assertEqual(self.vad._state.shape, (2, 4, 128))
self.assertEqual(self.vad._context.shape[0], 0)
self.assertEqual(self.vad._last_sr, 0)
self.assertEqual(self.vad._last_batch_size, 0)
def test_reset_default_batch_size(self):
self.vad.reset_states()
self.assertEqual(self.vad._state.shape, (2, 1, 128))
class TestVoiceActivityDetectionDownload(unittest.TestCase):
"""Tests for the model download function."""
@patch("os.path.exists", return_value=True)
def test_skips_download_if_exists(self, mock_exists):
path = VoiceActivityDetection.download()
self.assertTrue(path.endswith("silero_vad.onnx"))
@patch("os.path.exists", return_value=False)
@patch("subprocess.run")
@patch("os.makedirs")
def test_downloads_if_missing(self, mock_makedirs, mock_run, mock_exists):
path = VoiceActivityDetection.download()
mock_run.assert_called_once()
self.assertIn("silero_vad.onnx", path)
@patch("os.path.exists", return_value=False)
@patch("subprocess.run", side_effect=Exception("wget not found"))
@patch("os.makedirs")
def test_handles_download_failure(self, mock_makedirs, mock_run, mock_exists):
# should not raise, just prints an error
with self.assertRaises(Exception):
VoiceActivityDetection.download()
class TestVoiceActivityDetectorThreshold(unittest.TestCase):
"""Tests for VoiceActivityDetector threshold behavior."""
@patch.object(VoiceActivityDetection, "__init__", lambda self, **kw: None)
def test_above_threshold_returns_true(self):
detector = VoiceActivityDetector.__new__(VoiceActivityDetector)
detector.model = VoiceActivityDetection()
detector.threshold = 0.5
detector.frame_rate = 16000
mock_probs = torch.tensor([[0.9, 0.8, 0.7]])
with patch.object(detector.model, "audio_forward", return_value=mock_probs):
result = detector(np.random.randn(16000).astype(np.float32))
self.assertTrue(result)
@patch.object(VoiceActivityDetection, "__init__", lambda self, **kw: None)
def test_below_threshold_returns_false(self):
detector = VoiceActivityDetector.__new__(VoiceActivityDetector)
detector.model = VoiceActivityDetection()
detector.threshold = 0.5
detector.frame_rate = 16000
mock_probs = torch.tensor([[0.1, 0.2, 0.3]])
with patch.object(detector.model, "audio_forward", return_value=mock_probs):
result = detector(np.random.randn(16000).astype(np.float32))
self.assertFalse(result)
@patch.object(VoiceActivityDetection, "__init__", lambda self, **kw: None)
def test_custom_threshold(self):
detector = VoiceActivityDetector.__new__(VoiceActivityDetector)
detector.model = VoiceActivityDetection()
detector.threshold = 0.95
detector.frame_rate = 16000
mock_probs = torch.tensor([[0.9]])
with patch.object(detector.model, "audio_forward", return_value=mock_probs):
result = detector(np.random.randn(16000).astype(np.float32))
self.assertFalse(result)
if __name__ == "__main__":
unittest.main()