mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge master into the transcript queue branch
This commit is contained in:
@@ -17,6 +17,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
_SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
|
||||
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
|
||||
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data")
|
||||
os.environ["XDG_CACHE_HOME"] = os.path.join(_SANDBOX, "cache")
|
||||
# Home goes with them: the shortcut file, the applications directory and every
|
||||
# macOS path start from it rather than from an XDG variable, and a test run is
|
||||
# not allowed to touch the real one.
|
||||
|
||||
+195
-19
@@ -10,21 +10,28 @@ import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from dikte import assistant
|
||||
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
||||
from tests.support import (DikteTest, FakeCompleted, fake_urlopen,
|
||||
only_these_tools)
|
||||
|
||||
|
||||
class FakeCli:
|
||||
"""A CLI that prints the given events and exits."""
|
||||
"""A CLI that prints the given events and exits.
|
||||
|
||||
def __init__(self, events=(), code=0, stderr="", noise=()):
|
||||
Its stderr is not modelled: _stream hands the process a temporary file for
|
||||
that, and a mocked Popen leaves the file empty, which is what a quiet CLI
|
||||
writes anyway.
|
||||
"""
|
||||
|
||||
def __init__(self, events=(), code=0, noise=()):
|
||||
lines = list(noise) + [json.dumps(event) for event in events]
|
||||
self.stdout = io.StringIO("\n".join(lines) + "\n")
|
||||
self.stderr = io.StringIO(stderr)
|
||||
self.returncode = code
|
||||
self.killed = False
|
||||
|
||||
@@ -41,6 +48,38 @@ class FakeCli:
|
||||
self.killed = True
|
||||
|
||||
|
||||
class WedgedCli:
|
||||
"""A CLI whose stdout produces nothing, the way a hung process's does.
|
||||
|
||||
Iterating its stdout blocks until the kill arrives, because that is what
|
||||
reading a silent pipe does: only the process ending closes the stream.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.pid = 4242
|
||||
self.returncode = None
|
||||
self.released = threading.Event()
|
||||
self.stdout = self
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
# The 5 second cap is a safety net for the test itself; the kill is
|
||||
# what is supposed to end the wait.
|
||||
self.released.wait(timeout=5)
|
||||
raise StopIteration
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return self.returncode
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Provider(DikteTest):
|
||||
def test_the_default(self):
|
||||
self.assertEqual(assistant.provider(self.config()), "claude")
|
||||
@@ -221,19 +260,7 @@ class Denials(DikteTest):
|
||||
self.assertIn("Write", warning)
|
||||
|
||||
|
||||
class SessionMissing(unittest.TestCase):
|
||||
def test_a_session_that_is_gone(self):
|
||||
for text in ("Error: session abc not found",
|
||||
"No conversation with that id",
|
||||
"unknown thread: abc"):
|
||||
with self.subTest(text=text):
|
||||
self.assertTrue(assistant._session_missing(text))
|
||||
|
||||
def test_an_unrelated_failure(self):
|
||||
for text in ("", "network unreachable", "session limit exceeded"):
|
||||
with self.subTest(text=text):
|
||||
self.assertFalse(assistant._session_missing(text))
|
||||
|
||||
class LastLine(unittest.TestCase):
|
||||
def test_the_last_line_is_the_one_worth_showing(self):
|
||||
self.assertEqual(assistant.last_line("warning\n\nreal error\n"),
|
||||
"real error")
|
||||
@@ -269,6 +296,28 @@ class Conclude(DikteTest):
|
||||
assistant._conclude(self.found(), 1, "session abc not found",
|
||||
"abc", "Claude")
|
||||
|
||||
def test_the_recovery_no_longer_hangs_on_the_words_the_cli_chose(self):
|
||||
# The complaint used to be matched by substring, which a CLI update or
|
||||
# another language broke. A resumed run that died with nothing to show
|
||||
# is now enough on its own.
|
||||
for stderr in ("Oturum bulunamadı", "something else entirely", ""):
|
||||
with self.subTest(stderr=stderr):
|
||||
with self.assertRaises(assistant._SessionGone):
|
||||
assistant._conclude(self.found(), 1, stderr, "abc", "Claude")
|
||||
|
||||
def test_api_trouble_on_a_resumed_run_is_not_blamed_on_the_session(self):
|
||||
# A fresh session cannot cure a spent quota, a signed-out CLI or a dead
|
||||
# network: the retry would fail the same way after a second wait, and
|
||||
# the user would lose the conversation thread on top.
|
||||
for stderr in ("Rate limit exceeded",
|
||||
"You are not logged in. Please run /login.",
|
||||
"API Error: 401 Unauthorized",
|
||||
"fetch failed: ECONNREFUSED 127.0.0.1"):
|
||||
with self.subTest(stderr=stderr):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 1, stderr, "abc", "Claude")
|
||||
self.assertIn(stderr, str(caught.exception))
|
||||
|
||||
def test_a_session_that_is_gone_only_matters_when_one_was_resumed(self):
|
||||
with self.assertRaises(assistant.AssistantError):
|
||||
assistant._conclude(self.found(), 1, "session abc not found",
|
||||
@@ -279,6 +328,11 @@ class Conclude(DikteTest):
|
||||
"", "Claude")
|
||||
self.assertEqual(answer, "done")
|
||||
|
||||
def test_an_answer_on_a_resumed_session_is_kept_rather_than_retried(self):
|
||||
answer, _ = assistant._conclude(self.found(answer="done"), 1, "noise",
|
||||
"abc", "Claude")
|
||||
self.assertEqual(answer, "done")
|
||||
|
||||
def test_a_reported_failure_with_no_answer(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(failure="the model refused"), 0, "",
|
||||
@@ -291,14 +345,53 @@ class Conclude(DikteTest):
|
||||
self.assertIn("Codex", str(caught.exception))
|
||||
|
||||
|
||||
class Stream(DikteTest):
|
||||
def test_a_cli_that_floods_stderr_still_finishes(self):
|
||||
# A real subprocess, because the wedge being tested is real plumbing:
|
||||
# with stderr on a pipe nobody drains, 200 KB fills the pipe's buffer,
|
||||
# the child blocks writing it, and the run hangs until the watchdog
|
||||
# timeout. With stderr on a file the run completes at once.
|
||||
script = (
|
||||
"import sys\n"
|
||||
"sys.stderr.write('x' * 200000)\n"
|
||||
"sys.stderr.flush()\n"
|
||||
"print('{\"type\": \"result\", \"result\": \"done\"}')\n"
|
||||
)
|
||||
conf = self.config(assistant_timeout=15)
|
||||
events = []
|
||||
code, stderr = assistant._stream(
|
||||
[sys.executable, "-c", script], conf, events.append, None)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(len(stderr), 200000)
|
||||
self.assertEqual(events[-1]["result"], "done")
|
||||
|
||||
def test_the_watchdog_takes_the_whole_tree_down_on_timeout(self):
|
||||
proc = WedgedCli()
|
||||
|
||||
def killed(target):
|
||||
# What the real kill does, as far as _stream can see: the process
|
||||
# ends, and its closing stream releases the blocked read.
|
||||
target.returncode = 1
|
||||
target.released.set()
|
||||
|
||||
conf = self.config(assistant_timeout=0)
|
||||
with mock.patch.object(subprocess, "Popen", return_value=proc), \
|
||||
mock.patch.object(assistant, "kill_tree",
|
||||
side_effect=killed) as kill:
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._stream(["claude"], conf, lambda event: None, None)
|
||||
kill.assert_called_once_with(proc)
|
||||
self.assertIn("did not finish", str(caught.exception))
|
||||
|
||||
|
||||
class AskClaude(DikteTest):
|
||||
def run_ask(self, conf=None, events=None, code=0, stderr="", noise=(),
|
||||
def run_ask(self, conf=None, events=None, code=0, noise=(),
|
||||
session=""):
|
||||
conf = conf or self.config()
|
||||
proc = FakeCli(events or [
|
||||
{"type": "system", "subtype": "init", "session_id": "abc"},
|
||||
{"type": "result", "session_id": "abc", "result": " done "},
|
||||
], code=code, stderr=stderr, noise=noise)
|
||||
], code=code, noise=noise)
|
||||
stages = []
|
||||
with only_these_tools("claude", "codex"), \
|
||||
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||
@@ -533,6 +626,89 @@ class Ask(DikteTest):
|
||||
self.assertEqual(attempts, ["stale-id", ""])
|
||||
self.assertEqual(assistant.stored_provider(), "")
|
||||
|
||||
def test_a_resumed_run_that_dies_is_retried_without_the_session_flag(self):
|
||||
# All the way through the stream this time: the first run exits 1 with
|
||||
# no answer and whatever stderr it liked, and the recovery must not
|
||||
# depend on those words.
|
||||
conf = self.config()
|
||||
assistant.write_session("claude", "stale-id")
|
||||
procs = iter([
|
||||
FakeCli(code=1),
|
||||
FakeCli(events=[{"type": "result", "result": "done"}]),
|
||||
])
|
||||
cmds = []
|
||||
|
||||
def popen(cmd, **kwargs):
|
||||
cmds.append(cmd)
|
||||
return next(procs)
|
||||
|
||||
with only_these_tools("claude"), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=popen):
|
||||
answer, _ = assistant.ask("hi", conf)
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(cmds[0][cmds[0].index("--resume") + 1], "stale-id")
|
||||
self.assertNotIn("--resume", cmds[1])
|
||||
|
||||
def test_a_run_that_dies_with_an_answer_in_hand_is_not_retried(self):
|
||||
conf = self.config()
|
||||
assistant.write_session("claude", "stale-id")
|
||||
calls = []
|
||||
|
||||
def popen(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return FakeCli(events=[{"type": "result", "result": "done"}], code=1)
|
||||
|
||||
with only_these_tools("claude"), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=popen):
|
||||
answer, _ = assistant.ask("hi", conf)
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
class CodexModels(DikteTest):
|
||||
"""The model list read off `codex debug models`."""
|
||||
|
||||
CATALOG = {"models": [
|
||||
{"slug": "gpt-6-mini", "visibility": "list", "priority": 9},
|
||||
{"slug": "gpt-6", "visibility": "list", "priority": 1},
|
||||
{"slug": "codex-auto-review", "visibility": "hide", "priority": 3},
|
||||
]}
|
||||
|
||||
def models(self, reply, code=0):
|
||||
with only_these_tools("codex"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
return_value=FakeCompleted(
|
||||
returncode=code, stdout=reply)) as run:
|
||||
found = assistant.codex_models()
|
||||
self.run_call = run
|
||||
return found
|
||||
|
||||
def test_the_catalog_arrives_best_first_without_the_hidden_ones(self):
|
||||
found = self.models(json.dumps(self.CATALOG))
|
||||
self.assertEqual(found, ["gpt-6", "gpt-6-mini"])
|
||||
self.assertEqual(self.run_call.call_args.args[0],
|
||||
["codex", "debug", "models"])
|
||||
|
||||
def test_a_codex_that_is_not_installed_is_not_run(self):
|
||||
with only_these_tools(), \
|
||||
mock.patch.object(subprocess, "run") as run:
|
||||
self.assertEqual(assistant.codex_models(), [])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_a_codex_too_old_to_have_the_command(self):
|
||||
self.assertEqual(self.models("error: unknown subcommand", code=2), [])
|
||||
|
||||
def test_a_catalog_that_is_not_what_was_expected(self):
|
||||
self.assertEqual(self.models(json.dumps(["gpt-6"])), [])
|
||||
self.assertEqual(self.models(""), [])
|
||||
|
||||
def test_a_codex_that_hangs_is_given_up_on(self):
|
||||
with only_these_tools("codex"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired(
|
||||
["codex"], 30)):
|
||||
self.assertEqual(assistant.codex_models(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+203
-14
@@ -81,6 +81,14 @@ class ChunkLevels(unittest.TestCase):
|
||||
self.assertEqual(peak, 1.0)
|
||||
self.assertEqual(rms, 1.0)
|
||||
|
||||
def test_the_fast_and_plain_rms_paths_agree(self):
|
||||
"""sumprod is a speedup, not a different sum: on a 3.11 machine the
|
||||
loop must land on the same integers."""
|
||||
chunk = tone(0.1)
|
||||
with mock.patch.object(audio, "sumprod", None):
|
||||
plain = audio.chunk_levels(chunk)
|
||||
self.assertEqual(audio.chunk_levels(chunk), plain)
|
||||
|
||||
|
||||
class StereoLevels(unittest.TestCase):
|
||||
def test_the_channels_are_read_apart(self):
|
||||
@@ -280,6 +288,48 @@ class _StalledStream:
|
||||
self._released.set()
|
||||
|
||||
|
||||
class _DribblingStream:
|
||||
"""A pipe that never fills a whole chunk in one read, the way an unbuffered
|
||||
pipe hands data over under load."""
|
||||
|
||||
def __init__(self, data, piece):
|
||||
self._data = io.BytesIO(data)
|
||||
self._piece = piece
|
||||
|
||||
def read(self, size):
|
||||
return self._data.read(min(size, self._piece))
|
||||
|
||||
|
||||
class _RunSwappingStream:
|
||||
"""A pipe whose recorder moves on mid-read, the way a new recording starts
|
||||
while a stale pump is still draining the old one."""
|
||||
|
||||
def __init__(self, data, recorder, swap_at, new_proc):
|
||||
self._data = io.BytesIO(data)
|
||||
self._recorder = recorder
|
||||
self._swap_at = swap_at
|
||||
self._new_proc = new_proc
|
||||
self.reads = 0
|
||||
|
||||
def read(self, size):
|
||||
if self.reads == self._swap_at:
|
||||
self._recorder._run = object()
|
||||
self._recorder._proc = self._new_proc
|
||||
self.reads += 1
|
||||
return self._data.read(size)
|
||||
|
||||
|
||||
class _SignalCrashingProcess(FakeProcess):
|
||||
"""An ffmpeg that calls being interrupted a failure, the way ffmpeg does."""
|
||||
|
||||
def __init__(self, data, code=255):
|
||||
super().__init__(data)
|
||||
self._code = code
|
||||
|
||||
def poll(self):
|
||||
return None if self._alive else self._code
|
||||
|
||||
|
||||
class _HeldStream:
|
||||
"""A capture that is paused and taken up again partway through, the way a
|
||||
key press lands in the middle of a recording rather than between two."""
|
||||
@@ -307,7 +357,10 @@ class RecordingCommand(OnLinux, DikteTest):
|
||||
super().setUp()
|
||||
# Whether pw-record takes --raw is read off the installed binary, and
|
||||
# what is being tested here is the command rather than the machine the
|
||||
# test is running on. PwRecordRawOption covers the reading itself.
|
||||
# test is running on. PwRecordRawOption covers the reading itself. The
|
||||
# answer is remembered between calls, so it cannot be remembered
|
||||
# between tests.
|
||||
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||
self.enterContext(mock.patch.object(
|
||||
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
||||
|
||||
@@ -392,11 +445,30 @@ class PwRecordRawOption(DikteTest):
|
||||
self.assertEqual(["--raw"], self.option(return_value=FakeCompleted()))
|
||||
|
||||
|
||||
class PwRawMemo(OnLinux, DikteTest):
|
||||
"""The --raw probe runs once per process, not once per key press."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||
|
||||
def test_the_probe_is_asked_once_and_remembered(self):
|
||||
with only_these_tools("pw-record"), \
|
||||
mock.patch.object(audio, "_pw_record_raw_option",
|
||||
return_value=["--raw"]) as probe:
|
||||
first = audio.recording_command()
|
||||
second = audio.recording_command()
|
||||
probe.assert_called_once_with()
|
||||
self.assertIn("--raw", first)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
class RecorderChain(OnLinux, DikteTest):
|
||||
"""Start to WAV, with pw-record faked out."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||
self.enterContext(mock.patch.object(
|
||||
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
||||
|
||||
@@ -476,42 +548,121 @@ class RecorderChain(OnLinux, DikteTest):
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("pulseaudio-utils", failures[0])
|
||||
|
||||
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False):
|
||||
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False,
|
||||
alive=False):
|
||||
"""Run the pump in this thread, where a queued signal would need an
|
||||
event loop nobody is running here."""
|
||||
recorder = audio.Recorder()
|
||||
failures = []
|
||||
deaths = []
|
||||
recorder.failed.connect(failures.append)
|
||||
recorder.died.connect(lambda: deaths.append(True))
|
||||
proc = FakeProcess(data)
|
||||
proc.stderr = io.BytesIO(stderr)
|
||||
proc._alive = False
|
||||
proc._alive = alive
|
||||
recorder._proc = proc
|
||||
recorder._max_bytes = 10 ** 9
|
||||
recorder._log = io.BytesIO(stderr)
|
||||
recorder._stopping = stopping
|
||||
recorder._cancelled = cancelled
|
||||
recorder._pump()
|
||||
return failures
|
||||
recorder._run = run = object()
|
||||
recorder._pump(run, proc, proc.stdout, recorder._buffer,
|
||||
recorder._rms, 10 ** 9)
|
||||
return failures, deaths
|
||||
|
||||
def test_a_recorder_that_died_on_its_own_says_so(self):
|
||||
"""parec refused the device, or the sound server went away."""
|
||||
failures = self.pump(stderr=b"connection refused\n")
|
||||
failures, _ = self.pump(stderr=b"connection refused\n")
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("connection refused", failures[0])
|
||||
|
||||
def test_a_death_with_nothing_on_stderr_still_names_the_exit_code(self):
|
||||
failures = self.pump()
|
||||
failures, _ = self.pump()
|
||||
self.assertIn("exit code", failures[0])
|
||||
|
||||
def test_a_death_that_left_no_exit_code_is_not_named_none(self):
|
||||
"""A process nobody managed to reap has no code to show, and "exit
|
||||
code None" would only puzzle the person reading it."""
|
||||
failures, _ = self.pump(alive=True)
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertNotIn("None", failures[0])
|
||||
|
||||
def test_a_recording_we_ended_ourselves_is_not_a_death(self):
|
||||
"""Otherwise a stray keypress produces two errors, and the first one
|
||||
sends the user looking for a broken sound server."""
|
||||
self.assertEqual(self.pump(stopping=True), [])
|
||||
self.assertEqual(self.pump(stopping=True), ([], []))
|
||||
|
||||
def test_a_cancelled_recording_is_not_a_death(self):
|
||||
self.assertEqual(self.pump(cancelled=True), [])
|
||||
self.assertEqual(self.pump(cancelled=True), ([], []))
|
||||
|
||||
def test_a_recorder_that_captured_something_first_is_not_a_death(self):
|
||||
self.assertEqual(self.pump(data=silence(0.5)), [])
|
||||
def test_a_capture_that_ends_mid_recording_dies_rather_than_fails(self):
|
||||
"""Sound had already arrived, so this is not a broken installation:
|
||||
the app is told the recording died and can rescue what there is."""
|
||||
failures, deaths = self.pump(data=silence(0.5))
|
||||
self.assertEqual(failures, [])
|
||||
self.assertEqual(deaths, [True])
|
||||
|
||||
def test_short_pipe_reads_are_gathered_into_whole_chunks(self):
|
||||
"""Every RMS entry must stand for one full chunk, or the silence check
|
||||
weighs a half-filled read as its own stretch of room tone."""
|
||||
half = audio.CHUNK_BYTES // 2
|
||||
data = pcm([1000] * (3 * half // 2)) # three half-chunk reads
|
||||
recorder = audio.Recorder()
|
||||
proc = FakeProcess(b"")
|
||||
proc.stdout = _DribblingStream(data, half)
|
||||
proc._alive = False
|
||||
recorder._proc = proc
|
||||
recorder._log = io.BytesIO(b"")
|
||||
recorder._run = run = object()
|
||||
buffer, rms = bytearray(), []
|
||||
recorder._pump(run, proc, proc.stdout, buffer, rms, 10 ** 9)
|
||||
self.assertEqual(len(buffer), len(data))
|
||||
self.assertEqual(len(rms), 2) # one whole chunk, then the tail
|
||||
|
||||
def test_a_stale_pump_cannot_touch_the_recording_that_replaced_it(self):
|
||||
"""A pump that outlives its join must not meter the next run, stop its
|
||||
process, push audio into its buffer, or speak on its behalf."""
|
||||
recorder = audio.Recorder()
|
||||
levels, failures, deaths = [], [], []
|
||||
recorder.level.connect(levels.append)
|
||||
recorder.failed.connect(failures.append)
|
||||
recorder.died.connect(lambda: deaths.append(True))
|
||||
new_proc = FakeProcess(b"")
|
||||
old_proc = FakeProcess(b"")
|
||||
old_proc.stdout = _RunSwappingStream(tone(0.192), recorder,
|
||||
swap_at=1, new_proc=new_proc)
|
||||
recorder._proc = old_proc
|
||||
recorder._log = io.BytesIO(b"")
|
||||
old_run = object()
|
||||
recorder._run = old_run
|
||||
recorder._buffer = bytearray() # the next recording's buffer
|
||||
old_buffer, old_rms = bytearray(), []
|
||||
recorder._pump(old_run, old_proc, old_proc.stdout, old_buffer, old_rms,
|
||||
2 * audio.CHUNK_BYTES)
|
||||
# Metered once, then the new run took over: the over-length cutoff hit
|
||||
# on the next chunk and had to stand down instead of stopping a
|
||||
# process that was never its own.
|
||||
self.assertEqual(len(levels), 1)
|
||||
self.assertEqual(len(old_buffer), 2 * audio.CHUNK_BYTES)
|
||||
self.assertEqual(new_proc.signals, [])
|
||||
self.assertEqual(old_proc.signals, [])
|
||||
self.assertEqual(recorder._buffer, bytearray())
|
||||
self.assertEqual((failures, deaths), ([], []))
|
||||
|
||||
def test_a_wav_that_cannot_be_written_is_reported_not_raised(self):
|
||||
recorder = audio.Recorder()
|
||||
results, failures = [], []
|
||||
recorder.stopped.connect(lambda *args: results.append(args))
|
||||
recorder.failed.connect(failures.append)
|
||||
proc = FakeProcess(tone(1.0))
|
||||
with only_these_tools("pw-record"), \
|
||||
mock.patch.object(subprocess, "Popen", return_value=proc), \
|
||||
mock.patch.object(audio, "write_wav",
|
||||
side_effect=OSError("disk full")):
|
||||
recorder.start()
|
||||
recorder._thread.join(timeout=5)
|
||||
recorder.stop()
|
||||
self.assertEqual(results, [])
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("disk full", failures[0])
|
||||
|
||||
def test_a_short_recording_reports_only_that(self):
|
||||
_, results, failures, _ = self.record(silence(0.1))
|
||||
@@ -722,6 +873,42 @@ class MacMeetingRecorder(OnMacOS, DikteTest):
|
||||
_, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5))
|
||||
self.assertTrue(all(process.signals for process in processes))
|
||||
|
||||
def test_a_stop_we_asked_for_is_not_reported_as_an_ffmpeg_failure(self):
|
||||
"""ffmpeg exits 255 when interrupted, and the interruption was our own
|
||||
stop: a meeting ended at once must say "too short", not "ffmpeg → 255"."""
|
||||
path = str(self.path("meeting.wav"))
|
||||
recorder = audio.MeetingRecorder()
|
||||
failed = []
|
||||
recorder.failed.connect(failed.append)
|
||||
processes = [_SignalCrashingProcess(tone(0.1)),
|
||||
_SignalCrashingProcess(tone(0.1))]
|
||||
with only_these_tools("ffmpeg"), self.devices(), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=processes):
|
||||
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||
recorder._thread.join(timeout=5)
|
||||
recorder.stop()
|
||||
self.assertEqual(len(failed), 1)
|
||||
self.assertIn("0.3", failed[0])
|
||||
self.assertNotIn("255", failed[0])
|
||||
|
||||
def test_an_ffmpeg_that_died_on_its_own_keeps_its_exit_code(self):
|
||||
"""A process nobody interrupted has a story to tell, and its code is
|
||||
the only lead the user gets."""
|
||||
path = str(self.path("meeting.wav"))
|
||||
recorder = audio.MeetingRecorder()
|
||||
failed = []
|
||||
recorder.failed.connect(failed.append)
|
||||
dead = _SignalCrashingProcess(tone(0.1))
|
||||
dead._alive = False # it fell over before stop() reached it
|
||||
processes = [dead, FakeProcess(tone(0.1))]
|
||||
with only_these_tools("ffmpeg"), self.devices(), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=processes):
|
||||
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||
recorder._thread.join(timeout=5)
|
||||
recorder.stop()
|
||||
self.assertEqual(len(failed), 1)
|
||||
self.assertIn("255", failed[0])
|
||||
|
||||
def test_a_legacy_numeric_target_fails_before_recording(self):
|
||||
recorder = audio.MeetingRecorder()
|
||||
failed = []
|
||||
@@ -953,9 +1140,11 @@ class WindowsDevices(OnWindows, DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# The listing is remembered between calls, so that a dictation does not
|
||||
# run ffmpeg of its own. It cannot be remembered between tests.
|
||||
# run ffmpeg of its own. It cannot be remembered between tests, and
|
||||
# neither can the pw-record probe's answer.
|
||||
audio._DSHOW_SEEN.clear()
|
||||
self.addCleanup(audio._DSHOW_SEEN.clear)
|
||||
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||
|
||||
@contextlib.contextmanager
|
||||
def listing(self, stderr=None, tools=("ffmpeg",)):
|
||||
|
||||
+31
-18
@@ -1,9 +1,9 @@
|
||||
"""Who cleans the transcript up, and what they are asked.
|
||||
"""Who cleans the transcript up, and what they are asked.
|
||||
|
||||
The CLIs are faked at subprocess.run: what the tests read is the argument list
|
||||
each one is given, where the answer is picked up from, and what happens to the
|
||||
chain when the program is missing, slow or unhappy. The OpenRouter path is the
|
||||
one that was always there and is checked here only for still being taken.
|
||||
The CLIs are faked at subprocess.Popen: what the tests read is the argument
|
||||
list each one is given, where the answer is picked up from, and what happens to
|
||||
the chain when the program is missing, slow or unhappy. The OpenRouter path is
|
||||
the one that was always there and is checked here only for still being taken.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -18,18 +18,26 @@ from tests.support import DikteTest, fake_urlopen, sent_json, url_error
|
||||
from tests.test_api import FakeServer, chat_reply
|
||||
|
||||
|
||||
def fake_run(stdout="", code=0, stderr="", last_message=""):
|
||||
"""Stand in for subprocess.run, writing the file Codex would have written."""
|
||||
def fake_cli(stdout="", code=0, stderr="", last_message=""):
|
||||
"""Stand in for subprocess.Popen.
|
||||
|
||||
_output hands the process a temporary file for each stream, so the fake
|
||||
writes into those, plus the file Codex would have written on its way out.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
def popen(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
kwargs["stdout"].write(stdout.encode("utf-8"))
|
||||
kwargs["stderr"].write(stderr.encode("utf-8"))
|
||||
if last_message and "-o" in cmd:
|
||||
with open(cmd[cmd.index("-o") + 1], "w", encoding="utf-8") as fh:
|
||||
fh.write(last_message)
|
||||
return subprocess.CompletedProcess(cmd, code, stdout, stderr)
|
||||
proc = mock.Mock()
|
||||
proc.returncode = code
|
||||
return proc
|
||||
|
||||
return mock.patch.object(subprocess, "run", side_effect=run), calls
|
||||
return mock.patch.object(subprocess, "Popen", side_effect=popen), calls
|
||||
|
||||
|
||||
class Provider(DikteTest):
|
||||
@@ -80,7 +88,7 @@ class OpenRouter(DikteTest):
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
conf = self.config(openrouter_api_key="sk-or-test")
|
||||
patcher, calls = fake_run(stdout="never")
|
||||
patcher, calls = fake_cli(stdout="never")
|
||||
with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
|
||||
cleanup.run("uh, done", conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
@@ -93,7 +101,7 @@ class ClaudeCode(DikteTest):
|
||||
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
|
||||
def run_cleanup(self, text="uh, book it", **kwargs):
|
||||
patcher, calls = fake_run(**kwargs)
|
||||
patcher, calls = fake_cli(**kwargs)
|
||||
with patcher:
|
||||
answer = cleanup.run(text, self.conf, "the rules")
|
||||
return answer, calls[0]
|
||||
@@ -146,14 +154,18 @@ class ClaudeCode(DikteTest):
|
||||
self.run_cleanup(stdout="Book it.")
|
||||
self.assertIn("claude", str(caught.exception))
|
||||
|
||||
def test_a_run_that_never_ends(self):
|
||||
def run(cmd, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, 180)
|
||||
def test_a_run_that_never_ends_is_killed_with_its_whole_tree(self):
|
||||
def popen(cmd, **kwargs):
|
||||
proc = mock.Mock()
|
||||
proc.wait.side_effect = subprocess.TimeoutExpired(cmd, 180)
|
||||
return proc
|
||||
|
||||
with mock.patch.object(subprocess, "run", side_effect=run):
|
||||
with mock.patch.object(subprocess, "Popen", side_effect=popen), \
|
||||
mock.patch.object(cleanup.assistant, "kill_tree") as kill:
|
||||
with self.assertRaises(cleanup.CleanupError) as caught:
|
||||
cleanup.run("uh, book it", self.conf, "the rules")
|
||||
self.assertIn("180", str(caught.exception))
|
||||
kill.assert_called_once()
|
||||
|
||||
|
||||
class Codex(DikteTest):
|
||||
@@ -163,7 +175,7 @@ class Codex(DikteTest):
|
||||
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
|
||||
def run_cleanup(self, text="uh, book it", **kwargs):
|
||||
patcher, calls = fake_run(**kwargs)
|
||||
patcher, calls = fake_cli(**kwargs)
|
||||
with patcher:
|
||||
answer = cleanup.run(text, self.conf, "the rules")
|
||||
return answer, calls[0]
|
||||
@@ -280,7 +292,8 @@ class Here(DikteTest):
|
||||
self.assertIn("out of memory", str(caught.exception))
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
patcher, calls = fake_run(stdout="never")
|
||||
patcher, calls = fake_cli(stdout="never")
|
||||
with patcher, fake_urlopen(chat_reply("Done.")):
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ config and now shadows the default.
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -44,6 +47,21 @@ class Loading(DikteTest):
|
||||
conf = cfg.Config()
|
||||
self.assertEqual(conf["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
||||
|
||||
def test_a_config_that_is_not_json_is_set_aside_as_evidence(self):
|
||||
"""Left in place it would be overwritten by the very next save."""
|
||||
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cfg.CONFIG_FILE.write_text("{not json", encoding="utf-8")
|
||||
broken = cfg.CONFIG_FILE.with_suffix(".json.broken")
|
||||
with mock.patch("builtins.print") as told:
|
||||
conf = cfg.Config()
|
||||
self.assertEqual(broken.read_text(encoding="utf-8"), "{not json")
|
||||
self.assertFalse(cfg.CONFIG_FILE.exists())
|
||||
self.assertIn(str(broken), told.call_args[0][0])
|
||||
conf.save()
|
||||
self.assertEqual(broken.read_text(encoding="utf-8"), "{not json")
|
||||
self.assertEqual(self.read_config_file()["cleanup_model"],
|
||||
cfg.DEFAULTS["cleanup_model"])
|
||||
|
||||
def test_a_config_that_is_json_but_not_an_object(self):
|
||||
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cfg.CONFIG_FILE.write_text("[1, 2]", encoding="utf-8")
|
||||
@@ -113,6 +131,41 @@ class Saving(DikteTest):
|
||||
conf.save()
|
||||
self.assertEqual(i18n.language(), "tr")
|
||||
|
||||
def test_the_settings_hit_the_disk_before_the_swap(self):
|
||||
"""Renaming a file still in the page cache into place makes a power
|
||||
cut a settings wipe, which is what the atomic replace exists to stop."""
|
||||
with mock.patch("os.fsync") as fsync:
|
||||
cfg.Config().save()
|
||||
fsync.assert_called_once()
|
||||
|
||||
def test_a_file_held_briefly_by_a_scanner_does_not_fail_the_save(self):
|
||||
"""Antivirus and sync tools on Windows hold a fresh file for a moment,
|
||||
and the rename over it fails until they let go."""
|
||||
attempts = []
|
||||
real_replace = pathlib.Path.replace
|
||||
|
||||
def flaky(path, target):
|
||||
attempts.append(str(target))
|
||||
if len(attempts) < 3:
|
||||
raise PermissionError("held by a scanner")
|
||||
return real_replace(path, target)
|
||||
|
||||
with mock.patch.object(pathlib.Path, "replace", flaky), \
|
||||
mock.patch("time.sleep"):
|
||||
cfg.Config().save()
|
||||
self.assertEqual(len(attempts), 3)
|
||||
self.assertEqual(self.read_config_file()["language"],
|
||||
cfg.DEFAULTS["language"])
|
||||
|
||||
def test_a_file_held_for_good_still_raises(self):
|
||||
def held(path, target):
|
||||
raise PermissionError("never let go")
|
||||
|
||||
with mock.patch.object(pathlib.Path, "replace", held), \
|
||||
mock.patch("time.sleep"):
|
||||
with self.assertRaises(PermissionError):
|
||||
cfg.Config().save()
|
||||
|
||||
|
||||
class Keys(DikteTest):
|
||||
def test_a_stored_key_is_used(self):
|
||||
@@ -350,6 +403,23 @@ class History(DikteTest):
|
||||
cfg.delete_history([])
|
||||
self.assertEqual(len(cfg.read_history()), 1)
|
||||
|
||||
def test_amending_matches_on_content_and_patches_in_place(self):
|
||||
rows = [self.entry("a"), self.entry("b")]
|
||||
for row in rows:
|
||||
cfg.append_history(row)
|
||||
patched = cfg.amend_history(rows[0], cleanup_error="could not paste")
|
||||
self.assertEqual(patched["cleanup_error"], "could not paste")
|
||||
kept = cfg.read_history()
|
||||
self.assertEqual([row["text"] for row in kept], ["a", "b"])
|
||||
self.assertEqual(kept[0]["cleanup_error"], "could not paste")
|
||||
|
||||
def test_amending_a_row_a_trim_took_away_is_a_no_op(self):
|
||||
row = self.entry("gone")
|
||||
cfg.append_history(row)
|
||||
cfg.clear_history()
|
||||
self.assertIsNone(cfg.amend_history(row, cleanup_error="x"))
|
||||
self.assertEqual(cfg.read_history(), [])
|
||||
|
||||
def test_clearing(self):
|
||||
cfg.append_history(self.entry("a"))
|
||||
cfg.clear_history()
|
||||
@@ -358,6 +428,40 @@ class History(DikteTest):
|
||||
def test_clearing_a_history_that_is_not_there(self):
|
||||
cfg.clear_history() # must not raise
|
||||
|
||||
def test_an_append_during_a_trim_is_not_lost(self):
|
||||
"""Trim is read, cut, rewrite; a dictation appended between the read
|
||||
and the rewrite must wait rather than be erased by a rewrite that
|
||||
never saw it. The rewrite is slowed down to hold the race open."""
|
||||
for index in range(10):
|
||||
cfg.append_history(self.entry(str(index)))
|
||||
real_write = cfg._write_history
|
||||
rewriting = threading.Event()
|
||||
|
||||
def slow_write(lines):
|
||||
rewriting.set()
|
||||
time.sleep(0.1)
|
||||
real_write(lines)
|
||||
|
||||
with mock.patch.object(cfg, "_write_history", slow_write):
|
||||
trimmer = threading.Thread(target=cfg.trim_history, args=(3,))
|
||||
trimmer.start()
|
||||
# The trim now holds the lock inside its read-cut-rewrite window.
|
||||
self.assertTrue(rewriting.wait(5))
|
||||
appender = threading.Thread(target=cfg.append_history,
|
||||
args=(self.entry("late"),))
|
||||
appender.start()
|
||||
trimmer.join()
|
||||
appender.join()
|
||||
self.assertEqual([row["text"] for row in cfg.read_history()],
|
||||
["7", "8", "9", "late"])
|
||||
|
||||
def test_the_rewrite_hits_the_disk_before_the_swap(self):
|
||||
for index in range(5):
|
||||
cfg.append_history(self.entry(str(index)))
|
||||
with mock.patch("os.fsync") as fsync:
|
||||
cfg.trim_history(2)
|
||||
fsync.assert_called_once()
|
||||
|
||||
|
||||
class Meetings(DikteTest):
|
||||
def entry(self, base, **changes):
|
||||
@@ -430,6 +534,27 @@ class Meetings(DikteTest):
|
||||
cfg.delete_meetings([])
|
||||
self.assertEqual(len(cfg.read_meetings()), 1)
|
||||
|
||||
def test_the_index_hits_the_disk_before_the_swap(self):
|
||||
with mock.patch("os.fsync") as fsync:
|
||||
cfg.save_meeting(self.entry("a"))
|
||||
fsync.assert_called_once()
|
||||
|
||||
def test_an_index_held_briefly_by_a_scanner_is_still_written(self):
|
||||
real_replace = pathlib.Path.replace
|
||||
attempts = []
|
||||
|
||||
def flaky(path, target):
|
||||
attempts.append(str(target))
|
||||
if len(attempts) < 3:
|
||||
raise PermissionError("held by a scanner")
|
||||
return real_replace(path, target)
|
||||
|
||||
with mock.patch.object(pathlib.Path, "replace", flaky), \
|
||||
mock.patch("time.sleep"):
|
||||
cfg.save_meeting(self.entry("a"))
|
||||
self.assertEqual(len(attempts), 3)
|
||||
self.assertEqual([row["base"] for row in cfg.read_meetings()], ["a"])
|
||||
|
||||
|
||||
class Defaults(unittest.TestCase):
|
||||
"""The table itself, which every command line and settings tab reads."""
|
||||
|
||||
@@ -235,6 +235,28 @@ class ChunkSeconds(DikteTest):
|
||||
self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0)
|
||||
|
||||
|
||||
class Ffmpeg(DikteTest):
|
||||
"""How the converter process is started."""
|
||||
|
||||
def test_its_output_is_read_as_utf8_whatever_the_locale_says(self):
|
||||
"""ffmpeg writes UTF-8; read as the locale codepage its messages
|
||||
mojibake, and a byte the codepage cannot place raises from inside
|
||||
communicate itself."""
|
||||
out = str(self.path("out.wav"))
|
||||
with open(out, "wb") as fh:
|
||||
fh.write(b"\x00")
|
||||
proc = mock.Mock()
|
||||
proc.communicate.return_value = ("", "")
|
||||
proc.returncode = 0
|
||||
proc.poll.return_value = 0
|
||||
with mock.patch.object(ft.subprocess, "Popen", return_value=proc) as popen:
|
||||
ft._ffmpeg(["-i", "in.mp4", out], out)
|
||||
kwargs = popen.call_args.kwargs
|
||||
self.assertTrue(kwargs["text"])
|
||||
self.assertEqual(kwargs["encoding"], "utf-8")
|
||||
self.assertEqual(kwargs["errors"], "replace")
|
||||
|
||||
|
||||
class Chunks(DikteTest):
|
||||
"""What each provider is handed, and in how many pieces."""
|
||||
|
||||
|
||||
+242
-2
@@ -9,6 +9,7 @@ import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import sys
|
||||
import tarfile
|
||||
@@ -179,6 +180,21 @@ class Download(Local):
|
||||
ggml.download(item("m.bin", b"x"), target)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_a_target_held_open_keeps_the_finished_download(self):
|
||||
# Windows refuses to replace a file a running server holds open. The
|
||||
# bytes are complete and verified by then, so the .part must survive
|
||||
# the failure rather than being deleted with everything else.
|
||||
data = b"a finished, verified download"
|
||||
target = self.path("data", "models", "m.bin")
|
||||
with fake_urlopen(body(data)):
|
||||
with mock.patch.object(pathlib.Path, "replace",
|
||||
side_effect=PermissionError(13, "in use")):
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
ggml.download(item("m.bin", data), target)
|
||||
self.assertIn("held open", str(caught.exception))
|
||||
self.assertEqual(target.with_name("m.bin.part").read_bytes(), data)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
|
||||
# --- installing a program -------------------------------------------------
|
||||
|
||||
@@ -275,6 +291,70 @@ class InstallProgram(Local):
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty)
|
||||
self.assertIn("whisper-server", str(caught.exception))
|
||||
|
||||
def test_a_failed_update_leaves_the_working_install_alone(self):
|
||||
# The old install used to be deleted before the new bytes had even
|
||||
# arrived, so a bad download left no local server at all.
|
||||
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz",
|
||||
archive=b"not an archive at all")
|
||||
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
self.assertEqual(ggml.installed_version(ggml.WHISPER), "v1.9.1")
|
||||
# And the half-made sibling did not linger either.
|
||||
left = list(self.path("data", "bin", "whisper").glob("*.new"))
|
||||
self.assertEqual(left, [])
|
||||
|
||||
def test_an_update_that_never_downloaded_leaves_the_install_alone(self):
|
||||
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with serving(listing, self.archive):
|
||||
with mock.patch.object(ggml, "download",
|
||||
side_effect=ggml.LocalError("no route")):
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
|
||||
class StubServer:
|
||||
"""Owns the installed binary, remembers when it was told to stop."""
|
||||
|
||||
def __init__(self):
|
||||
self.program = ggml.WHISPER
|
||||
self.stops = 0
|
||||
self.new_version_was_ready = False
|
||||
|
||||
def settings(self):
|
||||
return {"binary": ""}
|
||||
|
||||
def stop(self):
|
||||
self.stops += 1
|
||||
self.new_version_was_ready = any(
|
||||
(ggml.BIN_DIR / "whisper").glob("*.new"))
|
||||
|
||||
def test_the_running_server_is_stopped_only_for_the_swap(self):
|
||||
# The outage is the swap, not the transfer: the server keeps answering
|
||||
# through a download that can take minutes, and is stopped only once
|
||||
# the replacement is unpacked next door and known to be whole.
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
server = self.StubServer()
|
||||
with mock.patch.object(ggml, "SERVERS", (server,)):
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
self.assertEqual(server.stops, 1)
|
||||
self.assertTrue(server.new_version_was_ready)
|
||||
|
||||
def test_a_download_that_fails_never_stops_the_server(self):
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
server = self.StubServer()
|
||||
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with mock.patch.object(ggml, "SERVERS", (server,)):
|
||||
with serving(listing, self.archive):
|
||||
with mock.patch.object(ggml, "download",
|
||||
side_effect=ggml.LocalError("no route")):
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertEqual(server.stops, 0)
|
||||
|
||||
|
||||
def test_a_release_without_a_published_checksum_is_refused(self):
|
||||
# GitHub did not always publish one, and whisper.cpp v1.8.0 and older
|
||||
@@ -456,12 +536,14 @@ STAND_IN = textwrap.dedent("""
|
||||
def opt(name, default=""):
|
||||
return args[args.index(name) + 1] if name in args else default
|
||||
|
||||
time.sleep(float(opt("--wait", "0")))
|
||||
|
||||
# After the sleep, so that --wait plus --die is a program that runs for a
|
||||
# while and then crashes, the way a bad model dies mid-load.
|
||||
if "--die" in args:
|
||||
print("could not load model: no such file")
|
||||
sys.exit(2)
|
||||
|
||||
time.sleep(float(opt("--wait", "0")))
|
||||
|
||||
started = time.monotonic()
|
||||
healthy_after = float(opt("--healthy-after", "0"))
|
||||
|
||||
@@ -539,6 +621,14 @@ class Servers(Local):
|
||||
self.assertTrue(server.running)
|
||||
self.assertTrue(second)
|
||||
|
||||
def count_ports(self):
|
||||
"""Record every port handed to a launch, one per attempt."""
|
||||
ports = []
|
||||
real = ggml._free_port
|
||||
self.patch_attr(ggml, "_free_port",
|
||||
lambda: ports.append(real()) or ports[-1])
|
||||
return ports
|
||||
|
||||
def test_a_program_that_dies_reports_what_it_printed(self):
|
||||
server = self.server(extra=["--die"])
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
@@ -546,6 +636,46 @@ class Servers(Local):
|
||||
self.assertIn("no such file", str(caught.exception))
|
||||
self.assertFalse(server.running)
|
||||
|
||||
def test_an_early_death_that_never_listened_is_retried(self):
|
||||
# A child that loses the bind race fails and exits at once, and which
|
||||
# port was lost cannot be told from the log: the shape of the failure,
|
||||
# not its wording, is what earns another port.
|
||||
ports = self.count_ports()
|
||||
server = self.server(extra=["--die"])
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
server.serve()
|
||||
self.assertEqual(len(ports), 3)
|
||||
|
||||
def test_a_late_crash_is_not_retried(self):
|
||||
# A program that ran for a while before dying was not a bind race: it
|
||||
# would die the same way on any port.
|
||||
self.patch_attr(ggml, "EARLY_EXIT_WINDOW", 0.2)
|
||||
ports = self.count_ports()
|
||||
server = self.server(extra=["--wait", "0.5", "--die"])
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
server.serve()
|
||||
self.assertEqual(len(ports), 1)
|
||||
self.assertIn("no such file", str(caught.exception))
|
||||
|
||||
def test_an_open_port_with_a_dead_child_is_not_ready(self):
|
||||
# Another process winning the bind race leaves the port open while our
|
||||
# child exits: the open port alone must not be read as ready.
|
||||
polls = iter([None, 2])
|
||||
proc = mock.Mock()
|
||||
proc.poll = lambda: next(polls)
|
||||
self.patch_attr(ggml, "_listening", lambda port: True)
|
||||
reason, listened = self.server()._wait_ready(proc, 1)
|
||||
self.assertEqual(reason, "exited")
|
||||
self.assertFalse(listened)
|
||||
|
||||
def test_an_open_port_with_a_child_that_outlives_it_a_beat_is_ready(self):
|
||||
proc = mock.Mock()
|
||||
proc.poll = lambda: None
|
||||
self.patch_attr(ggml, "_listening", lambda port: True)
|
||||
reason, listened = self.server()._wait_ready(proc, 1)
|
||||
self.assertEqual(reason, "ready")
|
||||
self.assertTrue(listened)
|
||||
|
||||
def test_a_model_that_is_still_loading_is_not_ready_yet(self):
|
||||
# llama binds its port first and answers /health with 503 until the
|
||||
# model is in memory, so the open port on its own is not the signal.
|
||||
@@ -614,6 +744,72 @@ class Servers(Local):
|
||||
def test_no_pid_file_is_nothing_to_sweep(self):
|
||||
self.assertFalse(self.server().sweep())
|
||||
|
||||
def test_an_unverifiable_pid_is_kept_for_a_later_sweep(self):
|
||||
# Could not be checked is not the same as known stale: dropping the
|
||||
# file here would lose track of a server that may still hold a model.
|
||||
server = self.server()
|
||||
server._remember(4242)
|
||||
with mock.patch.object(server, "_is_ours", return_value=None):
|
||||
self.assertFalse(server.sweep())
|
||||
self.assertTrue(server._pid_file().exists())
|
||||
|
||||
def test_a_pid_known_stale_is_forgotten(self):
|
||||
server = self.server()
|
||||
server._remember(4242)
|
||||
with mock.patch.object(server, "_is_ours", return_value=False):
|
||||
self.assertFalse(server.sweep())
|
||||
self.assertFalse(server._pid_file().exists())
|
||||
|
||||
def test_the_pid_file_goes_once_the_kill_was_attempted(self):
|
||||
server = self.server()
|
||||
server._remember(4242)
|
||||
with mock.patch.object(server, "_is_ours", return_value=True):
|
||||
with mock.patch.object(ggml.os, "kill") as kill:
|
||||
self.assertTrue(server.sweep())
|
||||
kill.assert_called_once_with(4242, signal.SIGTERM)
|
||||
self.assertFalse(server._pid_file().exists())
|
||||
|
||||
def test_forget_leaves_a_pid_file_that_is_no_longer_ours(self):
|
||||
server = self.server()
|
||||
server._remember(111)
|
||||
# A Dikte started after us wrote its own server's pid over ours;
|
||||
# removing the file would hide that server from every future sweep.
|
||||
server._pid_file().write_text("222")
|
||||
server._forget()
|
||||
self.assertEqual(server._pid_file().read_text(), "222")
|
||||
|
||||
def test_forget_removes_the_pid_it_remembered(self):
|
||||
server = self.server()
|
||||
server._remember(111)
|
||||
server._forget()
|
||||
self.assertFalse(server._pid_file().exists())
|
||||
|
||||
def test_stop_waits_out_a_start_in_flight_and_kills_it(self):
|
||||
# stop_all on quit must not slide past a launch that is mid-load: the
|
||||
# child would then survive Dikte with nothing left that knows its pid.
|
||||
children = []
|
||||
real = ggml.subprocess.Popen
|
||||
|
||||
def popen(*args, **kwargs):
|
||||
proc = real(*args, **kwargs)
|
||||
children.append(proc)
|
||||
return proc
|
||||
|
||||
self.patch_attr(ggml.subprocess, "Popen", popen)
|
||||
server = self.server(extra=["--wait", "0.5"])
|
||||
thread = threading.Thread(target=server.serve)
|
||||
thread.start()
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while not children and time.monotonic() < deadline:
|
||||
time.sleep(0.01) # until the launch is truly in flight
|
||||
server.stop()
|
||||
finally:
|
||||
thread.join(timeout=10)
|
||||
self.assertEqual(len(children), 1)
|
||||
self.assertIsNotNone(children[0].poll())
|
||||
self.assertFalse(server.running)
|
||||
|
||||
def test_a_start_that_goes_wrong_takes_its_process_with_it(self):
|
||||
started = []
|
||||
|
||||
@@ -752,6 +948,10 @@ class InstallOnWindows(Local):
|
||||
super().setUp()
|
||||
self.patch_attr(sys, "platform", "win32")
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
# shutil.which cannot be allowed through to the real one: standing on
|
||||
# win32 from another system, Python 3.12's Windows branch of which()
|
||||
# reaches for the nt module that is not there.
|
||||
self.enterContext(mock.patch("shutil.which", return_value=None))
|
||||
self.archive = zipball({
|
||||
"Release/whisper-server.exe": b"MZ not really a program",
|
||||
"Release/whisper.dll": b"not really a library",
|
||||
@@ -785,3 +985,43 @@ class InstallOnWindows(Local):
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertIn("this machine", str(caught.exception))
|
||||
|
||||
|
||||
class WindowsOwnership(Local):
|
||||
"""Sweeping on Windows goes by the executable's full path, never its name:
|
||||
the base name alone is anyone's whisper-server.exe, and this answer decides
|
||||
what gets killed."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(sys, "platform", "win32")
|
||||
# See InstallOnWindows: the real which() on win32 wants the nt module.
|
||||
self.enterContext(mock.patch("shutil.which", return_value=None))
|
||||
self.made = ggml.Server(ggml.WHISPER, lambda values: [], {"binary": ""})
|
||||
|
||||
def image(self, path):
|
||||
self.patch_attr(ggml, "_win_image_name", lambda pid: path)
|
||||
|
||||
def test_a_binary_under_our_bin_directory_is_ours(self):
|
||||
self.image(str(ggml.BIN_DIR / "whisper" / "v1.9.1" / "whisper-server.exe"))
|
||||
self.assertIs(self.made._is_ours(1234), True)
|
||||
|
||||
def test_the_configured_binary_is_ours_wherever_it_lives(self):
|
||||
mine = self.path("elsewhere", "whisper-server.exe")
|
||||
mine.parent.mkdir(parents=True)
|
||||
mine.write_bytes(b"MZ")
|
||||
mine.chmod(0o755)
|
||||
self.made._settings["binary"] = str(mine)
|
||||
self.image(str(mine.resolve()))
|
||||
self.assertIs(self.made._is_ours(1234), True)
|
||||
|
||||
def test_the_same_name_somewhere_else_is_not_ours(self):
|
||||
self.image(str(self.path("theirs", "whisper-server.exe")))
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
self.assertIs(self.made._is_ours(1234), False)
|
||||
|
||||
def test_a_process_that_cannot_be_read_is_no_verdict(self):
|
||||
# OpenProcess answering nothing covers both "gone" and "not readable
|
||||
# from here", and only one of those makes the pid file safe to drop.
|
||||
self.image("")
|
||||
self.assertIsNone(self.made._is_ours(1234))
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
|
||||
from dikte import hub
|
||||
from dikte import paths
|
||||
from tests.support import DikteTest, fake_urlopen, http_error, url_error
|
||||
|
||||
RELEASE = {
|
||||
@@ -165,6 +166,15 @@ def os_utime(path):
|
||||
os.utime(path, (old, old))
|
||||
|
||||
|
||||
class CacheLocation(DikteTest):
|
||||
"""Resolved at import, like every other path constant."""
|
||||
|
||||
def test_the_cache_lives_in_the_system_cache_directory(self):
|
||||
# One answer for both, the same way ggml and config share DATA_DIR:
|
||||
# hub asked paths once, at import, and kept what it was told.
|
||||
self.assertEqual(hub.CACHE_DIR, paths.cache_dir())
|
||||
|
||||
|
||||
class CacheOnDisk(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
@@ -475,6 +475,17 @@ class Windows(unittest.TestCase):
|
||||
self.installed = pathlib.Path(self.tmp.name).resolve()
|
||||
self.app = self.installed / "Dikte.exe"
|
||||
self.app.write_text("")
|
||||
# APPDATA pointed into the sandbox, so that the Startup folder these
|
||||
# tests delete from is never the machine's own.
|
||||
appdata = mock.patch.dict(os.environ,
|
||||
{"APPDATA": str(self.installed / "Roaming")})
|
||||
appdata.start()
|
||||
self.addCleanup(appdata.stop)
|
||||
|
||||
def startup_shortcut(self):
|
||||
"""Where install.ps1 -Autostart puts a checkout's sign-in entry."""
|
||||
return (self.installed / "Roaming" / "Microsoft" / "Windows"
|
||||
/ "Start Menu" / "Programs" / "Startup" / "Dikte.lnk")
|
||||
|
||||
def _write(self, command):
|
||||
self.value = command
|
||||
@@ -510,6 +521,45 @@ class Windows(unittest.TestCase):
|
||||
self.assertEqual(len(self.install()), 1)
|
||||
self.assertEqual(self.value, f'"{self.app}"')
|
||||
|
||||
def test_an_entry_for_another_working_install_is_left_alone(self):
|
||||
"""The same courtesy the Linux half pays another menu entry: an entry
|
||||
naming an executable that still exists is an installation that still
|
||||
works, and a start of this one has no business redirecting it."""
|
||||
other = self.installed / "Elsewhere" / "Dikte.exe"
|
||||
other.parent.mkdir()
|
||||
other.write_text("")
|
||||
self.value = f'"{other}"'
|
||||
self.assertEqual(self.install(), [])
|
||||
self.assertEqual(self.value, f'"{other}"')
|
||||
|
||||
def test_asking_outright_overrules_a_working_other_install(self):
|
||||
other = self.installed / "Elsewhere" / "Dikte.exe"
|
||||
other.parent.mkdir()
|
||||
other.write_text("")
|
||||
self.value = f'"{other}"'
|
||||
self.assertEqual(self.install(force=True), [integrate._run_entry_name()])
|
||||
self.assertEqual(self.value, f'"{self.app}"')
|
||||
|
||||
def test_typing_it_sweeps_away_a_checkout_startup_shortcut(self):
|
||||
"""install.ps1 -Autostart writes it, the Run value replaces it, and
|
||||
both left in place would be two Diktes at every sign-in."""
|
||||
shortcut = self.startup_shortcut()
|
||||
shortcut.parent.mkdir(parents=True)
|
||||
shortcut.write_text("")
|
||||
changed = self.install(force=True)
|
||||
self.assertIn(shortcut, changed)
|
||||
self.assertFalse(shortcut.exists())
|
||||
|
||||
def test_a_start_leaves_a_checkout_startup_shortcut_alone(self):
|
||||
"""The silent call on every start has not been asked to move the
|
||||
machine off its checkout."""
|
||||
shortcut = self.startup_shortcut()
|
||||
shortcut.parent.mkdir(parents=True)
|
||||
shortcut.write_text("")
|
||||
self.value = f'"{self.app}"'
|
||||
self.assertEqual(self.install(), [])
|
||||
self.assertTrue(shortcut.exists())
|
||||
|
||||
def test_running_it_again_changes_nothing(self):
|
||||
self.install(force=True)
|
||||
self.assertEqual(self.install(), [])
|
||||
@@ -521,6 +571,31 @@ class Windows(unittest.TestCase):
|
||||
self.assertEqual(self.remove(), [])
|
||||
|
||||
|
||||
class WindowedExecutable(unittest.TestCase):
|
||||
"""The windowed executable, looked up beside whichever one is running.
|
||||
|
||||
Beside rather than at a known place: the setup lays both executables into
|
||||
one directory wherever that directory was put, so either can find the
|
||||
other without knowing where the install is.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.installed = pathlib.Path(self.tmp.name).resolve()
|
||||
|
||||
def test_found_beside_the_named_executable(self):
|
||||
windowed = self.installed / "Dikte.exe"
|
||||
windowed.write_text("")
|
||||
self.assertEqual(
|
||||
integrate.windowed_executable(str(self.installed / "dikte-cli.exe")),
|
||||
windowed)
|
||||
|
||||
def test_none_when_no_setup_installed_one(self):
|
||||
self.assertIsNone(
|
||||
integrate.windowed_executable(str(self.installed / "dikte-cli.exe")))
|
||||
|
||||
|
||||
class WindowsExecutableNames(unittest.TestCase):
|
||||
"""The two Windows executables, read out of the files that name them.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import unittest
|
||||
from unittest import mock
|
||||
|
||||
from dikte import ipc
|
||||
from tests.support import DikteTest
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
@@ -185,5 +186,74 @@ class Send(unittest.TestCase):
|
||||
self.assertTrue(sock.disconnected)
|
||||
|
||||
|
||||
class AlreadyServing(unittest.TestCase):
|
||||
"""The single-instance check, which listen() cannot be: a Windows pipe
|
||||
takes a second server on the same name rather than refusing it."""
|
||||
|
||||
def probe(self, socket):
|
||||
with mock.patch.object(ipc, "QLocalSocket", return_value=socket):
|
||||
return ipc.already_serving()
|
||||
|
||||
def test_nothing_running_means_go_ahead(self):
|
||||
self.assertFalse(self.probe(FakeSocket(connected=False)))
|
||||
|
||||
def test_an_answer_means_yield(self):
|
||||
self.assertTrue(self.probe(FakeSocket(reply=b'{"ok": true}\n')))
|
||||
|
||||
def test_the_probe_has_no_side_effect(self):
|
||||
"""A probe that opened a window would open it during the relaunch a
|
||||
slow instance provokes, on top of the verb being forwarded."""
|
||||
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||
self.probe(sock)
|
||||
self.assertEqual(sock.written.decode("utf-8").strip(), "status")
|
||||
|
||||
def test_an_instance_too_old_to_answer_still_counts_as_running(self):
|
||||
self.assertTrue(self.probe(FakeSocket(reply=b"")))
|
||||
|
||||
|
||||
class InstanceLock(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# The lock derives its home from paths, which DikteTest's cfg patches
|
||||
# do not cover; without this the test would write into the real one.
|
||||
from dikte import paths
|
||||
self.patch_attr(paths, "DATA_DIR", self.path("data"))
|
||||
|
||||
def test_one_holder_at_a_time(self):
|
||||
first = ipc.instance_lock()
|
||||
self.assertIsNotNone(first)
|
||||
self.assertTrue(first.tryLock(0))
|
||||
second = ipc.instance_lock()
|
||||
self.assertFalse(second.tryLock(0))
|
||||
first.unlock()
|
||||
self.assertTrue(second.tryLock(0))
|
||||
second.unlock()
|
||||
|
||||
def test_the_lock_lives_in_the_data_directory(self):
|
||||
from dikte import paths
|
||||
lock = ipc.instance_lock()
|
||||
self.assertTrue(lock.tryLock(0))
|
||||
self.assertTrue((paths.DATA_DIR / "dikte.lock").exists())
|
||||
lock.unlock()
|
||||
|
||||
|
||||
class Respawn(unittest.TestCase):
|
||||
def test_windows_starts_a_detached_process_and_returns(self):
|
||||
with mock.patch.object(sys, "platform", "win32"), \
|
||||
mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \
|
||||
mock.patch.object(ipc.subprocess, "Popen") as popen:
|
||||
ipc.respawn(["--gui"])
|
||||
self.assertEqual(popen.call_args.args[0], ["py", "x", "--gui"])
|
||||
self.assertEqual(popen.call_args.kwargs["creationflags"],
|
||||
0x00000008 | 0x00000200)
|
||||
|
||||
def test_everywhere_else_the_process_is_replaced(self):
|
||||
with mock.patch.object(sys, "platform", "linux"), \
|
||||
mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \
|
||||
mock.patch.object(ipc.os, "execv") as execv:
|
||||
ipc.respawn(["toggle", "--gui"])
|
||||
execv.assert_called_once_with("py", ["py", "x", "toggle", "--gui"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -444,6 +444,45 @@ class MacOS(ClipboardContract, DikteTest):
|
||||
self.assertFalse(paste.paste_ready())
|
||||
|
||||
|
||||
class MacPasteGoesWhereTheDictationStarted(MacOS):
|
||||
"""The keys land in the frontmost window, so the front is what decides
|
||||
where a transcript ends up."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import mac_window
|
||||
self.mac_window = mac_window
|
||||
self.activated = []
|
||||
self.patch_attr(mac_window, "activate", self.activated.append)
|
||||
|
||||
def frontmost(self, dikte_is):
|
||||
self.patch_attr(self.mac_window, "is_frontmost", lambda: dikte_is)
|
||||
|
||||
def test_a_dikte_that_took_the_front_hands_it_back_before_pressing(self):
|
||||
self.frontmost(True)
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertEqual([event for _, event in self.api.posted], [1001, 1002])
|
||||
|
||||
def test_another_application_in_front_is_where_the_user_went_and_is_left(self):
|
||||
self.frontmost(False)
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_a_run_that_remembered_nobody_asks_nothing(self):
|
||||
self.frontmost(True)
|
||||
paste.press("cmd+v")
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_the_front_is_handed_back_only_once_macos_trusts_dikte(self):
|
||||
"""Pulling the user out of their window and then failing to type would
|
||||
be the worst of both."""
|
||||
self.frontmost(True)
|
||||
self.api.trusted = False
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
class MacClipboardSnapshot(DikteTest):
|
||||
def test_every_native_type_is_restored_and_the_files_are_removed(self):
|
||||
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
|
||||
|
||||
@@ -61,6 +61,34 @@ class Directories(unittest.TestCase):
|
||||
self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte"))
|
||||
|
||||
|
||||
class CacheDir(unittest.TestCase):
|
||||
"""The third place: files whose whole point is that they can be lost."""
|
||||
|
||||
def test_linux_follows_xdg(self):
|
||||
with mock.patch.dict(os.environ, {"XDG_CACHE_HOME": "/k"}):
|
||||
self.assertEqual(paths.cache_dir("linux").as_posix(), "/k/dikte")
|
||||
|
||||
def test_linux_without_the_variable_set(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertTrue(paths.cache_dir("linux").as_posix()
|
||||
.endswith("/.cache/dikte"))
|
||||
|
||||
def test_a_mac_caches_under_library_caches(self):
|
||||
"""Where Time Machine already knows not to look."""
|
||||
self.assertTrue(paths.cache_dir("darwin").as_posix()
|
||||
.endswith("/Library/Caches/Dikte"))
|
||||
|
||||
def test_windows_caches_outside_the_roaming_profile(self):
|
||||
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "C:/local"}):
|
||||
self.assertEqual(paths.cache_dir("win32").as_posix(),
|
||||
"C:/local/Dikte/cache")
|
||||
|
||||
def test_windows_without_the_variable_set(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertTrue(paths.cache_dir("win32").as_posix()
|
||||
.endswith("/AppData/Local/Dikte/cache"))
|
||||
|
||||
|
||||
class OnePlace(unittest.TestCase):
|
||||
"""The programs and the models go where everything else goes.
|
||||
|
||||
|
||||
+362
-3
@@ -133,6 +133,8 @@ class Settings(DikteTest):
|
||||
"_load_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_transcribe_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"))
|
||||
# The local model boxes fetch their own list the moment they are shown,
|
||||
# from a thread, which is nobody's test failing but a real request.
|
||||
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
||||
@@ -240,6 +242,21 @@ class Settings(DikteTest):
|
||||
with self.subTest(key=key):
|
||||
self.assertEqual(stored[key], value)
|
||||
|
||||
def test_only_a_save_that_changed_the_language_says_so(self):
|
||||
# The owner answers language_changed by replacing the window, so a
|
||||
# save that left the language alone must keep quiet.
|
||||
i18n = settings_ui.i18n
|
||||
self.addCleanup(i18n.set_language, i18n.language())
|
||||
window = self.window(cfg.Config())
|
||||
heard = []
|
||||
window.language_changed.connect(lambda: heard.append(True))
|
||||
window._save()
|
||||
self.assertEqual(heard, [])
|
||||
other = "en" if i18n.language() == "tr" else "tr"
|
||||
window._select_data(window.ui_language, other)
|
||||
window._save()
|
||||
self.assertEqual(heard, [True])
|
||||
|
||||
def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self):
|
||||
"""An OpenRouter id and a Claude alias are not the same field."""
|
||||
window = self.window(cfg.Config())
|
||||
@@ -254,6 +271,19 @@ class Settings(DikteTest):
|
||||
self.assertEqual(shown, [provider])
|
||||
self.assertFalse(box.isHidden())
|
||||
|
||||
def test_codex_answering_refills_both_of_its_boxes(self):
|
||||
"""The list Codex gave replaces the built-in one, in both places, and
|
||||
neither loses what was already picked."""
|
||||
conf = self.config(cleanup_codex_model="my-own-model")
|
||||
window = self.window(conf)
|
||||
window._on_codex_models_loaded(["gpt-6", "gpt-6-mini"])
|
||||
for combo in (window.cleanup_codex_model, window.assistant_codex_model):
|
||||
with self.subTest(combo=combo.objectName() or "combo"):
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered[1:], ["gpt-6", "gpt-6-mini"])
|
||||
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||||
"my-own-model")
|
||||
|
||||
def test_the_update_line_names_the_version_that_is_running(self):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertIn(settings_ui.__version__, window.update_status.text())
|
||||
@@ -518,6 +548,321 @@ class KdeSettings(Settings):
|
||||
self.assertFalse(window.evdev_enabled.isHidden())
|
||||
|
||||
|
||||
|
||||
|
||||
class FakeAppKit:
|
||||
"""The Objective-C runtime, answering rather than being asked.
|
||||
|
||||
Stands in for what mac_window._appkit() loads, so what the indicator's
|
||||
window would have been sent can be read off `told` on any machine. The
|
||||
selectors come back as the names they were registered under, which is what
|
||||
lets the tests below name the message they mean.
|
||||
"""
|
||||
|
||||
def __init__(self, window=4242, panel=True, mask=0xe):
|
||||
self.objc = self
|
||||
self.window, self.panel, self.mask = window, panel, mask
|
||||
self.told = {}
|
||||
|
||||
def objc_getClass(self, name):
|
||||
return 1 if name == b"NSPanel" else 0
|
||||
|
||||
def selector(self, name):
|
||||
return name.decode()
|
||||
|
||||
def shared(self, _class_name, _selector):
|
||||
return 1
|
||||
|
||||
def ask(self, _to, selector):
|
||||
return self.window if selector == "window" else 0
|
||||
|
||||
def ask_unsigned(self, _to, _selector):
|
||||
return self.mask
|
||||
|
||||
def ask_of_class(self, _to, _selector, _klass):
|
||||
return self.panel
|
||||
|
||||
def tell_bool(self, _to, selector, value):
|
||||
self.told[selector] = value
|
||||
|
||||
tell_unsigned = tell_bool
|
||||
|
||||
|
||||
class MacIndicatorWindow(DikteTest):
|
||||
"""Which of the three AppKit settings the indicator's window is sent.
|
||||
|
||||
The nonactivating bit is the one worth a test of its own: it is legal on an
|
||||
NSPanel and nowhere else, and sending it to a plain NSWindow raises an
|
||||
Objective-C exception, which through ctypes is not something Python can
|
||||
catch. It takes the whole process down. `_is_panel` is the only thing
|
||||
standing between the two, so what it answers has to decide what is sent.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import mac_window
|
||||
self.mac_window = mac_window
|
||||
self.patch_attr(mac_window.QGuiApplication, "platformName",
|
||||
staticmethod(lambda: "cocoa"))
|
||||
|
||||
def told(self, **kwargs):
|
||||
"""What keep_on_screen sends an indicator, against this AppKit."""
|
||||
appkit = FakeAppKit(**kwargs)
|
||||
self.patch_attr(self.mac_window, "_appkit", lambda: appkit)
|
||||
widget = overlay_module.Overlay()
|
||||
self.addCleanup(widget.deleteLater)
|
||||
self.addCleanup(widget.close)
|
||||
self.answered = self.mac_window.keep_on_screen(widget)
|
||||
return appkit.told
|
||||
|
||||
def test_a_window_that_is_not_a_panel_is_never_sent_the_style_mask(self):
|
||||
"""The message that would kill the process. The other two still go."""
|
||||
told = self.told(panel=False)
|
||||
self.assertTrue(self.answered)
|
||||
self.assertNotIn("setStyleMask:", told)
|
||||
self.assertIs(told["setHidesOnDeactivate:"], False)
|
||||
self.assertEqual(told["setCollectionBehavior:"],
|
||||
self.mac_window.BEHAVIOUR)
|
||||
|
||||
def test_a_panel_without_the_bit_is_sent_the_mask_with_it_added(self):
|
||||
told = self.told(panel=True, mask=0xe)
|
||||
self.assertEqual(told["setStyleMask:"],
|
||||
0xe | self.mac_window.NONACTIVATING_PANEL)
|
||||
|
||||
def test_a_panel_that_already_has_the_bit_is_left_alone(self):
|
||||
"""Every dictation runs this again, and a mask Cocoa did not need is a
|
||||
window it rebuilds underneath the indicator."""
|
||||
told = self.told(panel=True,
|
||||
mask=0xe | self.mac_window.NONACTIVATING_PANEL)
|
||||
self.assertNotIn("setStyleMask:", told)
|
||||
|
||||
def test_a_window_the_view_does_not_have_yet_is_not_messaged(self):
|
||||
self.assertEqual(self.told(window=0), {})
|
||||
self.assertFalse(self.answered)
|
||||
|
||||
def test_nothing_is_sent_anywhere_but_cocoa(self):
|
||||
"""Every other platform hands out a winId that means something else
|
||||
entirely, and messaging it crashes the run."""
|
||||
self.patch_attr(self.mac_window.QGuiApplication, "platformName",
|
||||
staticmethod(lambda: "offscreen"))
|
||||
self.assertEqual(self.told(), {})
|
||||
self.assertFalse(self.answered)
|
||||
|
||||
|
||||
class GivingTheFrontBack(DikteTest):
|
||||
"""Opening the microphone brings Dikte to the front, and the window the
|
||||
user was dictating into goes inactive with the caret in it. Nothing can be
|
||||
asked of the capture session, so the front is put back afterwards.
|
||||
|
||||
The watch is driven by hand here: what matters is what it decides, not how
|
||||
long Qt takes to tick.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import app as dikte_module
|
||||
from dikte import mac_window
|
||||
self.dikte = dikte_module
|
||||
self.activated = []
|
||||
self.patch_attr(mac_window, "activate", self.activate)
|
||||
self.ticks = []
|
||||
outer = self
|
||||
|
||||
class FakeTimer:
|
||||
"""Records what it was asked to do and hands over the tick."""
|
||||
|
||||
def __init__(self, _parent):
|
||||
self.interval = None
|
||||
self.running = False
|
||||
outer.ticks.append(self)
|
||||
|
||||
def setInterval(self, milliseconds):
|
||||
self.interval = milliseconds
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
@property
|
||||
def timeout(self):
|
||||
return self
|
||||
|
||||
def connect(self, slot):
|
||||
self.tick = slot
|
||||
|
||||
self.patch_attr(dikte_module, "QTimer", FakeTimer)
|
||||
|
||||
class BareDikte:
|
||||
"""As much of the application as this one method touches."""
|
||||
|
||||
app = None
|
||||
_front_watch = None
|
||||
_the_front = dikte_module.Dikte._the_front
|
||||
_give_the_front_back = dikte_module.Dikte._give_the_front_back
|
||||
_stop_watching_the_front = dikte_module.Dikte._stop_watching_the_front
|
||||
|
||||
self.bare = BareDikte
|
||||
|
||||
def activate(self, pid):
|
||||
self.activated.append(pid)
|
||||
return True
|
||||
|
||||
def watching(self, was_in_front, dikte_in_front, on=None):
|
||||
from dikte import mac_window
|
||||
self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front)
|
||||
dikte = on if on is not None else self.bare()
|
||||
dikte._give_the_front_back(was_in_front)
|
||||
return self.ticks[-1] if self.ticks else None
|
||||
|
||||
def test_the_front_goes_back_to_whoever_had_it(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertTrue(watch.running) # accepted is not the same as landed
|
||||
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False)
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
|
||||
def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertTrue(watch.running)
|
||||
|
||||
def test_an_accepted_restore_that_never_lands_still_times_out(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
with mock.patch.object(self.dikte.time, "monotonic",
|
||||
return_value=self.dikte.time.monotonic() + 60):
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
self.assertEqual(self.activated, [4242])
|
||||
|
||||
def test_a_restore_the_system_refused_is_retried(self):
|
||||
from dikte import mac_window
|
||||
self.patch_attr(mac_window, "activate",
|
||||
lambda pid: self.activated.append(pid) or False)
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242, 4242])
|
||||
self.assertTrue(watch.running)
|
||||
|
||||
def test_a_front_that_was_never_taken_is_left_where_it_is(self):
|
||||
"""The microphone does not always take it, and pulling an application
|
||||
forward that is already there is one flicker for nothing."""
|
||||
watch = self.watching(4242, dikte_in_front=False)
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [])
|
||||
self.assertTrue(watch.running) # still waiting for the moment
|
||||
|
||||
def test_it_gives_up_rather_than_watching_for_ever(self):
|
||||
watch = self.watching(4242, dikte_in_front=False)
|
||||
with mock.patch.object(self.dikte.time, "monotonic",
|
||||
return_value=self.dikte.time.monotonic() + 60):
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_a_dictation_started_in_dikte_itself_watches_nothing(self):
|
||||
"""Settings is a window of ours, and the front is already where it
|
||||
belongs."""
|
||||
self.assertIsNone(self.watching(os.getpid(), dikte_in_front=True))
|
||||
|
||||
def test_a_second_recording_calls_off_the_watch_the_first_one_left(self):
|
||||
"""The older watch remembers where the older recording started, and by
|
||||
now that is the wrong window to be pulling forward."""
|
||||
dikte = self.bare()
|
||||
first = self.watching(4242, dikte_in_front=False, on=dikte)
|
||||
second = self.watching(1111, dikte_in_front=False, on=dikte)
|
||||
self.assertFalse(first.running)
|
||||
self.assertTrue(second.running)
|
||||
second.tick() # and the survivor is the new one
|
||||
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: True)
|
||||
second.tick()
|
||||
self.assertEqual(self.activated, [1111])
|
||||
|
||||
def test_a_recording_nobody_needs_watching_for_still_calls_off_the_old_one(self):
|
||||
"""Starting the next one from Dikte's own window is not a reason to
|
||||
leave the last one's watch running."""
|
||||
dikte = self.bare()
|
||||
first = self.watching(4242, dikte_in_front=False, on=dikte)
|
||||
self.watching(os.getpid(), dikte_in_front=False, on=dikte)
|
||||
self.assertFalse(first.running)
|
||||
self.assertIsNone(dikte._front_watch)
|
||||
|
||||
def test_nobody_in_front_is_nobody_to_go_back_to(self):
|
||||
self.assertIsNone(self.watching(None, dikte_in_front=True))
|
||||
|
||||
def mac_window_module(self):
|
||||
from dikte import mac_window
|
||||
return mac_window
|
||||
|
||||
|
||||
class EveryRecordingProtectsTheFront(DikteTest):
|
||||
"""Three ways in, dictation, agent and meeting, and all three open the same
|
||||
avfoundation capture, so all three take the front the same way. What is
|
||||
checked here is the order: the front has to be noted before the microphone
|
||||
is opened, and the watch armed after, or there is nothing to go back to.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import app as dikte_module
|
||||
self.dikte = dikte_module
|
||||
self.order = []
|
||||
|
||||
def app(self, **attributes):
|
||||
"""A Dikte that records the order it does things in, and nothing else.
|
||||
|
||||
The methods under test are called unbound against it, so the stand-ins
|
||||
go on the object rather than on the class.
|
||||
"""
|
||||
dikte = mock.Mock(**attributes)
|
||||
dikte._run_id = 0
|
||||
dikte.conf = {"mic_target": "", "max_seconds": 60,
|
||||
"meeting_mic_target": "", "meeting_system_target": "",
|
||||
"meeting_max_seconds": 60}
|
||||
dikte._the_front.side_effect = lambda: self.order.append("noted") or 4242
|
||||
dikte._give_the_front_back.side_effect = (
|
||||
lambda pid: self.order.append(f"watching {pid}"))
|
||||
dikte._begin_recording = (
|
||||
lambda owner: self.dikte.Dikte._begin_recording(dikte, owner))
|
||||
dikte.recorder.start.side_effect = (
|
||||
lambda *_a: self.order.append("microphone"))
|
||||
dikte.meeting_recorder.start.side_effect = (
|
||||
lambda *_a: self.order.append("microphone"))
|
||||
return dikte
|
||||
|
||||
def test_a_dictation_notes_the_front_before_the_indicator_is_even_shown(self):
|
||||
dikte = self.app(state=self.dikte.IDLE, recording=False)
|
||||
dikte.overlay.show_recording.side_effect = (
|
||||
lambda *_a: self.order.append("indicator"))
|
||||
self.dikte.Dikte.start(dikte)
|
||||
self.assertEqual(self.order,
|
||||
["noted", "indicator", "microphone", "watching 4242"])
|
||||
|
||||
def test_the_agent_does_the_same(self):
|
||||
dikte = self.app(ask_state=self.dikte.IDLE, recording=False)
|
||||
self.dikte.Dikte.start_ask(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
|
||||
|
||||
def test_a_meeting_does_the_same(self):
|
||||
"""The one most worth protecting: the user is in a call."""
|
||||
dikte = self.app(meeting_state=self.dikte.M_IDLE)
|
||||
dikte.meeting_recorder.active = True
|
||||
self.dikte.Dikte.start_meeting(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
|
||||
|
||||
def test_a_meeting_whose_microphone_never_opened_watches_nothing(self):
|
||||
dikte = self.app(meeting_state=self.dikte.M_IDLE)
|
||||
dikte.meeting_recorder.active = False
|
||||
self.dikte.Dikte.start_meeting(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone"])
|
||||
|
||||
class Overlay(DikteTest):
|
||||
def overlay(self, **kwargs):
|
||||
widget = overlay_module.Overlay(**kwargs)
|
||||
@@ -654,7 +999,9 @@ class MeetingSources(DikteTest):
|
||||
only_these_tools(), \
|
||||
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_transcribe_models"):
|
||||
"_load_transcribe_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"):
|
||||
window = settings_ui.SettingsWindow(cfg.Config())
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
@@ -683,6 +1030,9 @@ class LocalModels(DikteTest):
|
||||
# "nothing can transcribe" question from its real binary and model.
|
||||
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
|
||||
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
||||
# And one with Codex on it would ask it for its model list.
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"))
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf)
|
||||
@@ -719,12 +1069,21 @@ class LocalModels(DikteTest):
|
||||
# Qt's int is C++'s 32-bit one, and a 2.3 GB model is more than fits in
|
||||
# it: the count came out the far side negative, at "-1%".
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box._downloading = True
|
||||
box._report(1_048_576, 2_489_757_856)
|
||||
box._report("model", 1_048_576, 2_489_757_856)
|
||||
_app.processEvents()
|
||||
self.assertIn("2.3 GB", box.status.text())
|
||||
self.assertNotIn("-", box.status.text())
|
||||
|
||||
def test_each_download_reports_into_its_own_label(self):
|
||||
"""The two can run at once; the tag, not a flag read later, says
|
||||
which label the bytes belong to."""
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box._report("program", 10, 100)
|
||||
box._report("model", 20, 100)
|
||||
_app.processEvents()
|
||||
self.assertIn("10", box.program_label.text())
|
||||
self.assertIn("20", box.status.text())
|
||||
|
||||
def test_a_long_model_name_is_not_cut_in_half(self):
|
||||
# The list under a combo box takes the box's width and elides what does
|
||||
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
||||
|
||||
+8
-2
@@ -128,6 +128,12 @@ class Hallucinations(DikteTest):
|
||||
self.assertFalse(vad.looks_like_hallucination("Bugün toplantı var.", 2.0))
|
||||
self.assertFalse(vad.looks_like_hallucination("Send it on Thursday.", 2.0))
|
||||
|
||||
def test_a_one_word_answer_is_believed(self):
|
||||
# Whisper invents both over silence, but people dictate both as whole
|
||||
# answers, and losing a real answer costs more than passing a fake one.
|
||||
self.assertFalse(vad.looks_like_hallucination("You.", 1.5))
|
||||
self.assertFalse(vad.looks_like_hallucination("Bye.", 1.5))
|
||||
|
||||
def test_an_empty_transcript_counts_as_invented(self):
|
||||
self.assertTrue(vad.looks_like_hallucination(" ", 2.0))
|
||||
self.assertTrue(vad.looks_like_hallucination("...", 2.0))
|
||||
@@ -138,8 +144,8 @@ class Hallucinations(DikteTest):
|
||||
self.assertTrue(vad.looks_like_hallucination(text, 2.0))
|
||||
|
||||
def test_the_boundary_is_the_max_duration(self):
|
||||
self.assertTrue(vad.looks_like_hallucination("you", 6.0))
|
||||
self.assertFalse(vad.looks_like_hallucination("you", 6.1))
|
||||
self.assertTrue(vad.looks_like_hallucination("thanks for watching", 6.0))
|
||||
self.assertFalse(vad.looks_like_hallucination("thanks for watching", 6.1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+67
-7
@@ -31,9 +31,11 @@ class Chain(DikteTest):
|
||||
|
||||
def run_chain(self, ask=False, paste_override=None, duration=2.0,
|
||||
transcript="uh, book it for Thursday",
|
||||
transcribe_error=None,
|
||||
cleaned="Book it for Thursday.",
|
||||
cleanup_error=None, answer=("Booked.", ""), rms=None,
|
||||
clipboard=b"what was there before", paste_error=None):
|
||||
clipboard=b"what was there before", paste_error=None,
|
||||
focus=None):
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
done, failures, stages, cancels = [], [], [], []
|
||||
pipeline.finished.connect(lambda *args: done.append(args))
|
||||
@@ -47,7 +49,10 @@ class Chain(DikteTest):
|
||||
# The chain reports its own failures on stderr, which a test run has no
|
||||
# use for.
|
||||
with contextlib.redirect_stderr(io.StringIO()), \
|
||||
mock.patch.object(api, "transcribe", return_value=transcript) as tr, \
|
||||
mock.patch.object(
|
||||
api, "transcribe",
|
||||
**({"side_effect": transcribe_error} if transcribe_error
|
||||
else {"return_value": transcript})) as tr, \
|
||||
mock.patch.object(api, "cleanup", cleanup), \
|
||||
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
|
||||
mock.patch.object(paste, "copy") as copy, \
|
||||
@@ -61,7 +66,8 @@ class Chain(DikteTest):
|
||||
"copy": copy, "copy_bytes": copy_bytes, "press": press,
|
||||
"read_clipboard": read_clipboard}
|
||||
pipeline._work(self.wav, duration,
|
||||
self.rms if rms is None else rms, ask, paste_override)
|
||||
self.rms if rms is None else rms, ask, paste_override,
|
||||
focus)
|
||||
return {"done": done, "failures": failures, "stages": stages,
|
||||
"cancelled": cancels, **calls}
|
||||
|
||||
@@ -73,7 +79,15 @@ class Chain(DikteTest):
|
||||
self.assertEqual(run["done"][0],
|
||||
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
||||
run["copy"].assert_called_once_with("Book it for Thursday.")
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"])
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
|
||||
focus=None)
|
||||
|
||||
def test_the_paste_is_told_where_the_dictation_started(self):
|
||||
"""Whoever was in front when the recording began is where the keys are
|
||||
meant to go, and the press is the only part that can act on it."""
|
||||
run = self.run_chain(focus=4242)
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
|
||||
focus=4242)
|
||||
|
||||
def test_the_stages_are_named_as_they_happen(self):
|
||||
run = self.run_chain()
|
||||
@@ -109,11 +123,57 @@ class Chain(DikteTest):
|
||||
run = self.run_chain()
|
||||
run["copy_bytes"].assert_not_called()
|
||||
|
||||
def test_the_clipboard_is_put_back_when_the_keypress_fails(self):
|
||||
def test_a_failed_keypress_leaves_the_transcript_on_the_clipboard(self):
|
||||
"""The press failing is a warning, not a lost dictation: restoring the
|
||||
old clipboard over the text would leave nothing to paste by hand."""
|
||||
self.conf["restore_clipboard"] = True
|
||||
run = self.run_chain(paste_error=paste.PasteError("not trusted"))
|
||||
self.assertIn("not trusted", run["failures"][0])
|
||||
run["copy_bytes"].assert_called_once_with(b"what was there before")
|
||||
self.assertEqual(run["failures"], [])
|
||||
raw, text, warning = run["done"][0]
|
||||
self.assertIn("not trusted", warning)
|
||||
run["copy_bytes"].assert_not_called()
|
||||
|
||||
def test_a_failed_keypress_still_reaches_the_history(self):
|
||||
self.run_chain(paste_error=paste.PasteError("not trusted"))
|
||||
rows = cfg.read_history()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["text"], "Book it for Thursday.")
|
||||
# The row goes in before the paste is attempted, so the paste failing
|
||||
# has to be written back into it: the record tells the whole truth.
|
||||
self.assertIn("not trusted", rows[0]["cleanup_error"])
|
||||
|
||||
def test_a_failed_transcription_keeps_the_audio(self):
|
||||
"""Speech the user cannot repeat from memory must survive the failure."""
|
||||
run = self.run_chain(transcribe_error=api.ApiError("server down"))
|
||||
self.assertIn("server down", run["failures"][0])
|
||||
self.assertIn("kept", run["failures"][0])
|
||||
kept = list(cfg.RECORDINGS_DIR.glob("*.wav"))
|
||||
self.assertEqual(len(kept), 1)
|
||||
self.assertFalse(os.path.exists(self.wav))
|
||||
|
||||
def test_two_failures_in_one_second_keep_both_recordings(self):
|
||||
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||
self.wav = make_wav(self.path("clip2.wav"), speech(2.0))
|
||||
with mock.patch.object(worker.time, "strftime",
|
||||
return_value="20260820-120000"):
|
||||
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||
self.wav = make_wav(self.path("clip3.wav"), speech(2.0))
|
||||
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||
self.assertEqual(len(list(cfg.RECORDINGS_DIR.glob("*.wav"))), 3)
|
||||
|
||||
def test_the_history_row_says_whether_cleanup_actually_ran(self):
|
||||
"""The ask path cleans under its own setting; the record follows the
|
||||
run, not the dictation gate."""
|
||||
self.conf["cleanup_enabled"] = False
|
||||
self.conf["assistant_cleanup"] = True
|
||||
self.run_chain(ask=True)
|
||||
row = cfg.read_history()[0]
|
||||
self.assertNotEqual(row["cleanup_model"], "")
|
||||
cfg.clear_history()
|
||||
self.conf["cleanup_enabled"] = True
|
||||
self.conf["assistant_cleanup"] = False
|
||||
self.run_chain(ask=True)
|
||||
self.assertEqual(cfg.read_history()[0]["cleanup_model"], "")
|
||||
|
||||
def test_the_transcription_is_told_the_language_and_the_glossary(self):
|
||||
self.conf["language"] = "tr"
|
||||
|
||||
Reference in New Issue
Block a user