mirror of
https://github.com/chidiwilliams/buzz.git
synced 2026-10-20 11:48:00 +00:00
1562 fix stop kill (#1568)
This commit is contained in:
parent
443a4f2271
commit
cb0b0089a1
8 changed files with 996 additions and 23 deletions
|
|
@ -18,6 +18,7 @@ from threading import Thread
|
|||
from typing import Optional, List
|
||||
|
||||
import tqdm
|
||||
import psutil
|
||||
from PyQt6.QtCore import QObject
|
||||
|
||||
from buzz import whisper_audio
|
||||
|
|
@ -37,6 +38,49 @@ from stable_whisper import WhisperResult
|
|||
PROGRESS_REGEX = re.compile(r"\d+(\.\d+)?%")
|
||||
|
||||
|
||||
def terminate_child_processes(pid: int, timeout: float = 5.0) -> None:
|
||||
"""Terminate every descendant process of ``pid`` (but not ``pid`` itself).
|
||||
|
||||
For whisper.cpp the actual work runs in a ``whisper-cli`` subprocess spawned
|
||||
by our multiprocessing worker. ``multiprocessing.Process.terminate()`` only
|
||||
signals the worker, leaving that CLI orphaned and still consuming CPU/GPU
|
||||
after the user presses Stop or closes the app. This kills those descendants.
|
||||
|
||||
Crucially it does *not* touch ``pid`` itself: the worker is a direct child
|
||||
of the app process and must be reaped via ``multiprocessing`` (join). Calling
|
||||
``psutil.wait_procs`` on it here would steal the ``waitpid`` reap and leave
|
||||
``Process.is_alive()`` stuck reporting True. The descendants are grandchildren
|
||||
of the app, so psutil only polls them and no such race occurs.
|
||||
"""
|
||||
try:
|
||||
parent = psutil.Process(pid)
|
||||
except (psutil.NoSuchProcess, ValueError):
|
||||
return
|
||||
|
||||
# Snapshot the tree before anything gets reparented by the worker exiting.
|
||||
try:
|
||||
descendants = parent.children(recursive=True)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return
|
||||
|
||||
# Ask each descendant to exit (SIGTERM / TerminateProcess).
|
||||
for proc in descendants:
|
||||
try:
|
||||
proc.terminate()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
_, alive = psutil.wait_procs(descendants, timeout=timeout)
|
||||
|
||||
# Force-kill whatever ignored the polite request.
|
||||
for proc in alive:
|
||||
try:
|
||||
proc.kill()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
psutil.wait_procs(alive, timeout=timeout)
|
||||
|
||||
|
||||
def check_file_has_audio_stream(file_path: str) -> None:
|
||||
"""Check if a media file has at least one audio stream.
|
||||
|
||||
|
|
@ -404,19 +448,17 @@ class WhisperFileTranscriber(FileTranscriber):
|
|||
self.stopped = True
|
||||
|
||||
if self.started_process:
|
||||
# Kill the whisper-cli subprocess the worker spawned first. The
|
||||
# worker's own terminate() below does not reach it, so it would
|
||||
# otherwise keep running (orphaned) after Stop / app close.
|
||||
if self.current_process.pid is not None:
|
||||
terminate_child_processes(self.current_process.pid)
|
||||
|
||||
# Terminate the worker itself; it is reaped via join() below.
|
||||
self.current_process.terminate()
|
||||
|
||||
if self.read_line_thread and self.read_line_thread.is_alive():
|
||||
self.read_line_thread.join(timeout=5)
|
||||
if self.read_line_thread.is_alive():
|
||||
logging.warning("Read line thread still alive after 5s")
|
||||
|
||||
self.current_process.join(timeout=10)
|
||||
if self.current_process.is_alive():
|
||||
logging.warning("Process didn't terminate gracefully, force killing")
|
||||
self.current_process.kill()
|
||||
self.current_process.join(timeout=5)
|
||||
|
||||
# Close the pipes to unblock the read_line thread, which is
|
||||
# otherwise stuck in recv() until it gets EOF / an OSError.
|
||||
try:
|
||||
if hasattr(self, 'send_pipe') and self.send_pipe:
|
||||
self.send_pipe.close()
|
||||
|
|
@ -429,6 +471,17 @@ class WhisperFileTranscriber(FileTranscriber):
|
|||
except Exception as e:
|
||||
logging.debug(f"Error closing recv_pipe: {e}")
|
||||
|
||||
if self.read_line_thread and self.read_line_thread.is_alive():
|
||||
self.read_line_thread.join(timeout=5)
|
||||
if self.read_line_thread.is_alive():
|
||||
logging.warning("Read line thread still alive after 5s")
|
||||
|
||||
self.current_process.join(timeout=10)
|
||||
if self.current_process.is_alive():
|
||||
logging.warning("Process didn't terminate gracefully, force killing")
|
||||
self.current_process.kill()
|
||||
self.current_process.join(timeout=5)
|
||||
|
||||
def read_line(self, pipe: Connection):
|
||||
while True:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ In addition to choosing an appropriate model size you also can choose whisper ty
|
|||
- **Whisper** is initial OpenAI implementation, it is accurate but slow and requires a lot of RAM.
|
||||
- **Faster Whisper** is an optimized implementation, it is orders of magnitude faster than regular Whisper and requires less RAM. Use this option if you have an Nvidia GPU with at least 6GB of VRAM.
|
||||
- **Whisper.cpp** is optimized C++ implementation, it quite fast and efficient and will use any brand of GPU. Whisper.cpp is capable of running real time transcription even on a modern laptop with integrated GPU. It can also run on CPU only. Use this option if you do not have Nvidia GPU. This is best option to us on Macs.
|
||||
- **HuggingFace** option is a `Transformers` implementation and is good in that it supports wide range of custom models that may be optimized for a particular language. This option also supports [MMS](https://ai.meta.com/blog/multilingual-model-speech-recognition/) family of models from Meta AI that support over 1000 of worlds languages as well as [PEFT](https://github.com/huggingface/peft) adjustments to Whisper models.
|
||||
- **HuggingFace** option is a `Transformers` implementation and is good in that it supports wide range of custom models that may be optimized for a particular language.
|
||||
- Since version `1.4.0` This option also supports [MMS](https://ai.meta.com/blog/multilingual-model-speech-recognition/) family of models from Meta AI that support over 1000 of worlds languages as well as [PEFT](https://github.com/huggingface/peft) adjustments to Whisper models.
|
||||
- Since version `1.5.0` [Parakeet](https://huggingface.co/models?sort=trending&search=parakeet), [Qwen3-ASR](https://huggingface.co/models?sort=trending&search=qwen+asr+hf) and [VibeVoice ASR](https://huggingface.co/models?sort=trending&search=vibevoice+hf+) models are also supported.
|
||||
|
||||
Models with `.En` in their names are for English and will not work for other languages.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ dependencies = [
|
|||
"museval>=0.4.1,<0.5",
|
||||
"mypy>=1.15.0,<2",
|
||||
"openunmix>=1.3.0,<2",
|
||||
"psutil>=5.9.0,<8",
|
||||
"pyyaml>=6.0.2,<7",
|
||||
"submitit>=1.5.2,<2",
|
||||
"tqdm>=4.67.1,<5",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import glob
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from threading import Thread
|
||||
from typing import List
|
||||
from unittest.mock import Mock
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
|
|
@ -24,12 +29,46 @@ from buzz.transcriber.transcriber import (
|
|||
from buzz.transcriber.whisper_file_transcriber import (
|
||||
WhisperFileTranscriber,
|
||||
check_file_has_audio_stream,
|
||||
terminate_child_processes,
|
||||
PROGRESS_REGEX,
|
||||
)
|
||||
from tests.audio import test_audio_path
|
||||
from tests.model_loader import get_model_path
|
||||
|
||||
|
||||
def _spawn_grandchild_worker(pipe):
|
||||
"""Multiprocessing target that mirrors the whisper.cpp process tree.
|
||||
|
||||
Spawns a long-lived subprocess (the stand-in for ``whisper-cli``), reports
|
||||
its pid back to the parent, then blocks waiting on it. This gives us a
|
||||
three-level tree (test -> worker process -> subprocess) to verify that
|
||||
``terminate_child_processes`` reaps grandchildren, not just the direct child.
|
||||
"""
|
||||
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"])
|
||||
pipe.send(proc.pid)
|
||||
pipe.close()
|
||||
proc.wait()
|
||||
|
||||
|
||||
def _is_dead_or_zombie(proc: psutil.Process) -> bool:
|
||||
"""True if the process is gone, or a not-yet-reaped zombie."""
|
||||
try:
|
||||
if not proc.is_running():
|
||||
return True
|
||||
return proc.status() == psutil.STATUS_ZOMBIE
|
||||
except psutil.NoSuchProcess:
|
||||
return True
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout: float = 15.0, interval: float = 0.1) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return predicate()
|
||||
|
||||
|
||||
class TestCheckFileHasAudioStream:
|
||||
def test_valid_audio_file(self):
|
||||
# Should not raise exception for valid audio file
|
||||
|
|
@ -74,6 +113,53 @@ class TestProgressRegex:
|
|||
assert percentage == 85
|
||||
|
||||
|
||||
class TestTerminateChildProcesses:
|
||||
def test_kills_grandchild_subprocess(self):
|
||||
"""The whisper-cli stand-in (a grandchild) must be killed, not orphaned.
|
||||
|
||||
Reproduces the whisper.cpp shape: a multiprocessing worker that spawns
|
||||
a long-lived subprocess. terminate_child_processes must kill that
|
||||
subprocess while leaving the worker for its owner to reap.
|
||||
"""
|
||||
recv_pipe, send_pipe = multiprocessing.Pipe(duplex=False)
|
||||
worker = multiprocessing.Process(
|
||||
target=_spawn_grandchild_worker, args=(send_pipe,)
|
||||
)
|
||||
worker.start()
|
||||
# Parent doesn't send; close its copy so the pipe isn't kept open.
|
||||
send_pipe.close()
|
||||
|
||||
# The worker reports the grandchild pid once the subprocess is up.
|
||||
grandchild_pid = recv_pipe.recv()
|
||||
recv_pipe.close()
|
||||
|
||||
worker_proc = psutil.Process(worker.pid)
|
||||
grandchild_proc = psutil.Process(grandchild_pid)
|
||||
assert worker_proc.is_running()
|
||||
assert grandchild_proc.is_running()
|
||||
# Sanity check the tree is actually nested two levels deep.
|
||||
assert grandchild_pid in {
|
||||
child.pid for child in worker_proc.children(recursive=True)
|
||||
}
|
||||
|
||||
terminate_child_processes(worker.pid)
|
||||
|
||||
# The grandchild must be gone...
|
||||
assert _wait_until(lambda: _is_dead_or_zombie(grandchild_proc)), (
|
||||
"whisper-cli stand-in subprocess was orphaned instead of killed"
|
||||
)
|
||||
|
||||
# ...and the worker must still be reapable via multiprocessing (i.e.
|
||||
# terminate_child_processes must not have stolen its waitpid()).
|
||||
worker.terminate()
|
||||
worker.join(timeout=10)
|
||||
assert not worker.is_alive()
|
||||
|
||||
def test_missing_pid_is_noop(self):
|
||||
# A pid that cannot be a live process must not raise.
|
||||
terminate_child_processes(-1)
|
||||
|
||||
|
||||
class TestWhisperFileTranscriber:
|
||||
@pytest.mark.parametrize(
|
||||
"file_path,output_format,expected_file_path",
|
||||
|
|
@ -393,7 +479,6 @@ class TestWhisperFileTranscriber:
|
|||
transcriber.stop()
|
||||
time.sleep(3)
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_transcribe_stop(self):
|
||||
output_file_path = os.path.join(tempfile.gettempdir(), "whisper.txt")
|
||||
if os.path.exists(output_file_path):
|
||||
|
|
@ -421,10 +506,46 @@ class TestWhisperFileTranscriber:
|
|||
file_path=test_audio_path,
|
||||
)
|
||||
)
|
||||
transcriber.run()
|
||||
time.sleep(1)
|
||||
|
||||
# run() blocks until transcription finishes, so drive it from a thread
|
||||
# and stop it mid-flight from the test thread.
|
||||
run_thread = Thread(target=transcriber.run, daemon=True)
|
||||
run_thread.start()
|
||||
|
||||
# Wait until the whisper.cpp worker process AND its whisper-cli
|
||||
# subprocess (grandchild) are actually up.
|
||||
def worker_tree_is_up() -> bool:
|
||||
if not transcriber.started_process:
|
||||
return False
|
||||
pid = transcriber.current_process.pid
|
||||
if pid is None:
|
||||
return False
|
||||
try:
|
||||
return len(psutil.Process(pid).children(recursive=True)) > 0
|
||||
except psutil.NoSuchProcess:
|
||||
return False
|
||||
|
||||
assert _wait_until(worker_tree_is_up, timeout=60), (
|
||||
"whisper.cpp worker/subprocess did not start"
|
||||
)
|
||||
|
||||
worker_pid = transcriber.current_process.pid
|
||||
worker_proc = psutil.Process(worker_pid)
|
||||
descendants = worker_proc.children(recursive=True)
|
||||
assert descendants, "whisper-cli subprocess did not start"
|
||||
|
||||
transcriber.stop()
|
||||
|
||||
# run() must return promptly and the whole process tree must be gone.
|
||||
run_thread.join(timeout=30)
|
||||
assert not run_thread.is_alive(), "transcriber.run() did not return after stop()"
|
||||
|
||||
assert _wait_until(lambda: _is_dead_or_zombie(worker_proc))
|
||||
for child in descendants:
|
||||
assert _wait_until(lambda child=child: _is_dead_or_zombie(child)), (
|
||||
f"whisper-cli subprocess {child.pid} still running after stop()"
|
||||
)
|
||||
|
||||
# Assert that file was not created
|
||||
assert os.path.isfile(output_file_path) is False
|
||||
|
||||
|
|
|
|||
|
|
@ -1457,3 +1457,194 @@ class TestPresentationTranslationSync:
|
|||
widget.translation_text_box.setPlainText("Translated content")
|
||||
widget.on_show_presentation_clicked()
|
||||
assert "Translated content" in widget.presentation_window._current_translation
|
||||
|
||||
|
||||
class TestOnNextTranslationExport:
|
||||
"""CSV/TXT export of translations, mirroring the transcript export tests."""
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_append_below_csv_appends_columns(self, qtbot):
|
||||
import csv
|
||||
with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile(
|
||||
suffix=".csv", delete=False, mode="w"
|
||||
) as f:
|
||||
export_path = f.name
|
||||
|
||||
try:
|
||||
widget.transcriber_mode = RecordingTranscriberMode.APPEND_BELOW
|
||||
widget.export_enabled = True
|
||||
widget.translation_export_file = export_path
|
||||
widget.export_file_type = "csv"
|
||||
widget.on_next_translation("bonjour")
|
||||
widget.on_next_translation("monde")
|
||||
|
||||
with open(export_path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = [r for r in csv.reader(f) if r]
|
||||
assert rows[0] == ["bonjour", "monde"]
|
||||
finally:
|
||||
os.unlink(export_path)
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_append_above_csv_prepends_columns(self, qtbot):
|
||||
import csv
|
||||
with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile(
|
||||
suffix=".csv", delete=False, mode="w"
|
||||
) as f:
|
||||
export_path = f.name
|
||||
|
||||
try:
|
||||
widget.transcriber_mode = RecordingTranscriberMode.APPEND_ABOVE
|
||||
widget.export_enabled = True
|
||||
widget.translation_export_file = export_path
|
||||
widget.export_file_type = "csv"
|
||||
widget.on_next_translation("first")
|
||||
widget.on_next_translation("second")
|
||||
|
||||
with open(export_path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = [r for r in csv.reader(f) if r]
|
||||
assert rows[0] == ["second", "first"]
|
||||
finally:
|
||||
os.unlink(export_path)
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_append_above_csv_respects_max_entries(self, qtbot):
|
||||
import csv
|
||||
with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile(
|
||||
suffix=".csv", delete=False, mode="w"
|
||||
) as f:
|
||||
export_path = f.name
|
||||
|
||||
try:
|
||||
widget.transcriber_mode = RecordingTranscriberMode.APPEND_ABOVE
|
||||
widget.export_enabled = True
|
||||
widget.translation_export_file = export_path
|
||||
widget.export_file_type = "csv"
|
||||
widget.export_max_entries = 2
|
||||
widget.on_next_translation("first")
|
||||
widget.on_next_translation("second")
|
||||
widget.on_next_translation("third")
|
||||
|
||||
with open(export_path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = [r for r in csv.reader(f) if r]
|
||||
assert rows[0] == ["third", "second"]
|
||||
finally:
|
||||
os.unlink(export_path)
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_append_below_txt_writes_translation(self, qtbot):
|
||||
with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile(
|
||||
suffix=".txt", delete=False, mode="w"
|
||||
) as f:
|
||||
export_path = f.name
|
||||
|
||||
try:
|
||||
widget.transcription_options.line_separator = "\n"
|
||||
widget.transcriber_mode = RecordingTranscriberMode.APPEND_BELOW
|
||||
widget.export_enabled = True
|
||||
widget.translation_export_file = export_path
|
||||
widget.export_file_type = "txt"
|
||||
widget.on_next_translation("bonjour")
|
||||
widget.on_next_translation("monde")
|
||||
|
||||
with open(export_path) as f:
|
||||
content = f.read()
|
||||
assert "bonjour" in content
|
||||
assert "monde" in content
|
||||
finally:
|
||||
os.unlink(export_path)
|
||||
|
||||
|
||||
class TestAppendAndCorrectCsvExport:
|
||||
"""APPEND_AND_CORRECT rewrites the CSV with the whole merged text as one entry."""
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_transcript_merge_writes_single_csv_entry(self, qtbot):
|
||||
import csv
|
||||
with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile(
|
||||
suffix=".csv", delete=False, mode="w"
|
||||
) as f:
|
||||
export_path = f.name
|
||||
|
||||
try:
|
||||
widget.transcriber_mode = RecordingTranscriberMode.APPEND_AND_CORRECT
|
||||
widget.hide_unconfirmed = False
|
||||
widget.export_enabled = True
|
||||
widget.export_file_type = "csv"
|
||||
widget.transcript_export_file = export_path
|
||||
widget.on_next_transcription("Hello world.")
|
||||
widget.on_next_transcription("world. Goodbye.")
|
||||
|
||||
with open(export_path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = [r for r in csv.reader(f) if r]
|
||||
assert len(rows) == 1
|
||||
assert len(rows[0]) == 1
|
||||
assert "Hello" in rows[0][0]
|
||||
assert "Goodbye" in rows[0][0]
|
||||
finally:
|
||||
os.unlink(export_path)
|
||||
|
||||
|
||||
class TestRecordingModeAndHideUnconfirmed:
|
||||
@pytest.mark.timeout(60)
|
||||
def test_on_recording_mode_changed_updates_mode(self, qtbot):
|
||||
with _widget_ctx(qtbot) as widget:
|
||||
widget.on_recording_mode_changed(RecordingTranscriberMode.APPEND_ABOVE)
|
||||
assert widget.transcriber_mode == RecordingTranscriberMode.APPEND_ABOVE
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_on_hide_unconfirmed_changed_updates_flag(self, qtbot):
|
||||
with _widget_ctx(qtbot) as widget:
|
||||
widget.on_hide_unconfirmed_changed(False)
|
||||
assert widget.hide_unconfirmed is False
|
||||
widget.on_hide_unconfirmed_changed(True)
|
||||
assert widget.hide_unconfirmed is True
|
||||
|
||||
|
||||
class TestOnCopyTranscriptFailurePaths:
|
||||
@pytest.mark.timeout(60)
|
||||
def test_no_qapplication_instance_shows_copy_failed(self, qtbot):
|
||||
with _widget_ctx(qtbot) as widget:
|
||||
widget.transcription_text_box.setPlainText("some text")
|
||||
with patch("buzz.widgets.recording_transcriber_widget.QApplication.instance",
|
||||
return_value=None):
|
||||
widget.on_copy_transcript_clicked()
|
||||
assert widget.copy_transcript_button.text() == _("Copy failed")
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_none_clipboard_shows_copy_failed(self, qtbot):
|
||||
with _widget_ctx(qtbot) as widget:
|
||||
widget.transcription_text_box.setPlainText("some text")
|
||||
mock_app = MagicMock()
|
||||
mock_app.clipboard.return_value = None
|
||||
with patch("buzz.widgets.recording_transcriber_widget.QApplication.instance",
|
||||
return_value=mock_app):
|
||||
widget.on_copy_transcript_clicked()
|
||||
assert widget.copy_transcript_button.text() == _("Copy failed")
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_clipboard_set_text_exception_shows_copy_failed(self, qtbot):
|
||||
with _widget_ctx(qtbot) as widget:
|
||||
widget.transcription_text_box.setPlainText("some text")
|
||||
mock_clipboard = MagicMock()
|
||||
mock_clipboard.setText.side_effect = Exception("clipboard boom")
|
||||
mock_app = MagicMock()
|
||||
mock_app.clipboard.return_value = mock_clipboard
|
||||
with patch("buzz.widgets.recording_transcriber_widget.QApplication.instance",
|
||||
return_value=mock_app):
|
||||
widget.on_copy_transcript_clicked()
|
||||
assert widget.copy_transcript_button.text() == _("Copy failed")
|
||||
|
||||
|
||||
class TestWriteCsvExportErrorHandling:
|
||||
def test_write_gives_up_after_max_retries(self, tmp_path):
|
||||
path = str(tmp_path / "out.csv")
|
||||
with patch("builtins.open", side_effect=PermissionError("locked")), \
|
||||
patch("time.sleep"):
|
||||
# Should log and return without raising
|
||||
RecordingTranscriberWidget.write_csv_export(path, "data", 0)
|
||||
|
||||
def test_write_handles_oserror(self, tmp_path):
|
||||
path = str(tmp_path / "out.csv")
|
||||
with patch("builtins.open", side_effect=OSError("disk full")):
|
||||
# Should return gracefully without raising
|
||||
RecordingTranscriberWidget.write_csv_export(path, "data", 0)
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ class TestSpeakerIdentificationWidget:
|
|||
# Create mock punctuation model - raise AssertionError on first batch, then succeed
|
||||
mock_punct_model = MagicMock()
|
||||
call_count = [0]
|
||||
|
||||
|
||||
def predict_side_effect(batch, chunk_size):
|
||||
call_count[0] += 1
|
||||
# Raise AssertionError on first call (first batch)
|
||||
|
|
@ -357,26 +357,26 @@ class TestSpeakerIdentificationWidget:
|
|||
raise AssertionError("Chunk size too large")
|
||||
# Succeed on subsequent calls (smaller batches)
|
||||
return [(word.strip(), ".") for word in batch]
|
||||
|
||||
|
||||
mock_punct_model.predict.side_effect = predict_side_effect
|
||||
|
||||
|
||||
# Create words list with 201 words (enough to trigger batch processing)
|
||||
words_list = [f"word{i}" for i in range(201)]
|
||||
|
||||
|
||||
# Wrap predict method to match the expected signature
|
||||
def predict_wrapper(batch, chunk_size, **kwargs):
|
||||
return mock_punct_model.predict(batch, chunk_size=chunk_size)
|
||||
|
||||
|
||||
# Call the generic batch processing function
|
||||
result = process_in_batches(
|
||||
items=words_list,
|
||||
process_func=predict_wrapper
|
||||
)
|
||||
|
||||
|
||||
# Verify that predict was called multiple times
|
||||
# First call fails, then smaller batches succeed
|
||||
assert mock_punct_model.predict.call_count > 1, "Should retry with smaller batches after AssertionError"
|
||||
|
||||
|
||||
# Verify that smaller batches were used after the error
|
||||
call_args_list = mock_punct_model.predict.call_args_list
|
||||
# After the first failed call, subsequent calls should have smaller batches
|
||||
|
|
@ -384,7 +384,286 @@ class TestSpeakerIdentificationWidget:
|
|||
args, kwargs = call
|
||||
batch = args[0]
|
||||
assert len(batch) <= 100, f"After AssertionError, batch size should be <= 100, got {len(batch)}"
|
||||
|
||||
|
||||
# Verify result contains all words
|
||||
assert len(result) == 201, "Result should contain all words"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IdentificationWorker unit tests
|
||||
#
|
||||
# These exercise the worker's individual helper methods in isolation with
|
||||
# mocked ML libraries, so they do NOT run any real speaker identification.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_worker_cancel_sets_flag(self, transcription, transcription_service):
|
||||
"""cancel() flips the flag and _cancel_if_requested reports it."""
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
|
||||
assert worker._cancel_if_requested("step 1") is False
|
||||
|
||||
worker.cancel()
|
||||
|
||||
assert worker._is_cancelled is True
|
||||
assert worker._cancel_if_requested("step 1") is True
|
||||
|
||||
def test_get_transcript_splits_on_sentence_end(self, transcription):
|
||||
"""get_transcript groups words into a segment when a sentence ends."""
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_transcription_segments.return_value = [
|
||||
MagicMock(text="Hello", start_time=0, end_time=100),
|
||||
MagicMock(text="world.", start_time=100, end_time=200),
|
||||
MagicMock(text="Unfinished", start_time=200, end_time=300),
|
||||
]
|
||||
worker = IdentificationWorker(transcription, mock_service)
|
||||
|
||||
result = worker.get_transcript(audio=None)
|
||||
|
||||
assert result["language"] == transcription.language
|
||||
# Only "world." ends a sentence, so exactly one segment is emitted and
|
||||
# the trailing "Unfinished" word (no terminator) is dropped.
|
||||
assert len(result["segments"]) == 1
|
||||
assert result["segments"][0]["text"] == "Hello world. "
|
||||
assert [w["word"] for w in result["segments"][0]["words"]] == ["Hello ", "world. "]
|
||||
|
||||
def test_get_transcript_data_joins_segments(self, transcription, transcription_service):
|
||||
"""_get_transcript_data collapses whitespace and decodes the audio."""
|
||||
import numpy as np
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
|
||||
fake_waveform = np.zeros(10, dtype=np.float32)
|
||||
with patch(
|
||||
"buzz.widgets.transcription_viewer.speaker_identification_widget.faster_whisper.decode_audio",
|
||||
return_value=fake_waveform,
|
||||
) as mock_decode:
|
||||
language, full_transcript, waveform = worker._get_transcript_data()
|
||||
|
||||
# transcription fixture has no language set, so it falls back to "en"
|
||||
assert language == "en"
|
||||
assert full_transcript == "Bien venue dans"
|
||||
assert waveform is fake_waveform
|
||||
mock_decode.assert_called_once_with(transcription.file)
|
||||
|
||||
def test_setup_device_force_cpu(self, transcription, transcription_service, monkeypatch):
|
||||
"""_setup_device honours BUZZ_FORCE_CPU regardless of CUDA availability."""
|
||||
import torch
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
monkeypatch.setenv("BUZZ_FORCE_CPU", "true")
|
||||
|
||||
device, torch_dtype = worker._setup_device()
|
||||
|
||||
assert device == "cpu"
|
||||
assert torch_dtype == torch.float32
|
||||
|
||||
def test_load_alignment_model_with_retry_success(self, transcription, transcription_service):
|
||||
"""Model loads on the first attempt without retrying."""
|
||||
import torch
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
worker._load_alignment_model = MagicMock(return_value=("model", "tokenizer"))
|
||||
|
||||
model, tokenizer = worker._load_alignment_model_with_retry("cpu", torch.float32)
|
||||
|
||||
assert (model, tokenizer) == ("model", "tokenizer")
|
||||
assert worker._load_alignment_model.call_count == 1
|
||||
|
||||
def test_load_alignment_model_with_retry_recovers(self, transcription, transcription_service):
|
||||
"""A first failure is retried and the second attempt succeeds."""
|
||||
import torch
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
worker._load_alignment_model = MagicMock(
|
||||
side_effect=[Exception("network"), ("model", "tokenizer")]
|
||||
)
|
||||
|
||||
with patch("buzz.widgets.transcription_viewer.speaker_identification_widget.time.sleep"):
|
||||
model, tokenizer = worker._load_alignment_model_with_retry("cpu", torch.float32)
|
||||
|
||||
assert (model, tokenizer) == ("model", "tokenizer")
|
||||
assert worker._load_alignment_model.call_count == 2
|
||||
|
||||
def test_load_alignment_model_with_retry_exhausted_raises(self, transcription, transcription_service):
|
||||
"""After all attempts fail a RuntimeError is raised."""
|
||||
import torch
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
worker._load_alignment_model = MagicMock(side_effect=Exception("network"))
|
||||
|
||||
with patch("buzz.widgets.transcription_viewer.speaker_identification_widget.time.sleep"):
|
||||
with pytest.raises(RuntimeError):
|
||||
worker._load_alignment_model_with_retry("cpu", torch.float32)
|
||||
|
||||
assert worker._load_alignment_model.call_count == 3
|
||||
|
||||
def test_handle_run_error_emits_signals(self, transcription, transcription_service):
|
||||
"""_handle_run_error emits error + an empty finished result."""
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
|
||||
errors = []
|
||||
finished = []
|
||||
progress = []
|
||||
worker.error.connect(errors.append)
|
||||
worker.finished.connect(finished.append)
|
||||
worker.progress_update.connect(progress.append)
|
||||
|
||||
worker._handle_run_error(Exception("boom"))
|
||||
|
||||
assert errors == ["boom"]
|
||||
assert finished == [[]]
|
||||
assert len(progress) == 1
|
||||
|
||||
def test_run_import_error_emits_error(self, transcription, transcription_service):
|
||||
"""run() surfaces a friendly error if libraries fail to import."""
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
worker._import_libraries = MagicMock(side_effect=ImportError("no module"))
|
||||
|
||||
errors = []
|
||||
worker.error.connect(errors.append)
|
||||
|
||||
worker.run()
|
||||
|
||||
# The message is localized, but the underlying import error detail is
|
||||
# always appended verbatim.
|
||||
assert len(errors) == 1
|
||||
assert "no module" in errors[0]
|
||||
|
||||
def test_map_speakers_unsupported_language_skips_punctuation(self, transcription, transcription_service):
|
||||
"""For an unsupported language, punctuation restoration is skipped."""
|
||||
worker = IdentificationWorker(transcription, transcription_service)
|
||||
|
||||
# Wire up the helpers normally injected by _import_libraries.
|
||||
worker._get_words_speaker_mapping = MagicMock(return_value=[{"word": "hi"}])
|
||||
worker._punct_model_langs = ["en"]
|
||||
worker._PunctuationModel = MagicMock()
|
||||
worker._get_realigned_ws_mapping_with_punctuation = MagicMock(
|
||||
return_value=[{"word": "hi"}]
|
||||
)
|
||||
worker._get_sentences_speaker_mapping = MagicMock(return_value=["sentence"])
|
||||
|
||||
result = worker._map_speakers_with_punctuation(
|
||||
word_timestamps=[], speaker_ts=[], language="xx",
|
||||
)
|
||||
|
||||
assert result == ["sentence"]
|
||||
# Punctuation model must not be constructed for an unsupported language.
|
||||
worker._PunctuationModel.assert_not_called()
|
||||
worker._get_sentences_speaker_mapping.assert_called_once()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SpeakerIdentificationWidget unit tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_on_progress_update_invalid_format(self, qtbot: QtBot, transcription, transcription_service):
|
||||
"""A non-numeric progress string updates the label but not the bar."""
|
||||
widget = SpeakerIdentificationWidget(
|
||||
transcription=transcription,
|
||||
transcription_service=transcription_service,
|
||||
)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.progress_bar.setValue(3)
|
||||
widget.on_progress_update("Loading something")
|
||||
|
||||
assert widget.progress_label.text() == "Loading something"
|
||||
# Bar value is left untouched when the format is invalid
|
||||
assert widget.progress_bar.value() == 3
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_on_speaker_preview_plays_segment(self, qtbot: QtBot, transcription, transcription_service):
|
||||
"""on_speaker_preview seeks and plays for a matching speaker."""
|
||||
widget = SpeakerIdentificationWidget(
|
||||
transcription=transcription,
|
||||
transcription_service=transcription_service,
|
||||
)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.identification_result = [
|
||||
{'speaker': 'Speaker 0', 'start_time': 1500, 'end_time': 2000, 'text': 'Hi'},
|
||||
]
|
||||
|
||||
with patch.object(widget.player, 'setPosition') as mock_set, \
|
||||
patch.object(widget.player, 'play') as mock_play:
|
||||
widget.on_speaker_preview('Speaker 0')
|
||||
|
||||
mock_set.assert_called_once_with(1500)
|
||||
mock_play.assert_called_once()
|
||||
|
||||
assert widget.player_timer is not None
|
||||
widget.player_timer.stop()
|
||||
widget.close()
|
||||
|
||||
def test_on_speaker_preview_no_matching_records(self, qtbot: QtBot, transcription, transcription_service):
|
||||
"""on_speaker_preview does nothing when the speaker has no records."""
|
||||
widget = SpeakerIdentificationWidget(
|
||||
transcription=transcription,
|
||||
transcription_service=transcription_service,
|
||||
)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.identification_result = [
|
||||
{'speaker': 'Speaker 0', 'start_time': 0, 'end_time': 1000, 'text': 'Hi'},
|
||||
]
|
||||
|
||||
with patch.object(widget.player, 'play') as mock_play:
|
||||
widget.on_speaker_preview('Speaker 99')
|
||||
mock_play.assert_not_called()
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_show_model_selector(self, qtbot: QtBot, transcription, transcription_service):
|
||||
"""_show_model_selector swaps the progress bar back for the model selector."""
|
||||
widget = SpeakerIdentificationWidget(
|
||||
transcription=transcription,
|
||||
transcription_service=transcription_service,
|
||||
)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.progress_bar.setVisible(True)
|
||||
widget.model_selection_widget.setVisible(False)
|
||||
|
||||
widget._show_model_selector()
|
||||
|
||||
assert widget.progress_bar.isHidden()
|
||||
assert not widget.model_selection_widget.isHidden()
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_cleanup_thread_when_none(self, qtbot: QtBot, transcription, transcription_service):
|
||||
"""_cleanup_thread is safe when there is no active thread/worker."""
|
||||
widget = SpeakerIdentificationWidget(
|
||||
transcription=transcription,
|
||||
transcription_service=transcription_service,
|
||||
)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.thread = None
|
||||
widget.worker = None
|
||||
|
||||
# Should not raise
|
||||
widget._cleanup_thread()
|
||||
|
||||
assert widget.thread is None
|
||||
assert widget.worker is None
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_identify_uses_selected_diarizer(self, qtbot: QtBot, transcription, transcription_service):
|
||||
"""The Sortformer radio selects the sortformer diarizer for the worker."""
|
||||
widget = SpeakerIdentificationWidget(
|
||||
transcription=transcription,
|
||||
transcription_service=transcription_service,
|
||||
)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.sortformer_radio.setChecked(True)
|
||||
|
||||
from PyQt6.QtCore import QThread as RealQThread
|
||||
mock_thread = MagicMock(spec=RealQThread)
|
||||
mock_thread.started = MagicMock()
|
||||
|
||||
with patch.object(widget, '_cleanup_thread'), \
|
||||
patch('buzz.widgets.transcription_viewer.speaker_identification_widget.QThread', return_value=mock_thread), \
|
||||
patch.object(IdentificationWorker, 'moveToThread'):
|
||||
widget.on_identify_button_clicked()
|
||||
|
||||
assert widget.worker.diarizer == "sortformer"
|
||||
|
||||
widget.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -994,3 +994,328 @@ class TestTranscriptionViewerWidgetAdditional:
|
|||
assert widget.current_media_player == widget.video_player
|
||||
|
||||
widget.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Search navigation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_on_search_text_changed_debounces_long_text(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""Typing >= 2 chars starts the debounce timer and shows the search bar."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.on_search_text_changed("seg")
|
||||
|
||||
assert widget.search_text == "seg"
|
||||
assert widget.search_debounce_timer.isActive()
|
||||
# show() was requested on the frame (not hidden explicitly)
|
||||
assert not widget.search_frame.isHidden()
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_on_search_text_changed_empty_clears(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""Clearing the input stops the timer and resets search state."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.search_results = [("table", 0, None)]
|
||||
widget.on_search_text_changed("")
|
||||
|
||||
assert widget.search_text == ""
|
||||
assert not widget.search_debounce_timer.isActive()
|
||||
assert widget.search_results == []
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_search_next_wraps_around(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""search_next advances the index and wraps back to the start."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.search_results = [("table", 0, None), ("table", 1, None)]
|
||||
widget.current_search_index = 0
|
||||
|
||||
widget.search_next()
|
||||
assert widget.current_search_index == 1
|
||||
|
||||
# Wrap around back to 0
|
||||
widget.search_next()
|
||||
assert widget.current_search_index == 0
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_search_previous_wraps_around(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""search_previous decrements the index and wraps to the end."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.search_results = [("table", 0, None), ("table", 1, None)]
|
||||
widget.current_search_index = 0
|
||||
|
||||
widget.search_previous()
|
||||
assert widget.current_search_index == 1
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_search_next_if_results_noop_when_empty(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""The *_if_results helpers do nothing when there are no results."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.search_results = []
|
||||
widget.current_search_index = 0
|
||||
|
||||
widget.search_next_if_results()
|
||||
widget.search_previous_if_results()
|
||||
|
||||
assert widget.current_search_index == 0
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_clear_search_resets_state(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""clear_search empties results and disables navigation buttons."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.search_text = "segment"
|
||||
widget.search_results = [("table", 0, None)]
|
||||
widget.search_prev_button.setEnabled(True)
|
||||
widget.search_next_button.setEnabled(True)
|
||||
|
||||
widget.clear_search()
|
||||
|
||||
assert widget.search_text == ""
|
||||
assert widget.search_results == []
|
||||
assert not widget.search_prev_button.isEnabled()
|
||||
assert not widget.search_next_button.isEnabled()
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_hide_search_bar(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""hide_search_bar hides the frame and syncs the find button state."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
widget.show()
|
||||
|
||||
widget.show_search_bar()
|
||||
assert widget.search_frame.isVisible()
|
||||
|
||||
widget.hide_search_bar()
|
||||
|
||||
assert not widget.search_frame.isVisible()
|
||||
assert not widget.find_button.isChecked()
|
||||
assert widget.find_widget_visible is False
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_focus_search_input_toggles(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""focus_search_input shows the bar when hidden and hides it when shown."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
widget.show()
|
||||
|
||||
# Hidden -> shown
|
||||
widget.search_frame.hide()
|
||||
widget.focus_search_input()
|
||||
assert widget.search_frame.isVisible()
|
||||
assert widget.find_widget_visible is True
|
||||
|
||||
# Shown -> hidden
|
||||
widget.focus_search_input()
|
||||
assert not widget.search_frame.isVisible()
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_search_100_plus_matches_label(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""update_search_ui shows a "100+" label when the cap is hit."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.search_results = [("table", i, None) for i in range(100)]
|
||||
widget.update_search_ui()
|
||||
|
||||
assert "100+" in widget.search_results_label.text()
|
||||
|
||||
widget.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# View mode / segment selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_on_view_mode_changed(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""on_view_mode_changed updates the mode and refreshes the view."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
widget.show()
|
||||
|
||||
widget.on_view_mode_changed(ViewMode.TEXT)
|
||||
|
||||
assert widget.view_mode == ViewMode.TEXT
|
||||
assert widget.text_display_box.isVisible()
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_on_segment_selected(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""on_segment_selected stores the segment and shows its text."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
widget.show()
|
||||
|
||||
record = widget.table_widget.model().record(0)
|
||||
widget.on_segment_selected(record)
|
||||
|
||||
# The highlight path may re-emit selection with a fresh QSqlRecord, so
|
||||
# compare by id value rather than object identity.
|
||||
assert widget.currently_selected_segment.value("id") == record.value("id")
|
||||
assert not widget.current_segment_frame.isHidden()
|
||||
assert widget.current_segment_text.text() == record.value("text")
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_on_audio_player_position_ms_changed_updates_text(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""Position changes update the current-segment text to the active segment."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
widget.show()
|
||||
|
||||
# Position 600ms falls inside the second segment (500-1000, "Second segment")
|
||||
widget.on_audio_player_position_ms_changed(600)
|
||||
|
||||
assert widget.current_segment_text.text() == "Second segment"
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_on_audio_player_position_ms_changed_no_segment(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""A position outside all segments is a no-op (returns early)."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
before = widget.current_segment_text.text()
|
||||
widget.on_audio_player_position_ms_changed(999999)
|
||||
|
||||
assert widget.current_segment_text.text() == before
|
||||
|
||||
widget.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Loop toggle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_on_loop_toggle_changed_enable_with_selection(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""Enabling looping with a selected segment sets a loop range."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.currently_selected_segment = widget.table_widget.model().record(0)
|
||||
|
||||
with patch.object(widget.current_media_player, 'set_range') as mock_range:
|
||||
widget.on_loop_toggle_changed(True)
|
||||
|
||||
assert widget.segment_looping_enabled is True
|
||||
assert mock_range.called
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_on_loop_toggle_changed_disable_clears_range(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""Disabling looping clears any existing loop range."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
with patch.object(widget.current_media_player, 'clear_range') as mock_clear:
|
||||
widget.on_loop_toggle_changed(False)
|
||||
|
||||
assert widget.segment_looping_enabled is False
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
widget.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dialog launchers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_on_resize_button_clicked(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""The resize button constructs and shows the resizer dialog."""
|
||||
updated_signal = MagicMock()
|
||||
widget = TranscriptionViewerWidget(
|
||||
transcription, transcription_service, shortcuts,
|
||||
transcriptions_updated_signal=updated_signal,
|
||||
)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
with patch(
|
||||
'buzz.widgets.transcription_viewer.transcription_viewer_widget.TranscriptionResizerWidget'
|
||||
) as mock_resizer:
|
||||
widget.on_resize_button_clicked()
|
||||
|
||||
mock_resizer.assert_called_once()
|
||||
widget.transcription_resizer_dialog.show.assert_called_once()
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_on_speaker_identification_button_clicked(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""The identify-speakers button constructs and shows the dialog."""
|
||||
import platform as _platform
|
||||
if _platform.system() == "Darwin" and _platform.machine() == "x86_64":
|
||||
pytest.skip("Speaker identification unavailable on Intel Mac")
|
||||
|
||||
updated_signal = MagicMock()
|
||||
widget = TranscriptionViewerWidget(
|
||||
transcription, transcription_service, shortcuts,
|
||||
transcriptions_updated_signal=updated_signal,
|
||||
)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
with patch(
|
||||
'buzz.widgets.transcription_viewer.transcription_viewer_widget.SpeakerIdentificationWidget'
|
||||
) as mock_dialog:
|
||||
widget.on_speaker_identification_button_clicked()
|
||||
|
||||
mock_dialog.assert_called_once()
|
||||
widget.speaker_identification_dialog.show.assert_called_once()
|
||||
|
||||
widget.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Splitter sizes persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_save_and_load_splitter_sizes(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""Splitter sizes round-trip through settings without error."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.media_splitter.setSizes([700, 300])
|
||||
widget.save_splitter_sizes()
|
||||
widget.load_splitter_sizes()
|
||||
|
||||
# Sizes are applied back onto the splitter
|
||||
assert sum(widget.media_splitter.sizes()) > 0
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_on_splitter_moved_schedules_save(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""Moving the splitter schedules a deferred save."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
with patch(
|
||||
'buzz.widgets.transcription_viewer.transcription_viewer_widget.QTimer.singleShot'
|
||||
) as mock_single_shot:
|
||||
widget.on_splitter_moved(100, 0)
|
||||
mock_single_shot.assert_called_once()
|
||||
|
||||
widget.close()
|
||||
|
||||
def test_close_event_closes_child_dialogs(self, qtbot: QtBot, transcription, transcription_service, shortcuts):
|
||||
"""closeEvent closes any open child dialogs."""
|
||||
widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts)
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
widget.transcription_resizer_dialog = MagicMock()
|
||||
widget.speaker_identification_dialog = MagicMock()
|
||||
|
||||
from PyQt6.QtGui import QCloseEvent
|
||||
widget.closeEvent(QCloseEvent())
|
||||
|
||||
widget.transcription_resizer_dialog.close.assert_called_once()
|
||||
widget.speaker_identification_dialog.close.assert_called_once()
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -314,6 +314,7 @@ dependencies = [
|
|||
{ name = "platformdirs" },
|
||||
{ name = "polib" },
|
||||
{ name = "posthog" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyqt6" },
|
||||
{ name = "pyqt6-qt6" },
|
||||
{ name = "pyqt6-sip" },
|
||||
|
|
@ -408,6 +409,7 @@ requires-dist = [
|
|||
{ name = "platformdirs", specifier = ">=4.2.1,<5" },
|
||||
{ name = "polib", specifier = ">=1.2.0,<2" },
|
||||
{ name = "posthog", specifier = ">=3.23.0,<4" },
|
||||
{ name = "psutil", specifier = ">=5.9.0,<8" },
|
||||
{ name = "pyqt6", specifier = "==6.9.1" },
|
||||
{ name = "pyqt6-qt6", specifier = "==6.9.1" },
|
||||
{ name = "pyqt6-sip", specifier = "==13.10.2" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue