From 7a507c52eb24f21efce0bfd0536274e1e5865900 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 2 Aug 2026 09:29:12 +0300 Subject: [PATCH] Make the audio file tab remember, and its Stop stop The two switches were written to disk by the Save button at the far end of the window, so a file transcribed with timestamps and cleanup was transcribed without either the next time. They belong to the run rather than to the form: they go to disk as they are ticked now, and the folder the last file came from goes with them. Stop only set a flag that was looked at between chunks, and a file under ten minutes is one chunk, so for most files it was looked at after the work it was meant to stop had already finished. Nothing that blocks is reached by a flag. The request is inside urlopen, ffmpeg is inside communicate, and a whisper on this machine is a process of ours that would grind on to the end of the chunk with nobody left to hand the answer to. So the socket is shut down under the read, ffmpeg is killed, and a local server is stopped and left for the next run to start again. Shutting the socket down rather than closing it is the point: close() alone leaves a thread already inside recv() waiting for bytes that are never coming now. The connection is registered before it has a socket, so a stop landing in the few lines between making a connection and blocking on it refuses the connection rather than missing it and letting urllib quietly open another. --- api.py | 169 ++++++++++++++++++++++++++++++++--- cleanup.py | 15 ++-- filetranscribe.py | 64 +++++++++---- settings_ui.py | 27 +++++- tests/test_api.py | 122 +++++++++++++++++++++++++ tests/test_filetranscribe.py | 37 +++++++- tests/test_ui.py | 37 ++++++++ 7 files changed, 438 insertions(+), 33 deletions(-) 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):