Merge master into the reliability branch

The paste block was rewritten by both sides: master taught press() to
put the remembered application back in front (focus), this branch moved
the history write ahead of the paste and stopped restoring the old
clipboard over a transcript the key press refused. Kept this branch's
order and error handling, and handed press() the focus it now takes.
This commit is contained in:
2026-08-27 15:23:41 +03:00
24 changed files with 1630 additions and 70 deletions
+4 -1
View File
@@ -25,6 +25,7 @@ from unittest import mock
from dikte import assistant
from dikte import config as cfg
from dikte import i18n
from dikte import update
# What the application is, rather than what it does: PipeWire, wl-clipboard,
# ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces
@@ -86,8 +87,10 @@ class DikteTest(unittest.TestCase):
MEETINGS_FILE=data_dir / "meetings.jsonl",
)
# Resolved from cfg.DATA_DIR when assistant was imported, so it needs
# moving on its own.
# moving on its own. The same goes for where the update check writes
# down when it last ran.
self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json")
self.patch_attr(update, "STATE_FILE", data_dir / "update.json")
i18n.set_language("en")
self.addCleanup(i18n.set_language, "en")
+27
View File
@@ -79,6 +79,33 @@ class Explain(DikteTest):
def test_the_status_is_carried_through(self):
self.assertEqual(self.error(429).status, 429)
def test_so_is_whether_it_is_worth_asking_again(self):
self.assertTrue(self.error(502).retryable)
self.assertFalse(self.error(401).retryable)
class Retryable(unittest.TestCase):
"""Which failures a second try can fix, and which will fail the same way."""
def test_a_gateway_that_gave_up_waiting(self):
for status in (408, 429, 500, 502, 503, 504):
with self.subTest(status=status):
self.assertTrue(api.ApiError("x", status).retryable)
def test_a_request_that_was_wrong(self):
for status in (400, 401, 402, 403, 404, 413, 422):
with self.subTest(status=status):
self.assertFalse(api.ApiError("x", status).retryable)
def test_an_error_of_our_own_is_not_the_network(self):
self.assertFalse(api.ApiError("Transcript came back empty.").retryable)
def test_a_connection_that_dropped_is_worth_a_second_try(self):
with fake_urlopen(url_error("connection reset")):
with self.assertRaises(api.ApiError) as caught:
api._request("https://example.test", b"{}", {})
self.assertTrue(caught.exception.retryable)
class ExtractError(unittest.TestCase):
def test_the_usual_shape(self):
+67 -1
View File
@@ -10,6 +10,8 @@ import contextlib
import io
import json
import unittest
import webbrowser
from typing import ClassVar
from unittest import mock
from dikte import audio
@@ -17,9 +19,11 @@ from dikte import cli
from dikte import config as cfg
from dikte import ggml
from dikte import hotkey
from dikte import hub
from dikte import ipc
from dikte import paste
from tests.support import DikteTest, fake_urlopen, only_these_tools
from dikte import update
from tests.support import DikteTest, fake_urlopen, only_these_tools, url_error
class Options:
@@ -420,6 +424,68 @@ class Providers(DikteTest):
self.assertIn("Groq", out)
class Updates(DikteTest):
"""`dikte update` looks, says what it found, and installs nothing."""
RELEASE: ClassVar[dict] = {
"tag_name": "v9.9.9",
"html_url": "https://github.com/yusufipk/dikte/releases/tag/v9.9.9",
}
def setUp(self):
super().setUp()
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
# Nothing here may reach a browser, whatever the answer turns out to be.
self.opened = []
self.patch_attr(webbrowser, "open", self.opened.append)
def run_update(self, reply, **values):
with fake_urlopen(reply), captured() as (out, err):
code = cli.cmd_update(Options(open=False, **values))
return code, out.getvalue(), err.getvalue()
def test_a_newer_release_is_named_with_its_page(self):
code, out, _ = self.run_update(self.RELEASE)
self.assertEqual(code, 0)
self.assertIn("9.9.9", out)
self.assertIn(self.RELEASE["html_url"], out)
def test_this_build_being_the_newest_is_not_a_failure(self):
code, out, _ = self.run_update({"tag_name": f"v{cli.__version__}"})
self.assertEqual(code, 0)
self.assertIn("newest", out)
def test_the_json_answer_says_both_numbers(self):
code, out, _ = self.run_update(self.RELEASE, json=True)
answer = json.loads(out)
self.assertTrue(answer["update"])
self.assertEqual(answer["latest"], "9.9.9")
self.assertEqual(answer["current"], cli.__version__)
def test_github_being_unreachable_is_a_failure_with_a_reason(self):
with fake_urlopen(url_error("no route to host")), captured() as (_, err):
code = cli.cmd_update(Options(open=False))
self.assertEqual(code, 1)
self.assertIn("api.github.com", err.getvalue())
def test_the_browser_is_opened_only_when_asked_and_only_when_there_is_one(self):
self.run_update(self.RELEASE)
self.assertEqual(self.opened, [])
with fake_urlopen({"tag_name": f"v{cli.__version__}"}), captured():
cli.cmd_update(Options(open=True))
self.assertEqual(self.opened, [])
with fake_urlopen(self.RELEASE), captured():
cli.cmd_update(Options(open=True))
self.assertEqual(self.opened, [self.RELEASE["html_url"]])
def test_the_answer_is_written_down_for_the_application(self):
"""A check at a terminal is a check; the tray must not go and ask the
same question an hour later."""
self.run_update(self.RELEASE)
self.assertEqual(update.state()["version"], "9.9.9")
self.assertFalse(update.due())
class Doctor(DikteTest):
"""One pass over everything the settings window checks behind its buttons."""
+68 -4
View File
@@ -214,10 +214,22 @@ class ChunkSeconds(DikteTest):
self.assertEqual(ft.chunk_seconds(self.file(1024), 600), 0.0)
def test_a_file_over_the_limit_is_cut_by_what_it_measured(self):
# Twice the limit over an hour, so a little under half an hour fits.
seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600)
self.assertGreater(seconds, 1500)
self.assertLess(seconds, 1800)
# Twice the limit over twenty minutes, so a little under ten fits.
seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 1200)
self.assertGreater(seconds, 500)
self.assertLess(seconds, 600)
def test_a_chunk_is_never_more_audio_than_a_request_can_outlive(self):
"""An hour in one request is a 502 from the gateway, whatever it weighs."""
self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600),
ft.MAX_CHUNK_SECONDS)
def test_a_small_file_that_is_still_hours_long_is_cut_on_the_clock(self):
self.assertEqual(ft.chunk_seconds(self.file(1024), 7200),
ft.MAX_CHUNK_SECONDS)
def test_a_file_short_enough_on_both_counts_is_not_cut(self):
self.assertEqual(ft.chunk_seconds(self.file(1024), ft.MAX_CHUNK_SECONDS), 0.0)
def test_a_file_with_no_length_is_left_whole(self):
self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0)
@@ -400,6 +412,58 @@ class Transcriber(DikteTest):
worker.stop()
self.assertTrue(worker._abort.aborted)
def test_a_chunk_is_given_longer_to_answer_than_a_dictation(self):
"""A quarter hour of audio is not a sentence: the default would cut it off."""
worker = ft.FileTranscriber(self.conf)
with mock.patch.object(ft, "_to_wav", side_effect=lambda *a: self.source), \
mock.patch.object(ft, "_to_mp3",
side_effect=lambda path, *a, **k: path), \
mock.patch.object(ft.shutil, "which", return_value="/usr/bin/ffmpeg"), \
mock.patch.object(api, "transcribe", return_value="text") as call:
worker._work(self.source, False, False)
self.assertEqual(call.call_args.kwargs["timeout"], ft.HOSTED_TIMEOUT)
def test_a_gateway_having_a_bad_moment_is_asked_again(self):
with mock.patch.object(ft.FileTranscriber, "_wait"):
done, failures, progress, _ = self.run_chain(
fail=[api.ApiError("HTTP 502: timeout", 502), "raw text"])
self.assertEqual(failures, [])
self.assertEqual(done[0][0], "raw text")
self.assertTrue(any("Trying again" in message for message in progress))
def test_a_rejected_key_is_not_asked_again(self):
"""Trying again with the same key is only a slower way to fail."""
call = mock.Mock(side_effect=api.ApiError("rejected the API key", 401))
with mock.patch.object(ft.FileTranscriber, "_wait"):
_, failures, _, _ = self.run_chain(fail=call)
self.assertEqual(call.call_count, 1)
self.assertIn("rejected", failures[0])
def test_a_chunk_is_given_up_on_after_the_last_try(self):
call = mock.Mock(side_effect=api.ApiError("HTTP 502: timeout", 502))
with mock.patch.object(ft.FileTranscriber, "_wait"):
_, failures, _, _ = self.run_chain(fail=call)
self.assertEqual(call.call_count, ft.RETRIES)
self.assertIn("502", failures[0])
def test_what_was_heard_before_the_failure_is_still_handed_over(self):
"""An hour already transcribed is not thrown away over the chunk after it."""
boom = api.ApiError("HTTP 502: timeout", 502)
with mock.patch.object(ft.FileTranscriber, "_wait"), \
mock.patch.object(ft.FileTranscriber, "_chunks",
side_effect=lambda wav, *a: [(wav, 0.0), (wav, 10.0)]):
done, failures, _, _ = self.run_chain(
fail=["first half"] + [boom] * ft.RETRIES)
self.assertEqual(done[0][0], "first half")
self.assertIn("502", failures[0])
def test_nothing_heard_at_all_is_a_plain_failure(self):
call = mock.Mock(side_effect=api.ApiError("rejected the API key", 401))
with mock.patch.object(ft.FileTranscriber, "_wait"):
done, failures, _, _ = self.run_chain(fail=call)
self.assertEqual(done, [])
self.assertEqual(failures[0], "rejected the API key")
def test_a_second_start_while_one_is_running_is_ignored(self):
worker = ft.FileTranscriber(self.conf)
worker._thread = mock.Mock(is_alive=lambda: True)
+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-")
+342
View File
@@ -25,6 +25,7 @@ from dikte import ipc
from dikte import overlay as overlay_module
from dikte import paste
from dikte import settings_ui
from dikte import update
from tests.support import DikteTest, only_these_tools
# One application for the whole run; Qt allows no second one.
@@ -103,6 +104,7 @@ CHANGED = {
"pause_shortcut": "Meta+P",
"evdev_hotkey": True,
"history_limit": 50,
"update_check": False,
}
@@ -252,6 +254,31 @@ class Settings(DikteTest):
self.assertEqual(shown, [provider])
self.assertFalse(box.isHidden())
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())
# Nothing to open until a check has found something to open.
self.assertTrue(window.update_page.isHidden())
def test_a_newer_release_puts_the_page_button_on_screen(self):
window = self.window(cfg.Config())
told = []
window.update_found.connect(told.append)
release = update.Release("9.9.9", "https://example.invalid/9.9.9", "")
window._on_update_checked(release, "")
self.assertIn("9.9.9", window.update_status.text())
self.assertFalse(window.update_page.isHidden())
self.assertEqual(window._release_url, release.url)
# And the tray hears about it from here rather than waiting a day.
self.assertEqual(told, [release])
def test_a_check_that_failed_says_why_and_hands_the_button_back(self):
window = self.window(cfg.Config())
window.update_now.setEnabled(False)
window._on_update_checked(None, "api.github.com answered HTTP 403.")
self.assertIn("403", window.update_status.text())
self.assertTrue(window.update_now.isEnabled())
def test_the_settings_the_window_does_not_show_are_left_alone(self):
"""A tab nobody wrote must not reset what the command line set."""
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
@@ -491,6 +518,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)
+175
View File
@@ -0,0 +1,175 @@
"""Whether a newer release is one worth telling somebody about.
Two things carry the weight here. A version is compared by its numbers alone,
because a build off master carries the released number with its commit after
it and is ahead of that release rather than behind it. And the clock lives in a
file, so a day of asking nobody has to survive a restart.
"""
import json
import time
from dikte import hub
from dikte import update
from tests.support import DikteTest, fake_urlopen, url_error
RELEASE = {
"tag_name": "v1.4.0",
"html_url": "https://github.com/yusufipk/dikte/releases/tag/v1.4.0",
"published_at": "2026-08-01T10:00:00Z",
}
class Numbers(DikteTest):
def test_a_tag_and_a_bare_number_read_the_same(self):
self.assertEqual(update._numbers("v1.4.0"), (1, 4, 0))
self.assertEqual(update._numbers("1.4.0"), (1, 4, 0))
def test_a_short_number_is_filled_out(self):
self.assertEqual(update._numbers("2"), (2, 0, 0))
self.assertEqual(update._numbers("2.1"), (2, 1, 0))
def test_what_follows_the_number_is_dropped(self):
self.assertEqual(update._numbers("1.0.1-dev.abc1234"), (1, 0, 1))
self.assertEqual(update._numbers("1.0.1+build7"), (1, 0, 1))
def test_something_that_is_not_a_version_is_no_version(self):
self.assertEqual(update._numbers("latest"), ())
self.assertEqual(update._numbers(""), ())
self.assertEqual(update._numbers(None), ())
class Newer(DikteTest):
def test_a_higher_number_is_newer(self):
self.assertTrue(update.newer("1.4.0", "1.3.9"))
self.assertTrue(update.newer("v2.0.0", "1.9.9"))
def test_the_same_number_is_not(self):
self.assertFalse(update.newer("1.4.0", "1.4.0"))
self.assertFalse(update.newer("1.3.0", "1.4.0"))
def test_a_build_off_master_is_ahead_of_the_release_it_names(self):
"""1.0.1-dev.abc1234 was built after 1.0.1 went out, not before it.
Read as a version suffix it would be older, and every nightly would be
told to go back to the release it had already passed."""
self.assertFalse(update.newer("1.0.1", "1.0.1-dev.abc1234"))
self.assertTrue(update.newer("1.0.2", "1.0.1-dev.abc1234"))
def test_a_tag_that_is_not_a_version_is_never_newer(self):
self.assertFalse(update.newer("nightly", "1.0.0"))
class Asking(DikteTest):
def setUp(self):
super().setUp()
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
self.patch_attr(update, "__version__", "1.0.0")
def test_the_newest_release_comes_back_with_its_page(self):
with fake_urlopen(RELEASE) as calls:
release = update.latest()
self.assertEqual(release.version, "1.4.0")
self.assertEqual(release.url, RELEASE["html_url"])
self.assertEqual(
calls[0].full_url,
"https://api.github.com/repos/yusufipk/dikte/releases/latest")
def test_a_release_with_no_page_falls_back_to_the_redirect(self):
with fake_urlopen({"tag_name": "v1.4.0"}):
release = update.latest()
self.assertEqual(release.url, update.RELEASES_PAGE)
def test_a_repository_with_no_release_is_an_error(self):
with fake_urlopen({"message": "Not Found"}):
with self.assertRaises(hub.HubError):
update.latest()
def test_a_check_answers_with_the_newer_release(self):
with fake_urlopen(RELEASE):
release = update.check()
self.assertEqual(release.version, "1.4.0")
def test_a_check_that_finds_nothing_new_answers_with_nothing(self):
self.patch_attr(update, "__version__", "1.4.0")
with fake_urlopen(RELEASE):
self.assertIsNone(update.check())
def test_a_second_check_the_same_day_asks_nobody(self):
with fake_urlopen(RELEASE) as calls:
update.check()
release = update.check()
self.assertEqual(len(calls), 1)
# And still says what the first one found, since it is still true.
self.assertEqual(release.version, "1.4.0")
def test_a_day_later_it_asks_again(self):
with fake_urlopen(RELEASE) as calls:
update.check()
update._store(checked=time.time() - update.INTERVAL - 60)
update.check()
self.assertEqual(len(calls), 2)
def test_the_button_asks_whatever_the_clock_says(self):
with fake_urlopen(RELEASE) as calls:
update.check()
update.check(force=True)
self.assertEqual(len(calls), 2)
def test_a_check_that_cannot_reach_github_says_so(self):
with fake_urlopen(url_error("no route to host")):
with self.assertRaises(hub.HubError):
update.check()
class Remembering(DikteTest):
def setUp(self):
super().setUp()
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
self.patch_attr(update, "__version__", "1.0.0")
def test_what_the_last_check_found_survives_a_restart(self):
with fake_urlopen(RELEASE):
update.check()
release = update.pending()
self.assertEqual(release.version, "1.4.0")
self.assertEqual(release.url, RELEASE["html_url"])
def test_nothing_was_ever_checked(self):
self.assertIsNone(update.pending())
self.assertEqual(update.state(), {})
self.assertTrue(update.due())
def test_a_release_that_is_no_longer_newer_is_not_pending(self):
"""The state file outlives the build that wrote it: an update that was
found and then installed must not still be waiting afterwards."""
update._store(version="1.4.0")
self.patch_attr(update, "__version__", "1.4.0")
self.assertIsNone(update.pending())
def test_a_version_is_announced_once(self):
self.assertEqual(update.announced(), "")
update.mark_announced("1.4.0")
self.assertEqual(update.announced(), "1.4.0")
def test_a_state_file_that_is_rubbish_is_no_state_at_all(self):
update.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
update.STATE_FILE.write_text("half a {", encoding="utf-8")
self.assertEqual(update.state(), {})
self.assertIsNone(update.pending())
def test_a_state_file_that_cannot_be_written_is_not_a_failure(self):
self.patch_attr(update, "STATE_FILE",
self.path("nope") / "deeper" / "update.json")
self.path("nope").write_text("a file where a directory would go")
with fake_urlopen(RELEASE):
release = update.check()
self.assertEqual(release.version, "1.4.0")
def test_the_clock_is_kept_out_of_the_settings(self):
"""A background check writes while the settings window may be open, and
a write into config.json there would undo whatever it holds."""
with fake_urlopen(RELEASE):
update.check()
stored = json.loads(update.STATE_FILE.read_text(encoding="utf-8"))
self.assertEqual(stored["version"], "1.4.0")
self.assertGreater(stored["checked"], 0)
+13 -3
View File
@@ -33,7 +33,8 @@ class Chain(DikteTest):
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))
@@ -64,7 +65,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}
@@ -76,7 +78,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()