Merge master into the Gemini and Antigravity branch

Both sides rewrote the doctor: master rebuilt it around ready flags so a
fully local setup stops reading as broken, while this branch taught it
that Google has a key of its own and that the local model has neither a
key nor a program. Kept master's structure and folded the branch in: the
two hosted providers answer for their keys, a CLI for its program, and
the JSON keeps both the provider-aware "key" and master's "ready".

SECRET_KEYS gained groq on master and gemini here; the resolution keeps
all four. _conclude was also changed by both: master stopped matching
stderr wording and asks _API_TROUBLE instead, this branch renamed its
last parameter to the provider's short name so a session is stored under
the name it is read back by; the rename now rides on master's body.
codex_models() and the settings loaders landed beside the new Gemini
ones, so both stay. The new cleanup tests called the CLI fake by its old
name, fake_run, which master had renamed to fake_cli.
This commit is contained in:
2026-08-27 15:50:48 +03:00
39 changed files with 3441 additions and 471 deletions
+1
View File
@@ -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
View File
@@ -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")
@@ -247,19 +286,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")
@@ -302,6 +329,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",
@@ -312,6 +361,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, "",
@@ -324,14 +378,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:
@@ -669,6 +762,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
View File
@@ -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",)):
+33 -20
View File
@@ -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):
@@ -92,7 +100,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, [])
@@ -132,7 +140,7 @@ class GoogleAiStudio(DikteTest):
self.assertIn("Google AI Studio", 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, [])
@@ -145,7 +153,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]
@@ -198,14 +206,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):
@@ -215,7 +227,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]
@@ -268,7 +280,7 @@ class Antigravity(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]
@@ -389,7 +401,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, [])
+125
View File
@@ -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):
@@ -352,6 +405,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()
@@ -360,6 +430,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):
@@ -432,6 +536,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."""
+22
View File
@@ -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
View File
@@ -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))
+10
View File
@@ -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()
+75
View File
@@ -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.
+70
View File
@@ -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()
+39
View File
@@ -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-")
+28
View File
@@ -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
View File
@@ -137,6 +137,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,
@@ -244,6 +246,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())
@@ -258,6 +275,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())
@@ -522,6 +552,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)
@@ -658,7 +1003,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)
@@ -687,6 +1034,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)
@@ -723,12 +1073,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
View File
@@ -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__":
+102 -13
View File
@@ -8,6 +8,7 @@ afterwards. A pull request that reorders any of it shows up here.
import contextlib
import io
import os
import threading
import unittest
from unittest import mock
@@ -30,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))
@@ -46,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, \
@@ -60,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}
@@ -72,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()
@@ -108,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"
@@ -278,13 +339,41 @@ class Chain(DikteTest):
class Busy(DikteTest):
def test_a_second_run_while_one_is_going_is_ignored(self):
def test_a_second_run_while_one_is_going_waits_its_turn(self):
"""The microphone is free while a transcript is being cleaned up, so
the next dictation can already have been spoken by then. It has to run
once the first is done, in the order they were spoken, on one thread."""
pipeline = worker.Pipeline(self.config())
pipeline._thread = mock.Mock(is_alive=lambda: True)
self.assertTrue(pipeline.busy)
with mock.patch.object(worker.threading, "Thread") as thread:
pipeline.run("/tmp/nope.wav", 1.0)
thread.assert_not_called()
order = []
started, gate = threading.Event(), threading.Event()
def work(wav_path, *_rest):
order.append(wav_path)
started.set()
if wav_path == "first.wav":
gate.wait(5)
with mock.patch.object(pipeline, "_work", side_effect=work):
pipeline.run("first.wav", 1.0)
self.assertTrue(started.wait(5))
pipeline.run("second.wav", 1.0)
# Held, not dropped and not running beside the first.
self.assertEqual(order, ["first.wav"])
gate.set()
pipeline._thread.join(5)
self.assertEqual(order, ["first.wav", "second.wav"])
def test_a_run_arriving_after_the_queue_drained(self):
"""The worker thread ends with the queue; the next run brings one."""
pipeline = worker.Pipeline(self.config())
order = []
with mock.patch.object(pipeline, "_work",
side_effect=lambda wav, *rest: order.append(wav)):
pipeline.run("first.wav", 1.0)
pipeline._thread.join(5)
pipeline.run("second.wav", 1.0)
pipeline._thread.join(5)
self.assertEqual(order, ["first.wav", "second.wav"])
def test_the_chunk_length_matches_the_level_meter(self):
"""The silence thresholds are read in seconds, so the two must agree."""