mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
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:
+4
-1
@@ -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")
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user