diff --git a/api.py b/api.py index cc27e66..1f35e60 100644 --- a/api.py +++ b/api.py @@ -11,10 +11,14 @@ is up, which is the one thing this module has to fill in for it. """ import collections +import contextlib +import http.client import json import mimetypes import os import secrets +import socket +import threading import urllib.error import urllib.request @@ -59,6 +63,142 @@ class ApiError(Exception): self.status = status +class Aborted(Exception): + """A request that was cut off from another thread rather than answered.""" + + +class Aborter: + """A Stop button that reaches the call a worker thread is blocked inside. + + urlopen() hands nothing back until the server has answered, and a whisper on + this machine is minutes away from answering, so a flag read between calls is + a Stop that does nothing until the work it was meant to stop is already + done. What is registered here is cut off where it stands instead. + """ + + def __init__(self): + self._lock = threading.Lock() + self._cancels = [] + self.aborted = False + + def abort(self): + with self._lock: + self.aborted = True + pending, self._cancels = self._cancels, [] + for cancel in pending: + cancel() + + def check(self): + if self.aborted: + raise Aborted + + @contextlib.contextmanager + def holding(self, cancel): + """Run `cancel` if an abort lands while this block is open.""" + with self._lock: + if self.aborted: + raise Aborted + self._cancels.append(cancel) + try: + yield + finally: + with self._lock: + with contextlib.suppress(ValueError): + self._cancels.remove(cancel) + + +class _Sockets: + """The connections one request is using, and whether it may still use any. + + A stop can land at any point of the handful of lines urllib takes to get + from "make a connection" to "wait for the reply", so this keeps the two + halves of the answer together: what is already open is cut, and anything + opened after that is refused rather than quietly left to block. + """ + + def __init__(self): + self._lock = threading.Lock() + self._conns = [] + self._cut = False + + def add(self, conn): + with self._lock: + if self._cut: + raise Aborted + self._conns.append(conn) + + def cut(self): + with self._lock: + self._cut = True + conns = list(self._conns) + for conn in conns: + _stop_using(conn) + + +def _stop_using(conn): + """Take a connection out of use, connected or not. + + A connection whose socket is not open yet would open one on the next line, + so the reconnect is turned off first. One that is open is being read from, + and close() alone leaves that read waiting for bytes which are never coming + now; the shutdown is what makes it return. + """ + conn.auto_open = 0 + sock = getattr(conn, "sock", None) + if sock is not None: + with contextlib.suppress(OSError): + sock.shutdown(socket.SHUT_RDWR) + with contextlib.suppress(OSError): + conn.close() + + +class _TrackedHTTP(urllib.request.HTTPHandler): + """urllib's own handler, handing the connection it opens to `sockets`. + + That connection is what a Stop is applied to, and urlopen() makes it out of + sight, inside the call that is about to block on it. + """ + + def __init__(self, sockets): + super().__init__() + self._sockets = sockets + + def http_open(self, req): + return self.do_open(self._connect, req) + + def _connect(self, host, **kwargs): + conn = http.client.HTTPConnection(host, **kwargs) + self._sockets.add(conn) + return conn + + +class _TrackedHTTPS(urllib.request.HTTPSHandler): + def __init__(self, sockets): + super().__init__() + self._sockets = sockets + + def https_open(self, req): + return self.do_open(self._connect, req, context=self._context) + + def _connect(self, host, **kwargs): + conn = http.client.HTTPSConnection(host, **kwargs) + self._sockets.add(conn) + return conn + + +@contextlib.contextmanager +def _opened(req, timeout, aborter): + """The response, left where `aborter` can cut it off.""" + if aborter is None: + with urllib.request.urlopen(req, timeout=timeout) as resp: + yield resp + return + sockets = _Sockets() + opener = urllib.request.build_opener(_TrackedHTTP(sockets), _TrackedHTTPS(sockets)) + with aborter.holding(sockets.cut), opener.open(req, timeout=timeout) as resp: + yield resp + + def explain(exc, service): """Turn an HTTP status into something the user can act on.""" if exc.status in (401, 403): @@ -74,16 +214,21 @@ def explain(exc, service): return ApiError(f"{service}: {exc}", exc.status) -def _request(url, data, headers, timeout=120): +def _request(url, data, headers, timeout=120, aborter=None): req = urllib.request.Request(url, data=data, headers=headers, method="POST") try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _opened(req, timeout, aborter) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", "replace") raise ApiError(f"HTTP {exc.code}: {_extract_error(body)}", exc.code) from exc - except urllib.error.URLError as exc: - raise ApiError(t("Could not connect: {reason}", reason=exc.reason)) from exc + except (OSError, http.client.HTTPException) as exc: + # A socket that went out from under the read is this run being stopped, + # not the network failing. URLError is an OSError, so both land here. + if aborter is not None and aborter.aborted: + raise Aborted from None + raise ApiError(t("Could not connect: {reason}", + reason=getattr(exc, "reason", exc))) from exc except json.JSONDecodeError as exc: raise ApiError(t("Could not parse the response: {error}", error=exc)) from exc @@ -165,7 +310,7 @@ def local_failure(service, server, exc): def _transcribe_request(target, wav_path, language, prompt, response_format, - granularity=None, timeout=300): + granularity=None, timeout=300, aborter=None): if target.provider == "local": # The timeouts here are sized for a hosted API, where a slow answer is a # bill running. Locally the only thing being spent is time. @@ -189,6 +334,7 @@ def _transcribe_request(target, wav_path, language, prompt, response_format, return _request( f"{target.base_url.rstrip('/')}/audio/transcriptions", body, _headers(target.provider, target.api_key, ctype), timeout=timeout, + aborter=aborter, ) except ApiError as exc: if target.provider == "local": @@ -235,9 +381,9 @@ def _merge_word_splits(segments): return merged -def transcribe(target, wav_path, language="", prompt="", timeout=300): +def transcribe(target, wav_path, language="", prompt="", timeout=300, aborter=None): data = _transcribe_request( - target, wav_path, language, prompt, "json", timeout=timeout + target, wav_path, language, prompt, "json", timeout=timeout, aborter=aborter ) text = data.get("text") or "" if target.provider == "local": @@ -248,12 +394,13 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300): return text -def transcribe_segments(target, wav_path, language="", prompt="", timeout=300): +def transcribe_segments(target, wav_path, language="", prompt="", timeout=300, + aborter=None): """[(start_seconds, end_seconds, text)] using whisper-1's verbose response.""" data = _transcribe_request( target._replace(model=timestamp_model(target.provider, target.model)), wav_path, language, prompt, "verbose_json", - granularity="segment", timeout=timeout, + granularity="segment", timeout=timeout, aborter=aborter, ) segments = data.get("segments") or [] if target.provider == "local": @@ -311,7 +458,7 @@ def local_ceiling(text): def cleanup(text, api_key, model, system_prompt, reasoning="", base_url=OPENROUTER_URL, timeout=180, provider="openrouter", - service="OpenRouter"): + service="OpenRouter", aborter=None): if not api_key and provider != "local-llm": raise ApiError(t("{service} API key is empty. Add it in Settings.", service=service)) @@ -331,7 +478,7 @@ def cleanup(text, api_key, model, system_prompt, reasoning="", f"{base_url.rstrip('/')}/chat/completions", json.dumps(payload).encode("utf-8"), _headers(provider, api_key, "application/json"), - timeout=timeout, + timeout=timeout, aborter=aborter, ) except ApiError as exc: raise explain(exc, service) from None diff --git a/cleanup.py b/cleanup.py index 0dbb273..b6485b3 100644 --- a/cleanup.py +++ b/cleanup.py @@ -59,22 +59,27 @@ def model(conf): return conf["cleanup_model"] -def run(text, conf, system_prompt, timeout=180): - """Hand the transcript to whoever is set to clean it up.""" +def run(text, conf, system_prompt, timeout=180, aborter=None): + """Hand the transcript to whoever is set to clean it up. + + `aborter` is only of use to the two that answer over HTTP; a CLI is stopped + between blocks instead, which is close enough when a block is seconds. + """ name = provider(conf) if name == "openrouter": return api.cleanup( text, conf.openrouter_key(), conf["cleanup_model"], system_prompt, reasoning=conf["cleanup_reasoning"], base_url=conf["openrouter_base_url"], timeout=timeout, + aborter=aborter, ) if name == "local": - return _local(text, conf, system_prompt, timeout) + return _local(text, conf, system_prompt, timeout, aborter) runner = _claude if name == "claude" else _codex return runner(text, conf, system_prompt, timeout) -def _local(text, conf, system_prompt, timeout): +def _local(text, conf, system_prompt, timeout, aborter=None): """llama.cpp, on this machine, answering the request OpenRouter answers. No key and no bill, and the address does not exist until the server is up, @@ -88,7 +93,7 @@ def _local(text, conf, system_prompt, timeout): reasoning=conf["local_llm_reasoning"], base_url=api.serving(ggml.llm), timeout=max(timeout, api.LOCAL_TIMEOUT), - provider="local-llm", service=service, + provider="local-llm", service=service, aborter=aborter, ) except api.ApiError as exc: # A server that died mid-request would otherwise report only that the diff --git a/filetranscribe.py b/filetranscribe.py index f797d58..b336e3a 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -18,6 +18,7 @@ from PyQt6.QtCore import QObject, pyqtSignal import api import cleanup +import ggml from i18n import t CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16 @@ -29,8 +30,9 @@ MIN_SUBTITLE_SECONDS = 1.5 # how long a cue with no end time of its own stays STAMP_RE = re.compile(r"^\[(?:(\d+):)?(\d{1,2}):(\d{2})\]\s*") -class Cancelled(Exception): - pass +# What a stopped run comes back with, wherever it was stopped: the request that +# was cut off raises it from api, and the steps in between raise it themselves. +Cancelled = api.Aborted class FileTranscriber(QObject): @@ -42,7 +44,9 @@ class FileTranscriber(QObject): super().__init__(parent) self.conf = conf self._thread = None - self._stop = threading.Event() + self._abort = api.Aborter() + # The server on this machine the work is with, when it is with one. + self._local = None @property def busy(self): @@ -51,18 +55,26 @@ class FileTranscriber(QObject): def start(self, path, timestamps, do_cleanup): if self.busy: return - self._stop.clear() + self._abort = api.Aborter() # the last one is spent self._thread = threading.Thread( target=self._work, args=(path, timestamps, do_cleanup), daemon=True ) self._thread.start() def stop(self): - self._stop.set() + """Cut the run off where it stands, rather than at the next step.""" + self._abort.abort() + # Closing the socket is nothing to a server on this machine: it is a + # process of ours, and it would grind on to the end of the chunk with + # nobody left to hand the answer to. Stopping it is what stops the + # work; the next run starts it again. Killing waits on the process, so + # not on the thread the window is drawn from. + local = self._local + if local is not None: + threading.Thread(target=local.stop, daemon=True).start() def _check(self): - if self._stop.is_set(): - raise Cancelled + self._abort.check() def _work(self, path, timestamps, do_cleanup): conf = self.conf @@ -73,7 +85,7 @@ class FileTranscriber(QObject): workdir = tempfile.mkdtemp(prefix="dikte-file-") self.progress.emit(t("Converting audio…")) - wav_path = _to_wav(path, workdir) + wav_path = _to_wav(path, workdir, self._abort) self._check() chunks = split_wav(wav_path, workdir) @@ -81,6 +93,7 @@ class FileTranscriber(QObject): self.progress.emit(t("Splitting into {count} chunks…", count=len(chunks))) target = conf.transcribe_target() + self._local = ggml.whisper if target.provider == "local" else None pieces = [] segments = [] for index, (chunk_path, offset) in enumerate(chunks, start=1): @@ -96,6 +109,7 @@ class FileTranscriber(QObject): chunk_path, language=conf["language"], prompt=conf["transcribe_prompt"], + aborter=self._abort, ) ) pieces = [f"[{format_timestamp(start)}] {line}" @@ -106,6 +120,7 @@ class FileTranscriber(QObject): chunk_path, language=conf["language"], prompt=conf["transcribe_prompt"], + aborter=self._abort, )) text = "\n".join(pieces) if timestamps else " ".join(pieces) @@ -122,16 +137,18 @@ class FileTranscriber(QObject): except (api.ApiError, OSError, subprocess.SubprocessError, wave.Error) as exc: self.failed.emit(str(exc)) finally: + self._local = None if workdir: shutil.rmtree(workdir, ignore_errors=True) def _cleanup(self, text, timestamps): conf = self.conf + self._local = ggml.llm if cleanup.provider(conf) == "local" else None prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True) out = [] for block in split_text(text, timestamps): self._check() - out.append(cleanup.run(block, conf, prompt)) + out.append(cleanup.run(block, conf, prompt, aborter=self._abort)) return ("\n" if timestamps else "\n\n").join(out) @@ -194,17 +211,34 @@ def to_srt(text, segments): return "\n\n".join(blocks) + "\n" if blocks else "" -def _to_wav(path, workdir): +def _reap(proc): + """Leave nothing running behind a conversion that did not finish.""" + if proc.poll() is None: + proc.kill() + proc.wait() + + +def _to_wav(path, workdir, aborter=None): out = os.path.join(workdir, "audio.wav") - res = subprocess.run( + proc = subprocess.Popen( ["ffmpeg", "-nostdin", "-y", "-i", path, "-vn", "-ac", "1", "-ar", str(RATE), "-c:a", "pcm_s16le", out], - capture_output=True, text=True, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, ) - if res.returncode != 0 or not os.path.exists(out): - tail = (res.stderr or "").strip().splitlines() + # A two hour film is a minute of ffmpeg, which is a minute of a Stop button + # doing nothing unless the abort reaches the process itself. + with contextlib.ExitStack() as stack: + stack.callback(_reap, proc) + if aborter is not None: + stack.enter_context(aborter.holding(proc.kill)) + _stdout, stderr = proc.communicate() + if aborter is not None: + aborter.check() + if proc.returncode != 0 or not os.path.exists(out): + tail = (stderr or "").strip().splitlines() raise api.ApiError(t("Could not read the file: {error}", - error=tail[-1] if tail else res.returncode)) + error=tail[-1] if tail else proc.returncode)) return out diff --git a/settings_ui.py b/settings_ui.py index 239a049..c2ed2e6 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -512,6 +512,10 @@ class SettingsWindow(QDialog): self.meetings.finished.connect(self._on_minutes_finished) self.meetings.failed.connect(self._on_minutes_failed) self._load() + # Connected after the load, so that filling the boxes in is not taken + # for the user ticking them. + self.file_timestamps.toggled.connect(self._remember_file_choices) + self.file_cleanup.toggled.connect(self._remember_file_choices) # On a machine where nothing can transcribe yet, this window was opened # because of that, so open it on the tab that fixes it. if not conf.transcribe_ready(): @@ -1159,7 +1163,7 @@ class SettingsWindow(QDialog): self.file_run = QPushButton(t("Transcribe")) self.file_run.clicked.connect(self._run_file) self.file_stop = QPushButton(t("Stop")) - self.file_stop.clicked.connect(self.transcriber.stop) + self.file_stop.clicked.connect(self._stop_file) self.file_stop.setEnabled(False) run_row = QHBoxLayout() run_row.addWidget(self.file_run) @@ -1713,6 +1717,20 @@ class SettingsWindow(QDialog): self.file_path = path self.file_label.setText(os.path.basename(path)) self.conf["file_last_dir"] = os.path.dirname(path) + self._remember_file_choices() + + def _remember_file_choices(self): + """Keep this tab's choices without waiting for the Save button. + + The two switches and the folder belong to the run rather than to the + form: what was ticked before Transcribe is what the next file wants + too, and Save is at the far end of a window opened to transcribe one + file. Everything else on the tab is a button, so there is nothing here + an unsaved form could be caught by. + """ + self.conf["file_timestamps"] = self.file_timestamps.isChecked() + self.conf["file_cleanup"] = self.file_cleanup.isChecked() + self.conf.save() def _run_file(self): if not getattr(self, "file_path", "") or self.transcriber.busy: @@ -1728,6 +1746,13 @@ class SettingsWindow(QDialog): self.file_cleanup.isChecked(), ) + def _stop_file(self): + # The button goes dead here rather than when the run comes back, so a + # second press cannot land while the first one is still travelling. + self.file_stop.setEnabled(False) + self.file_status.setText(t("Stopping…")) + self.transcriber.stop() + def _on_file_progress(self, message): self.file_status.setText(message) if message == t("Stopped."): diff --git a/tests/test_api.py b/tests/test_api.py index 6019061..f45ca21 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,10 +3,17 @@ Nothing here reaches the network. What is checked is the request that would have gone out, because that is what a new provider changes and what an old one notices: the URL, the headers, the fields of the multipart body, the JSON. + +Stopping one is the exception. Cutting a request off is done to the socket it +is blocked on, and a faked urlopen has no socket to cut, so those tests talk to +a server of their own on the loopback interface. """ +import http.server import json import os +import threading +import time import unittest import api @@ -578,3 +585,118 @@ class TranscribeHere(DikteTest): with fake_urlopen({"segments": [{"start": 0, "end": 1, "text": " hi"}]}) as calls: api.transcribe_segments(LOCAL, self.wav) self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin") + + +class Stopping(unittest.TestCase): + """The Stop button, from the far end: a request already blocked on a reply. + + The one that matters is a whisper on this machine, which answers minutes + after it was asked, so it is a real socket here rather than a fake urlopen. + Nothing leaves the loopback interface. + """ + + def setUp(self): + answering = threading.Event() + + class Slow(http.server.BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length") or 0)) + answering.set() + time.sleep(30) # the model, thinking + + def log_message(self, *args): + pass + + self.answering = answering + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Slow) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + self.url = f"http://127.0.0.1:{self.server.server_address[1]}/v1/x" + + def post(self, aborter, out): + try: + api._request(self.url, b"{}", {}, timeout=30, aborter=aborter) + out.append("answered") + except BaseException as exc: # noqa: BLE001 - the type is the result + out.append(type(exc).__name__) + + def test_a_request_waiting_on_a_reply_is_cut_off(self): + aborter, out = api.Aborter(), [] + thread = threading.Thread(target=self.post, args=(aborter, out)) + thread.start() + self.assertTrue(self.answering.wait(10)) + aborter.abort() + thread.join(timeout=10) + self.assertFalse(thread.is_alive()) + self.assertEqual(out, ["Aborted"]) + + def test_a_request_that_starts_after_the_stop_never_goes_out(self): + aborter, out = api.Aborter(), [] + aborter.abort() + self.post(aborter, out) + self.assertEqual(out, ["Aborted"]) + self.assertFalse(self.answering.is_set()) + + def test_without_one_the_request_is_the_plain_urllib_one(self): + """Everything that is not stoppable keeps the opener it always had.""" + with fake_urlopen({"text": "hi"}) as calls: + api._request(self.url, b"{}", {}) + self.assertEqual(len(calls), 1) + + +class Sockets(unittest.TestCase): + """The few lines urllib takes between making a connection and blocking on + it. A stop that lands in there must not leave the request waiting out its + hour-long local timeout.""" + + class FakeConn: + auto_open = 1 + sock = None + closed = False + + def close(self): + self.closed = True + + def test_a_connection_opened_after_the_stop_is_refused(self): + sockets = api._Sockets() + sockets.cut() + with self.assertRaises(api.Aborted): + sockets.add(self.FakeConn()) + + def test_one_that_is_already_open_is_closed_where_it_stands(self): + sockets, conn = api._Sockets(), self.FakeConn() + sockets.add(conn) + sockets.cut() + self.assertTrue(conn.closed) + + def test_one_with_no_socket_yet_is_stopped_from_making_another(self): + """close() leaves auto_open on, and the next line would reconnect.""" + sockets, conn = api._Sockets(), self.FakeConn() + sockets.add(conn) + sockets.cut() + self.assertEqual(conn.auto_open, 0) + + +class Aborter(unittest.TestCase): + def test_what_was_registered_is_run_once_the_stop_lands(self): + aborter, cut = api.Aborter(), [] + with aborter.holding(lambda: cut.append(True)): + aborter.abort() + self.assertEqual(cut, [True]) + + def test_a_block_that_ended_is_not_cut_afterwards(self): + aborter, cut = api.Aborter(), [] + with aborter.holding(lambda: cut.append(True)): + pass + aborter.abort() + self.assertEqual(cut, []) + + def test_a_stop_that_already_landed_stops_the_next_step_too(self): + aborter = api.Aborter() + aborter.abort() + with self.assertRaises(api.Aborted): + aborter.check() + with self.assertRaises(api.Aborted): + with aborter.holding(lambda: None): + pass diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py index def4f5c..b45a385 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -7,6 +7,7 @@ made up a stamp nobody recorded. """ import contextlib +import time import unittest import wave from unittest import mock @@ -170,7 +171,7 @@ class Transcriber(DikteTest): worker.failed.connect(failures.append) worker.progress.connect(progress.append) - def to_wav(path, workdir): + def to_wav(path, workdir, aborter=None): return make_wav(self.path("converted.wav"), tone(1.0)) with mock.patch.object(ft, "_to_wav", side_effect=to_wav), \ @@ -225,6 +226,40 @@ class Transcriber(DikteTest): _, _, _, cleanup_call = self.run_chain(cleanup=True, transcript="") cleanup_call.assert_not_called() + def test_a_stopped_run_is_not_a_failure(self): + def stopped(*args, **kwargs): + raise api.Aborted + done, failures, progress, _ = self.run_chain(fail=stopped) + self.assertEqual(failures, []) + self.assertEqual(done, []) + self.assertEqual(progress[-1], "Stopped.") + + def test_the_request_is_handed_the_stop_to_watch(self): + worker = ft.FileTranscriber(self.conf) + with mock.patch.object(ft, "_to_wav", side_effect=lambda *a: self.source), \ + mock.patch.object(ft.shutil, "which", return_value="/usr/bin/ffmpeg"), \ + mock.patch.object(api, "transcribe", return_value="text") as call: + worker._work(self.source, False, False) + self.assertIs(call.call_args.kwargs["aborter"], worker._abort) + + def test_stopping_a_local_run_stops_the_model_with_it(self): + """Closing the socket is nothing to a process of ours: it would grind on + to the end of the chunk with nobody left to hand the answer to.""" + worker = ft.FileTranscriber(self.conf) + worker._local = mock.Mock() + worker.stop() + self.assertTrue(worker._abort.aborted) + for _ in range(100): + if worker._local.stop.called: + break + time.sleep(0.01) + worker._local.stop.assert_called_once_with() + + def test_a_run_that_is_over_leaves_the_model_alone(self): + worker = ft.FileTranscriber(self.conf) + worker.stop() + self.assertTrue(worker._abort.aborted) + def test_a_second_start_while_one_is_running_is_ignored(self): worker = ft.FileTranscriber(self.conf) worker._thread = mock.Mock(is_alive=lambda: True) diff --git a/tests/test_ui.py b/tests/test_ui.py index 3ba334c..10554f2 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -266,6 +266,43 @@ class Settings(DikteTest): window = self.window(cfg.Config()) self.assertEqual(window.windowTitle(), "Dikte Ayarları") + def test_the_audio_file_switches_are_kept_without_the_save_button(self): + """They are ticked to transcribe one file, not to fill in a form.""" + self.write_config({"file_timestamps": False, "file_cleanup": True}) + window = self.window(cfg.Config()) + window.file_timestamps.setChecked(True) + window.file_cleanup.setChecked(False) + stored = self.read_config_file() + self.assertTrue(stored["file_timestamps"]) + self.assertFalse(stored["file_cleanup"]) + + def test_loading_the_audio_file_tab_is_not_taken_for_a_change(self): + self.write_config({"file_timestamps": True, "file_cleanup": False}) + conf = cfg.Config() + with mock.patch.object(conf, "save") as save: + window = self.window(conf) + save.assert_not_called() + self.assertTrue(window.file_timestamps.isChecked()) + self.assertFalse(window.file_cleanup.isChecked()) + + def test_the_run_button_comes_back_when_the_stop_lands(self): + """In whichever language, since the worker says so through t() too.""" + for language in ("auto", "tr"): + with self.subTest(language=language): + self.write_config({"ui_language": language}) + window = self.window(cfg.Config()) + window.file_run.setEnabled(False) + window._on_file_progress(settings_ui.t("Stopped.")) + self.assertTrue(window.file_run.isEnabled()) + + def test_stop_leaves_nothing_to_press_twice(self): + window = self.window(cfg.Config()) + with mock.patch.object(window.transcriber, "stop") as stop: + window.file_stop.setEnabled(True) + window._stop_file() + stop.assert_called_once_with() + self.assertFalse(window.file_stop.isEnabled()) + class Overlay(DikteTest): def overlay(self, **kwargs):