mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Merge master into the macOS front branch
This commit is contained in:
+16
-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
|
||||
@@ -40,6 +41,18 @@ linux_only = unittest.skipUnless(
|
||||
"covers the Linux desktop stack (PipeWire, wl-clipboard, ydotool, KDE)",
|
||||
)
|
||||
|
||||
# The launchers a downloaded build writes for itself. There are two downloads,
|
||||
# an AppImage and a disk image, so `integrate` has a Linux half and a macOS half
|
||||
# and no third one, and the tests that pin them stand in a home laid out the way
|
||||
# those two systems lay one out: paths that start at the root, a $HOME the
|
||||
# library reads, a symlink for the command. None of that is a Windows machine,
|
||||
# where the same code never runs. A Windows build would add an entry there and
|
||||
# take the mark off these.
|
||||
posix_only = unittest.skipIf(
|
||||
sys.platform == "win32",
|
||||
"covers what an AppImage and a .app write into the desktop they landed on",
|
||||
)
|
||||
|
||||
|
||||
def _no_network(*args, **kwargs):
|
||||
raise AssertionError(
|
||||
@@ -74,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):
|
||||
|
||||
@@ -887,5 +887,201 @@ class MacRecordingCommand(OnMacOS, DikteTest):
|
||||
self.assertFalse(recorder.active)
|
||||
|
||||
|
||||
class NoFarSideToRecord(DikteTest):
|
||||
"""Two different answers, and the table is what tells them apart.
|
||||
|
||||
A sound system that records the far side has a device this machine could
|
||||
not pick out, and Settings is where to choose one. A sound system that does
|
||||
not had nothing to offer there in the first place, and "pick one" would
|
||||
send somebody to an empty box and an installation that cannot help.
|
||||
"""
|
||||
|
||||
def failure(self, meetings):
|
||||
recorder = audio.MeetingRecorder()
|
||||
failures = []
|
||||
recorder.failed.connect(failures.append)
|
||||
with only_these_tools("ffmpeg"), \
|
||||
mock.patch.object(audio, "default_monitor", return_value=""), \
|
||||
mock.patch.object(audio, "sound",
|
||||
return_value=audio.PULSE._replace(
|
||||
meetings=meetings)):
|
||||
recorder.start(str(self.path("meeting.wav")))
|
||||
self.assertFalse(recorder.active)
|
||||
return failures[0]
|
||||
|
||||
def test_a_system_that_records_the_far_side_sends_you_to_settings(self):
|
||||
self.assertIn("Settings", self.failure(True))
|
||||
|
||||
def test_a_system_that_does_not_says_that_instead(self):
|
||||
message = self.failure(False)
|
||||
self.assertIn("nothing that records what the speakers", message)
|
||||
self.assertNotIn("Settings", message)
|
||||
|
||||
def test_the_three_sound_systems_each_answer_the_question(self):
|
||||
self.assertTrue(audio.PULSE.meetings)
|
||||
self.assertTrue(audio.COREAUDIO.meetings)
|
||||
self.assertFalse(audio.DSHOW.meetings)
|
||||
|
||||
|
||||
class OnWindows:
|
||||
"""A test that runs as if the machine ran Windows."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(sys, "platform", "win32"))
|
||||
|
||||
|
||||
class WindowsDevices(OnWindows, DikteTest):
|
||||
"""The one ffmpeg listing the device questions are answered from.
|
||||
|
||||
dshow names devices rather than numbering them, and the names carry
|
||||
whatever alphabet the machine speaks, so the listing here does too.
|
||||
"""
|
||||
|
||||
MIC = "@device_cm_{33D9A762}\\wave_{B1C2}"
|
||||
LISTING = (
|
||||
'[dshow @ 0000020c] "Integrated Camera" (video)\n'
|
||||
'[dshow @ 0000020c] Alternative name "@device_pnp_\\...."\n'
|
||||
'[dshow @ 0000020c] "Mikrofon Dizisi (Intel Smart Sound)" (audio)\n'
|
||||
f'[dshow @ 0000020c] Alternative name "{MIC}"\n'
|
||||
'[dshow @ 0000020c] "Kulaklık (Soundcore Life Q30)" (audio)\n'
|
||||
'[dshow @ 0000020c] Could not find audio only device with name '
|
||||
'"dummy" among source devices of type audio.\n'
|
||||
"dummy: Immediate exit requested\n"
|
||||
).encode("utf-8")
|
||||
|
||||
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.
|
||||
audio._DSHOW_SEEN.clear()
|
||||
self.addCleanup(audio._DSHOW_SEEN.clear)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def listing(self, stderr=None, tools=("ffmpeg",)):
|
||||
completed = FakeCompleted(
|
||||
returncode=1, stderr=self.LISTING if stderr is None else stderr)
|
||||
with only_these_tools(*tools), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
return_value=completed) as run:
|
||||
yield run
|
||||
|
||||
def test_windows_records_through_dshow(self):
|
||||
self.assertIs(audio.sound(), audio.DSHOW)
|
||||
|
||||
def test_the_audio_devices_are_the_only_ones_read(self):
|
||||
with self.listing():
|
||||
self.assertEqual(audio.list_sources(), [
|
||||
(self.MIC, "Mikrofon Dizisi (Intel Smart Sound)"),
|
||||
("Kulaklık (Soundcore Life Q30)",
|
||||
"Kulaklık (Soundcore Life Q30)"),
|
||||
])
|
||||
|
||||
def test_the_device_ffmpeg_could_not_open_is_not_one_of_them(self):
|
||||
"""The command ends by quoting the name it was sent to look for."""
|
||||
with self.listing():
|
||||
self.assertNotIn("dummy", [name for _, name in audio.list_sources()])
|
||||
|
||||
def test_a_listing_from_ffmpeg_8_which_renamed_the_prefix(self):
|
||||
"""ffmpeg 8 writes `[in#0 @ ...]` where older builds wrote `[dshow @ ...]`."""
|
||||
listing = (
|
||||
'[in#0 @ 00000238c3300ac0] "Integrated Camera" (video)\n'
|
||||
'[in#0 @ 00000238c3300ac0] Alternative name "@device_pnp_\\..."\n'
|
||||
'[in#0 @ 00000238c3300ac0] "OBS Virtual Camera" (none)\n'
|
||||
'[in#0 @ 00000238c3300ac0] Alternative name "@device_sw_{860B}"\n'
|
||||
'[in#0 @ 00000238c3300ac0] "Mikrofon Dizisi (Intel® Smart Sound)" (audio)\n'
|
||||
'[in#0 @ 00000238c3300ac0] Alternative name "@device_cm_{33D9}"\n'
|
||||
"Error opening input file dummy.\n"
|
||||
).encode("utf-8")
|
||||
with self.listing(stderr=listing):
|
||||
self.assertEqual(audio.list_sources(),
|
||||
[("@device_cm_{33D9}",
|
||||
"Mikrofon Dizisi (Intel® Smart Sound)")])
|
||||
|
||||
def test_a_listing_from_an_ffmpeg_that_marks_nothing(self):
|
||||
"""Older builds print a heading instead of an (audio) on every line."""
|
||||
listing = (
|
||||
'[dshow @ 0] DirectShow video devices\n'
|
||||
'[dshow @ 0] "Integrated Camera"\n'
|
||||
'[dshow @ 0] Alternative name "@device_pnp_\\..."\n'
|
||||
'[dshow @ 0] DirectShow audio devices\n'
|
||||
'[dshow @ 0] "Microphone (Realtek Audio)"\n'
|
||||
'[dshow @ 0] Alternative name "@device_cm_{ABCD}"\n'
|
||||
).encode("utf-8")
|
||||
with self.listing(stderr=listing):
|
||||
self.assertEqual(audio.list_sources(),
|
||||
[("@device_cm_{ABCD}", "Microphone (Realtek Audio)")])
|
||||
|
||||
def test_two_devices_called_the_same_thing_stay_apart(self):
|
||||
"""The normal state of a laptop with a headset plugged into it."""
|
||||
listing = (
|
||||
'[dshow @ 0] "Microphone" (audio)\n'
|
||||
'[dshow @ 0] Alternative name "@device_cm_{ONE}"\n'
|
||||
'[dshow @ 0] "Microphone" (audio)\n'
|
||||
'[dshow @ 0] Alternative name "@device_cm_{TWO}"\n'
|
||||
).encode("utf-8")
|
||||
with self.listing(stderr=listing):
|
||||
sources = audio.list_sources()
|
||||
self.assertEqual([identifier for identifier, _ in sources],
|
||||
["@device_cm_{ONE}", "@device_cm_{TWO}"])
|
||||
self.assertEqual({name for _, name in sources}, {"Microphone"})
|
||||
|
||||
def test_no_ffmpeg_installed(self):
|
||||
with only_these_tools():
|
||||
self.assertEqual(audio.list_sources(), [])
|
||||
self.assertEqual(audio.recording_command(), [])
|
||||
|
||||
def test_the_identifier_is_what_the_recorder_is_given_back(self):
|
||||
with self.listing():
|
||||
cmd = audio.recording_command(self.MIC)
|
||||
self.assertEqual(cmd[cmd.index("-f") + 1], "dshow")
|
||||
self.assertIn(f"audio={self.MIC}", cmd)
|
||||
|
||||
def test_no_microphone_named_means_the_first_one_listed(self):
|
||||
"""dshow has no default device for an empty target to mean."""
|
||||
with self.listing():
|
||||
self.assertIn(f"audio={self.MIC}", audio.recording_command())
|
||||
|
||||
def test_a_dictation_does_not_run_a_listing_of_its_own(self):
|
||||
"""Two hundred milliseconds of ffmpeg in front of every key press."""
|
||||
with self.listing() as run:
|
||||
audio.list_sources()
|
||||
audio.recording_command()
|
||||
audio.recording_command()
|
||||
self.assertEqual(run.call_count, 1)
|
||||
|
||||
def test_opening_the_device_list_asks_again(self):
|
||||
"""Which is what somebody who has just plugged one in does."""
|
||||
with self.listing() as run:
|
||||
audio.list_sources()
|
||||
audio.list_sources()
|
||||
self.assertEqual(run.call_count, 2)
|
||||
|
||||
def test_a_machine_with_no_microphone_at_all(self):
|
||||
with self.listing(stderr=b'[dshow @ 0] "Integrated Camera" (video)\n'):
|
||||
self.assertEqual(audio.recording_command(), [])
|
||||
|
||||
def test_an_ffmpeg_that_will_not_run(self):
|
||||
with only_these_tools("ffmpeg"), \
|
||||
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||||
self.assertEqual(audio.list_sources(), [])
|
||||
|
||||
def test_nothing_offers_the_far_side_of_a_meeting(self):
|
||||
"""What the speakers play is not a capture device Windows hands out."""
|
||||
with self.listing():
|
||||
self.assertEqual(audio.list_monitors(), [])
|
||||
self.assertEqual(audio.default_monitor(), "")
|
||||
self.assertEqual(audio.meeting_commands("mic", "sys"), [])
|
||||
|
||||
def test_a_meeting_says_what_is_wrong_rather_than_where_to_look(self):
|
||||
recorder = audio.MeetingRecorder()
|
||||
failures = []
|
||||
recorder.failed.connect(failures.append)
|
||||
with self.listing():
|
||||
recorder.start(str(self.path("meeting.wav")))
|
||||
self.assertIn("nothing that records what the speakers", failures[0])
|
||||
self.assertFalse(recorder.active)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+135
-1
@@ -10,13 +10,20 @@ import contextlib
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import webbrowser
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
from dikte import audio
|
||||
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 tests.support import DikteTest, fake_urlopen
|
||||
from dikte import paste
|
||||
from dikte import update
|
||||
from tests.support import DikteTest, fake_urlopen, only_these_tools, url_error
|
||||
|
||||
|
||||
class Options:
|
||||
@@ -417,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."""
|
||||
|
||||
@@ -434,6 +503,30 @@ class Doctor(DikteTest):
|
||||
self.assertIn("OpenRouter key, cleaning up on some/model",
|
||||
self.run_doctor(as_json=False, cleanup_model="some/model"))
|
||||
|
||||
def test_it_asks_after_the_programs_this_desktop_actually_uses(self):
|
||||
"""A missing ydotool on a Mac is a red mark with nothing behind it."""
|
||||
with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS):
|
||||
mac = self.run_doctor()["programs"]
|
||||
with mock.patch.object(cli.paste, "desktop", return_value=paste.WAYLAND):
|
||||
wayland = self.run_doctor()["programs"]
|
||||
self.assertIn("pbcopy", mac)
|
||||
self.assertNotIn("ydotool", mac)
|
||||
self.assertIn("ydotool", wayland)
|
||||
self.assertIn("ffmpeg", mac) # the one every system records through
|
||||
|
||||
def test_a_system_that_shells_out_for_neither_half_is_asked_for_neither(self):
|
||||
# shutil.which is faked as well as the platform: the real one reads
|
||||
# sys.platform too, and reaches for a Windows API this machine has not
|
||||
# got the moment it is told it is on Windows.
|
||||
with mock.patch.object(cli.paste, "desktop", return_value=paste.WINDOWS), \
|
||||
only_these_tools("ffmpeg"), \
|
||||
mock.patch.object(cli.sys, "platform", "win32"):
|
||||
programs = self.run_doctor()["programs"]
|
||||
self.assertNotIn("", programs)
|
||||
self.assertEqual([name for name in ("wl-copy", "ydotool", "pactl",
|
||||
"pw-record", "kwriteconfig6")
|
||||
if name in programs], [])
|
||||
|
||||
def test_cleanup_on_a_cli_is_a_question_about_the_program(self):
|
||||
reply = self.run_doctor(cleanup_provider="codex",
|
||||
cleanup_codex_model="gpt-5.4")
|
||||
@@ -445,6 +538,25 @@ class Doctor(DikteTest):
|
||||
cleanup_codex_model="gpt-5.4"))
|
||||
|
||||
|
||||
class Devices(DikteTest):
|
||||
def test_a_machine_with_nothing_names_its_own_missing_program(self):
|
||||
"""The Windows README sends people here, and pactl is not on it."""
|
||||
for here, expected in ((audio.DSHOW, "ffmpeg"),
|
||||
(audio.PULSE, "pulseaudio-utils")):
|
||||
with self.subTest(sound=expected):
|
||||
with mock.patch.object(cli.audio, "sound", return_value=here), \
|
||||
mock.patch.object(cli.audio, "list_sources",
|
||||
return_value=[]), \
|
||||
mock.patch.object(cli.audio, "list_monitors",
|
||||
return_value=[]), \
|
||||
mock.patch.object(cli.audio, "default_monitor",
|
||||
return_value=""), \
|
||||
captured() as (out, _err):
|
||||
code = cli.cmd_devices(Options(json=True))
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn(expected, json.loads(out.getvalue())["error"])
|
||||
|
||||
|
||||
class Finding(DikteTest):
|
||||
def test_no_history_at_all(self):
|
||||
self.assertIsNone(cli._find_history("last"))
|
||||
@@ -623,5 +735,27 @@ class Replies(DikteTest):
|
||||
self.assertFalse(launched.called)
|
||||
|
||||
|
||||
class TranscribeRunsHere(DikteTest):
|
||||
"""`dikte transcribe` runs in this process, not in the instance."""
|
||||
|
||||
def test_the_local_servers_are_handed_the_settings_first(self):
|
||||
# The GUI does this at startup; a CLI run has no GUI to have done it,
|
||||
# and without it the whisper server holds an empty model name.
|
||||
wav = self.path("clip.wav")
|
||||
wav.write_bytes(b"RIFF not really audio")
|
||||
self.write_config({"local_model": "ggml-base.bin"})
|
||||
self.addCleanup(ggml.whisper.configure,
|
||||
model="", threads=0, gpu=True, binary="")
|
||||
|
||||
opts = cli.build_parser().parse_args(["transcribe", str(wav)])
|
||||
with mock.patch.object(cli.filetranscribe, "FileTranscriber"), \
|
||||
mock.patch.object(cli, "_headless",
|
||||
return_value={"error": "stopped"}), \
|
||||
captured():
|
||||
cli.cmd_transcribe(opts)
|
||||
|
||||
self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,6 +8,7 @@ config and now shadows the default.
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -89,6 +90,8 @@ class Saving(DikteTest):
|
||||
cfg.Config().save()
|
||||
self.assertTrue(cfg.CONFIG_FILE.exists())
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32",
|
||||
"NTFS access is decided by ACLs, not by the mode bits")
|
||||
def test_the_file_is_readable_by_nobody_else(self):
|
||||
"""It holds two API keys."""
|
||||
cfg.Config().save()
|
||||
@@ -491,6 +494,9 @@ class ReadyToRun(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
||||
# A machine Dikte is actually installed on would otherwise answer for
|
||||
# the "missing program" below through the real install record.
|
||||
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
|
||||
|
||||
def install(self, name):
|
||||
path = ggml.whisper_model_path(name)
|
||||
|
||||
@@ -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)
|
||||
@@ -378,6 +390,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)
|
||||
|
||||
@@ -15,6 +15,7 @@ import tarfile
|
||||
import textwrap
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from unittest import mock
|
||||
|
||||
from dikte import ggml
|
||||
@@ -77,6 +78,15 @@ def tarball(entries):
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def zipball(entries):
|
||||
"""A .zip laid out the way the Windows releases are."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as bundle:
|
||||
for name, content in entries.items():
|
||||
bundle.writestr(name, content)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class Local(DikteTest):
|
||||
"""A test with its own bin, models and cache directories."""
|
||||
|
||||
@@ -691,3 +701,87 @@ class Sizes(DikteTest):
|
||||
self.assertEqual(ggml.human_size(512), "512 B")
|
||||
self.assertEqual(ggml.human_size(574041195), "547.4 MB")
|
||||
self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB")
|
||||
|
||||
|
||||
# --- Windows ----------------------------------------------------------------
|
||||
|
||||
|
||||
class WindowsAssets(Local):
|
||||
"""Which archive a Windows machine is handed."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(sys, "platform", "win32")
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
|
||||
def test_whisper_prefers_the_blas_build(self):
|
||||
# On a plain CPU it transcribes about twice as fast as the stock one.
|
||||
self.assertEqual(ggml._wanted_assets(ggml.WHISPER),
|
||||
("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip"))
|
||||
|
||||
def test_llama_takes_the_vulkan_build_when_there_is_a_loader(self):
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
|
||||
("bin-win-vulkan-x64.zip", "bin-win-cpu-x64.zip"))
|
||||
|
||||
def test_llama_falls_back_to_the_cpu_build_without_one(self):
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
|
||||
("bin-win-cpu-x64.zip",))
|
||||
|
||||
def test_an_arm_machine_is_not_handed_the_x64_build(self):
|
||||
self.patch_attr(ggml, "_arch", lambda: "arm64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
|
||||
("bin-win-cpu-arm64.zip",))
|
||||
|
||||
def test_an_arm_machine_is_handed_the_x64_whisper_anyway(self):
|
||||
"""whisper.cpp publishes no arm64 build for Windows: the release has
|
||||
Win32 and x64 and nothing else, so emulated is the only local option
|
||||
a Snapdragon has. Pinned here so that a release which does start
|
||||
publishing one is noticed rather than quietly ignored."""
|
||||
self.patch_attr(ggml, "_arch", lambda: "arm64")
|
||||
self.assertEqual(ggml._wanted_assets(ggml.WHISPER),
|
||||
("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip"))
|
||||
|
||||
|
||||
class InstallOnWindows(Local):
|
||||
"""The Windows releases are zips, and the binary carries .exe."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(sys, "platform", "win32")
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.archive = zipball({
|
||||
"Release/whisper-server.exe": b"MZ not really a program",
|
||||
"Release/whisper.dll": b"not really a library",
|
||||
})
|
||||
|
||||
def release(self, *names):
|
||||
digest = hashlib.sha256(self.archive)
|
||||
return {"tag_name": "v1.9.1", "assets": [
|
||||
{"name": name, "browser_download_url": f"https://example.invalid/{name}",
|
||||
"size": 10, "digest": "sha256:" + digest.hexdigest()}
|
||||
for name in names]}
|
||||
|
||||
def test_the_zip_lands_and_the_exe_inside_it_is_found(self):
|
||||
with serving(self.release("whisper-blas-bin-x64.zip"), self.archive):
|
||||
path = ggml.install_program(ggml.WHISPER)
|
||||
self.assertTrue(path.endswith("whisper-server.exe"))
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path),
|
||||
"whisper.dll")))
|
||||
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||
|
||||
def test_the_blas_build_is_the_one_fetched_when_both_are_offered(self):
|
||||
listing = self.release("whisper-bin-x64.zip", "whisper-blas-bin-x64.zip")
|
||||
with serving(listing, self.archive) as calls:
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
urls = [call.args[0].full_url for call in calls.call_args_list]
|
||||
self.assertTrue(urls[1].endswith("whisper-blas-bin-x64.zip"))
|
||||
|
||||
def test_a_release_with_nothing_for_windows_says_so(self):
|
||||
with fake_urlopen(json_body(self.release("whisper-bin-ubuntu-x64.tar.gz"))):
|
||||
with self.assertRaises(ggml.LocalError) as caught:
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertIn("this machine", str(caught.exception))
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from dikte import config as cfg
|
||||
from dikte import hotkey
|
||||
from tests.support import DikteTest, FakeCompleted, linux_only
|
||||
@@ -753,5 +757,204 @@ class MacChooser(DikteTest):
|
||||
self.assertFalse(hotkey.valid_shortcut("Cmd+Space"))
|
||||
|
||||
|
||||
# --- Windows ----------------------------------------------------------------
|
||||
|
||||
class ParseWindowsShortcut(unittest.TestCase):
|
||||
def test_the_default(self):
|
||||
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Space"),
|
||||
(hotkey.WIN_MODS["ctrl"], 0x20))
|
||||
|
||||
def test_case_and_spacing_do_not_matter(self):
|
||||
self.assertEqual(hotkey.parse_windows_shortcut(" ctrl + SPACE "),
|
||||
hotkey.parse_windows_shortcut("Ctrl+Space"))
|
||||
|
||||
def test_several_modifiers_are_one_number(self):
|
||||
modifiers, key = hotkey.parse_windows_shortcut("Ctrl+Shift+M")
|
||||
self.assertEqual(modifiers,
|
||||
hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"])
|
||||
self.assertEqual(key, hotkey.WIN_KEYS["m"])
|
||||
|
||||
def test_the_synonyms_land_on_one_number(self):
|
||||
for name in ("meta", "super", "win"):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(hotkey.parse_windows_shortcut(f"{name}+space"),
|
||||
(hotkey.WIN_MODS["win"], 0x20))
|
||||
self.assertEqual(hotkey.parse_windows_shortcut("Control+Space"),
|
||||
hotkey.parse_windows_shortcut("Ctrl+Space"))
|
||||
|
||||
def test_a_key_on_its_own(self):
|
||||
self.assertEqual(hotkey.parse_windows_shortcut("F9"),
|
||||
(0, hotkey.WIN_KEYS["f9"]))
|
||||
|
||||
def test_modifiers_with_no_key(self):
|
||||
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Alt"), (None, None))
|
||||
|
||||
def test_a_key_nobody_mapped(self):
|
||||
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+F13"), (None, None))
|
||||
|
||||
def test_something_that_is_not_even_a_string(self):
|
||||
self.assertEqual(hotkey.parse_windows_shortcut(None), (None, None))
|
||||
|
||||
|
||||
class FakeWinHotkeys:
|
||||
"""user32 and kernel32, as much of both as the listener calls.
|
||||
|
||||
The message queue is a real queue: GetMessageW blocks on it the way the
|
||||
real one blocks on the thread's, so the listener runs its actual loop and
|
||||
a test presses the key by posting the message a press would.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.registered = {} # identifier -> (modifiers, key)
|
||||
self.refused = set() # (modifiers, key) another program holds
|
||||
self.unregistered = []
|
||||
self.queue = queue.Queue()
|
||||
|
||||
# --- user32
|
||||
def RegisterHotKey(self, hwnd, identifier, modifiers, key):
|
||||
if (modifiers & ~hotkey.WIN_MOD_NOREPEAT, key) in self.refused:
|
||||
return 0
|
||||
self.registered[identifier] = (modifiers, key)
|
||||
return 1
|
||||
|
||||
def UnregisterHotKey(self, hwnd, identifier):
|
||||
self.unregistered.append(identifier)
|
||||
self.registered.pop(identifier, None)
|
||||
return 1
|
||||
|
||||
def PeekMessageW(self, reference, hwnd, low, high, remove):
|
||||
return 0
|
||||
|
||||
def GetMessageW(self, reference, hwnd, low, high):
|
||||
kind, wparam = self.queue.get()
|
||||
if kind == hotkey.WM_QUIT:
|
||||
return 0
|
||||
message = reference._obj
|
||||
message.message = kind
|
||||
message.wParam = wparam
|
||||
return 1
|
||||
|
||||
def PostThreadMessageW(self, thread_id, message, wparam, lparam):
|
||||
self.queue.put((message, wparam))
|
||||
return 1
|
||||
|
||||
# --- kernel32
|
||||
def GetCurrentThreadId(self):
|
||||
return 1
|
||||
|
||||
# --- the keyboard
|
||||
def press(self, identifier):
|
||||
self.queue.put((hotkey.WM_HOTKEY, identifier))
|
||||
|
||||
|
||||
class WinListener(DikteTest):
|
||||
"""What the listener asks Windows for, without a Windows to ask."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = FakeWinHotkeys()
|
||||
self.patch_attr(hotkey, "_win_input", lambda: (self.api, self.api))
|
||||
self.addCleanup(hotkey._REGISTERED.clear)
|
||||
self.listener = hotkey.WinHotkey()
|
||||
self.addCleanup(self.listener.stop)
|
||||
self.failures = []
|
||||
# Direct, because the emits come from the listener's own thread and
|
||||
# there is no event loop here to carry a queued one across.
|
||||
self.listener.failed.connect(self.failures.append,
|
||||
Qt.ConnectionType.DirectConnection)
|
||||
|
||||
@staticmethod
|
||||
def settles(seen, count=1):
|
||||
"""The signals arrive from the listener's own thread, not this one."""
|
||||
deadline = time.monotonic() + 2
|
||||
while len(seen) < count and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
return seen
|
||||
|
||||
def test_every_binding_is_registered_with_its_modifiers(self):
|
||||
self.assertTrue(self.listener.start({"toggle": "Ctrl+Space",
|
||||
"cancel": "Ctrl+Shift+Space"}))
|
||||
norepeat = hotkey.WIN_MOD_NOREPEAT
|
||||
self.assertEqual(self.api.registered, {
|
||||
1: (hotkey.WIN_MODS["ctrl"] | norepeat, 0x20),
|
||||
2: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"] | norepeat, 0x20),
|
||||
})
|
||||
|
||||
def test_what_landed_is_what_the_status_line_shows(self):
|
||||
self.listener.start({"toggle": "Ctrl+Space"})
|
||||
self.assertEqual(hotkey._REGISTERED,
|
||||
{hotkey.DESKTOP_ID: "Ctrl+Space"})
|
||||
|
||||
def test_a_press_arrives_under_the_name_it_was_registered_as(self):
|
||||
seen = []
|
||||
self.listener.triggered.connect(seen.append,
|
||||
Qt.ConnectionType.DirectConnection)
|
||||
self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"})
|
||||
self.api.press(2)
|
||||
self.assertEqual(self.settles(seen), ["cancel"])
|
||||
|
||||
def test_a_held_combination_is_reported_and_the_rest_still_land(self):
|
||||
self.api.refused = {(hotkey.WIN_MODS["ctrl"], 0x20)}
|
||||
started = self.listener.start({"toggle": "Ctrl+Space",
|
||||
"cancel": "Ctrl+Shift+Space"})
|
||||
self.assertTrue(started)
|
||||
self.assertIn("Ctrl+Space", self.settles(self.failures)[0])
|
||||
self.assertEqual(list(self.api.registered), [2])
|
||||
|
||||
def test_an_unparsable_binding_is_reported(self):
|
||||
self.assertFalse(self.listener.start({"toggle": "Ctrl+F13"}))
|
||||
self.assertIn("Ctrl+F13", self.failures[0])
|
||||
|
||||
def test_nothing_but_empty_bindings_does_not_start(self):
|
||||
self.assertFalse(self.listener.start({"toggle": "", "cancel": ""}))
|
||||
self.assertFalse(self.listener.running)
|
||||
|
||||
def test_stop_lets_go_of_everything(self):
|
||||
self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"})
|
||||
self.listener.stop()
|
||||
self.assertEqual(self.api.registered, {})
|
||||
self.assertEqual(hotkey._REGISTERED, {})
|
||||
self.assertFalse(self.listener.running)
|
||||
|
||||
def test_a_second_start_is_a_clean_slate(self):
|
||||
self.listener.start({"toggle": "Ctrl+Space"})
|
||||
self.assertTrue(self.listener.start({"toggle": "Ctrl+Shift+Space"}))
|
||||
self.assertEqual(self.api.registered,
|
||||
{1: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"]
|
||||
| hotkey.WIN_MOD_NOREPEAT, 0x20)})
|
||||
|
||||
|
||||
class WindowsChooser(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(hotkey.sys, "platform", "win32"))
|
||||
self.addCleanup(hotkey._REGISTERED.clear)
|
||||
|
||||
def test_the_listener_is_the_windows_hotkey_service(self):
|
||||
self.assertIsInstance(hotkey.listener(), hotkey.WinHotkey)
|
||||
|
||||
def test_a_combination_is_checked_against_the_windows_table(self):
|
||||
self.assertTrue(hotkey.valid_shortcut("Ctrl+Space"))
|
||||
self.assertFalse(hotkey.valid_shortcut("Ctrl+F13"))
|
||||
|
||||
def test_no_registry_to_write_into_and_no_restart_to_wait_for(self):
|
||||
self.assertFalse(hotkey.installs_shortcuts())
|
||||
self.assertFalse(hotkey.shortcut_needs_restart())
|
||||
self.assertEqual(hotkey.desktop_name(), "Windows")
|
||||
|
||||
def test_installing_records_it_rather_than_writing_anything(self):
|
||||
with mock.patch.object(hotkey.subprocess, "run") as run:
|
||||
ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
|
||||
run.assert_not_called()
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space")
|
||||
hotkey.remove_shortcut()
|
||||
self.assertIsNone(hotkey.shortcut_status())
|
||||
|
||||
def test_no_list_of_conflicts_to_read(self):
|
||||
"""Not even KDE's file, which a dual-boot home directory could hold."""
|
||||
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -9,12 +9,14 @@ has to notice AppImageLauncher's entry, which is not under the name ours is.
|
||||
import os
|
||||
import pathlib
|
||||
import plistlib
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from dikte import integrate
|
||||
from tests.support import posix_only
|
||||
|
||||
|
||||
class Frozen:
|
||||
@@ -75,6 +77,7 @@ class WhatToStart(unittest.TestCase):
|
||||
def test_a_checkout_names_this_interpreter_and_the_entry_point(self):
|
||||
self.assertFalse(integrate.packaged())
|
||||
|
||||
@posix_only
|
||||
def test_an_appimage_names_the_file_and_not_the_mount(self):
|
||||
"""The mount is a fresh /tmp path every run; a shortcut written to it
|
||||
would work until the next login and never again."""
|
||||
@@ -83,6 +86,7 @@ class WhatToStart(unittest.TestCase):
|
||||
self.assertEqual(str(integrate.target()),
|
||||
"/home/someone/Downloads/Dikte.AppImage")
|
||||
|
||||
@posix_only
|
||||
def test_a_mac_names_the_bundle_and_not_the_executable_inside_it(self):
|
||||
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
|
||||
platform="darwin"):
|
||||
@@ -95,6 +99,7 @@ class WhatToStart(unittest.TestCase):
|
||||
class BundledTools(unittest.TestCase):
|
||||
"""The ffmpeg the disk image carries, and how anything finds it."""
|
||||
|
||||
@posix_only
|
||||
def test_a_mac_looks_beside_the_bundle_not_beside_the_executable(self):
|
||||
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
|
||||
platform="darwin"):
|
||||
@@ -213,6 +218,7 @@ class Certificates(unittest.TestCase):
|
||||
self.assertIsNone(integrate.use_system_certificates())
|
||||
|
||||
|
||||
@posix_only
|
||||
class Linux(Home):
|
||||
def install(self, appimage, force=False):
|
||||
with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage),
|
||||
@@ -348,6 +354,7 @@ class Linux(Home):
|
||||
self.assertEqual(integrate.ensure(), [])
|
||||
|
||||
|
||||
@posix_only
|
||||
class MacOS(Home):
|
||||
def agent(self):
|
||||
return self.home / "Library/LaunchAgents/io.github.yusufipk.dikte.plist"
|
||||
@@ -358,6 +365,12 @@ class MacOS(Home):
|
||||
mock.patch.object(integrate, "_launchctl_reload"):
|
||||
return integrate.install(force=force)
|
||||
|
||||
def remove(self):
|
||||
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
|
||||
home=self.home, platform="darwin"), \
|
||||
mock.patch("subprocess.run"):
|
||||
return integrate.remove()
|
||||
|
||||
def test_it_writes_a_login_item_and_the_command(self):
|
||||
app = self.home / "Applications" / "Dikte.app"
|
||||
(app / "Contents/MacOS").mkdir(parents=True)
|
||||
@@ -419,6 +432,123 @@ class MacOS(Home):
|
||||
self.install(app)
|
||||
self.assertIn("install-mac.sh", command.read_text())
|
||||
|
||||
def test_removing_takes_back_the_login_item_and_command_it_wrote(self):
|
||||
app = self.home / "Applications" / "Dikte.app"
|
||||
(app / "Contents/MacOS").mkdir(parents=True)
|
||||
self.install(app)
|
||||
command = self.home / ".local/bin/dikte"
|
||||
|
||||
self.assertEqual(self.remove(), [self.agent(), command])
|
||||
self.assertFalse(self.agent().exists())
|
||||
self.assertFalse(command.exists())
|
||||
|
||||
def test_removing_leaves_another_installers_command_alone(self):
|
||||
command = self.home / ".local/bin/dikte"
|
||||
command.parent.mkdir(parents=True)
|
||||
command.write_text("#!/bin/sh\n# Written by install-mac.sh.\n"
|
||||
"exec /usr/local/bin/python3 /src/__main__.py \"$@\"\n")
|
||||
|
||||
self.assertEqual(self.remove(), [])
|
||||
self.assertIn("install-mac.sh", command.read_text())
|
||||
|
||||
|
||||
class Windows(unittest.TestCase):
|
||||
"""The half of the Windows install the setup program cannot do.
|
||||
|
||||
It writes the Start Menu entry, the command and the uninstaller as it runs,
|
||||
and asks once whether Dikte should start at sign-in. Changing that answer
|
||||
afterwards is what is left here, and the value is faked rather than the
|
||||
registry, so that all of it is read on every platform the tests run on.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.value = ""
|
||||
for name, function in (("_run_entry", lambda: self.value),
|
||||
("_write_run_entry", self._write),
|
||||
("_delete_run_entry", self._delete)):
|
||||
patch = mock.patch.object(integrate, name, function)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.installed = pathlib.Path(self.tmp.name).resolve()
|
||||
self.app = self.installed / "Dikte.exe"
|
||||
self.app.write_text("")
|
||||
|
||||
def _write(self, command):
|
||||
self.value = command
|
||||
|
||||
def _delete(self):
|
||||
there, self.value = bool(self.value), ""
|
||||
return there
|
||||
|
||||
def install(self, force=False):
|
||||
with Frozen(str(self.app), platform="win32"):
|
||||
return integrate.install(force=force)
|
||||
|
||||
def remove(self):
|
||||
with Frozen(str(self.app), platform="win32"):
|
||||
return integrate.remove()
|
||||
|
||||
def test_the_windowed_executable_is_what_starts_at_sign_in(self):
|
||||
"""The console one is what the `dikte` command runs, and a sign-in that
|
||||
started that would open a console window nobody asked for."""
|
||||
with Frozen(str(self.installed / "dikte-cli.exe"), platform="win32"):
|
||||
self.assertEqual(integrate.target(), self.app)
|
||||
|
||||
def test_a_start_does_not_turn_it_on_for_somebody_who_said_no(self):
|
||||
self.assertEqual(self.install(), [])
|
||||
self.assertEqual(self.value, "")
|
||||
|
||||
def test_typing_it_turns_starting_at_sign_in_on(self):
|
||||
self.assertEqual(len(self.install(force=True)), 1)
|
||||
self.assertEqual(self.value, f'"{self.app}"')
|
||||
|
||||
def test_an_installation_that_moved_is_pointed_at_where_it_is_now(self):
|
||||
self.value = '"D:\\Dikte\\Dikte.exe"'
|
||||
self.assertEqual(len(self.install()), 1)
|
||||
self.assertEqual(self.value, f'"{self.app}"')
|
||||
|
||||
def test_running_it_again_changes_nothing(self):
|
||||
self.install(force=True)
|
||||
self.assertEqual(self.install(), [])
|
||||
|
||||
def test_removing_stops_it_starting_and_says_so_once(self):
|
||||
self.install(force=True)
|
||||
self.assertEqual(len(self.remove()), 1)
|
||||
self.assertEqual(self.value, "")
|
||||
self.assertEqual(self.remove(), [])
|
||||
|
||||
|
||||
class WindowsExecutableNames(unittest.TestCase):
|
||||
"""The two Windows executables, read out of the files that name them.
|
||||
|
||||
Windows matches a filename without regard to its case, so Dikte.exe and
|
||||
dikte.exe are one file in one directory and whichever was written second is
|
||||
the only one installed. Nothing else here would catch that: these tests and
|
||||
the builds that check the packaging both run on filesystems where the two
|
||||
names are two files.
|
||||
"""
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
def executables(self):
|
||||
"""What the spec calls each one, in the order it builds them."""
|
||||
spec = (self.root / "packaging" / "dikte.spec").read_text()
|
||||
return [re.search(r'name="(.*?)"', block).group(1)
|
||||
for block in spec.split("EXE(")[1:]]
|
||||
|
||||
def test_the_two_are_more_than_a_case_apart(self):
|
||||
windowed, console = self.executables()
|
||||
self.assertNotEqual(windowed.lower(), console.lower())
|
||||
|
||||
def test_the_command_runs_the_console_one(self):
|
||||
"""The setup writes the shim, so it is the setup that has to be right."""
|
||||
console = self.executables()[1]
|
||||
setup = (self.root / "packaging" / "dikte.iss").read_text()
|
||||
self.assertIn(f"{{app}}\\{console}.exe", setup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+10
-4
@@ -7,6 +7,8 @@ answers by saying nothing at all.
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
@@ -57,13 +59,17 @@ class FakeSocket:
|
||||
|
||||
class Paths(unittest.TestCase):
|
||||
def test_script_path_points_at_dikte(self):
|
||||
self.assertTrue(ipc.script_path().endswith("dikte/__main__.py"))
|
||||
# By its parts rather than as a string: the separator is a backslash on
|
||||
# Windows, and the path is what a shortcut there runs too.
|
||||
path = pathlib.Path(ipc.script_path())
|
||||
self.assertEqual(path.parts[-2:], ("dikte", "__main__.py"))
|
||||
self.assertTrue(os.path.exists(ipc.script_path()))
|
||||
|
||||
def test_the_shortcut_command_runs_it_with_this_interpreter(self):
|
||||
command = ipc.command_for("toggle")
|
||||
self.assertTrue(command.startswith(sys.executable))
|
||||
self.assertTrue(command.endswith(" toggle"))
|
||||
# Read back through the same quoting it went out with: a Windows path
|
||||
# is spelled with backslashes and comes out of the join quoted.
|
||||
self.assertEqual(shlex.split(ipc.command_for("toggle")),
|
||||
[sys.executable, ipc.script_path(), "toggle"])
|
||||
|
||||
def test_a_packaged_build_names_itself_and_no_interpreter(self):
|
||||
"""There is no __main__.py on disk in one, and sys.executable is the
|
||||
|
||||
@@ -13,6 +13,7 @@ is checked on a Mac and the macOS half on Linux, and a change to the chooser
|
||||
cannot quietly break the platform nobody is sitting at.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
@@ -56,6 +57,9 @@ class Chooser(DikteTest):
|
||||
def test_a_mac(self):
|
||||
self.assertIs(self.under("darwin"), paste.MACOS)
|
||||
|
||||
def test_windows(self):
|
||||
self.assertIs(self.under("win32"), paste.WINDOWS)
|
||||
|
||||
def test_a_mac_running_an_x_server_is_still_a_mac(self):
|
||||
"""XQuartz sets DISPLAY, and none of X's programs are what pastes here."""
|
||||
self.assertIs(self.under("darwin", DISPLAY=":0"), paste.MACOS)
|
||||
@@ -504,5 +508,149 @@ class MacClipboardSnapshot(DikteTest):
|
||||
self.assertFalse(os.path.exists(directory))
|
||||
|
||||
|
||||
class FakeWin32:
|
||||
"""user32 and kernel32, as much of both as paste.py calls.
|
||||
|
||||
The clipboard is a string held here. A read materialises it as this
|
||||
machine's own wide characters, which is what wstring_at reads wherever the
|
||||
test runs; a write arrives as the UTF-16 the real clipboard is handed, so
|
||||
what the code sent is exactly what is checked.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.text = None
|
||||
self.buffers = {}
|
||||
self.next_handle = 1
|
||||
self.pressed = [] # (virtual key, flags), in the order sent
|
||||
self.send_result = None # None: report every event as delivered
|
||||
self.held = False # another program has the clipboard open
|
||||
self.out_of_memory = False
|
||||
|
||||
def _keep(self, buffer):
|
||||
handle = self.next_handle
|
||||
self.next_handle += 1
|
||||
self.buffers[handle] = buffer
|
||||
return handle
|
||||
|
||||
# --- user32
|
||||
def OpenClipboard(self, owner):
|
||||
return 0 if self.held else 1
|
||||
|
||||
def CloseClipboard(self):
|
||||
return 1
|
||||
|
||||
def EmptyClipboard(self):
|
||||
self.text = None
|
||||
return 1
|
||||
|
||||
def GetClipboardData(self, fmt):
|
||||
if self.text is None:
|
||||
return 0
|
||||
return self._keep(ctypes.create_unicode_buffer(self.text))
|
||||
|
||||
def SetClipboardData(self, fmt, handle):
|
||||
raw = self.buffers[handle].raw
|
||||
self.text = raw.decode("utf-16-le").split("\x00", 1)[0]
|
||||
return handle
|
||||
|
||||
def SendInput(self, count, inputs, size):
|
||||
self.pressed.extend((entry.union.ki.wVk, entry.union.ki.dwFlags)
|
||||
for entry in inputs)
|
||||
return count if self.send_result is None else self.send_result
|
||||
|
||||
# --- kernel32
|
||||
def GlobalAlloc(self, flags, size):
|
||||
if self.out_of_memory:
|
||||
return 0
|
||||
return self._keep(ctypes.create_string_buffer(size))
|
||||
|
||||
def GlobalLock(self, handle):
|
||||
buffer = self.buffers.get(handle)
|
||||
return ctypes.addressof(buffer) if buffer else 0
|
||||
|
||||
def GlobalUnlock(self, handle):
|
||||
return 1
|
||||
|
||||
def GlobalFree(self, handle):
|
||||
self.buffers.pop(handle, None)
|
||||
return 1
|
||||
|
||||
|
||||
class Windows(Standing, DikteTest):
|
||||
"""Windows shells out to nothing: both halves are calls into the system."""
|
||||
|
||||
platform = "win32"
|
||||
here = paste.WINDOWS
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = FakeWin32()
|
||||
self.patch_attr(paste, "_win_api", lambda: (self.api, self.api))
|
||||
self.patch_attr(paste.time, "sleep", lambda seconds: None)
|
||||
|
||||
def test_what_is_copied_is_what_reads_back(self):
|
||||
paste.copy("ığüşöç İ")
|
||||
self.assertEqual(paste.read_clipboard(), "ığüşöç İ".encode("utf-8"))
|
||||
|
||||
def test_an_empty_clipboard_reads_as_empty_text(self):
|
||||
self.assertEqual(paste.read_clipboard(), b"")
|
||||
|
||||
def test_what_was_saved_goes_back_after_the_paste(self):
|
||||
paste.copy("mine")
|
||||
saved = paste.read_clipboard()
|
||||
paste.copy("the dictation")
|
||||
paste.copy_bytes(saved)
|
||||
self.assertEqual(self.api.text, "mine")
|
||||
|
||||
def test_a_copy_that_fails_leaves_what_was_there(self):
|
||||
"""EmptyClipboard is the point of no return, so nothing runs after it."""
|
||||
paste.copy("mine")
|
||||
for failure in ("out_of_memory", "held"):
|
||||
with self.subTest(failure=failure):
|
||||
setattr(self.api, failure, True)
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.copy("the dictation")
|
||||
self.assertEqual(self.api.text, "mine")
|
||||
setattr(self.api, failure, False)
|
||||
|
||||
def test_the_handle_is_not_leaked_when_the_copy_fails(self):
|
||||
self.api.held = True
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.copy("the dictation")
|
||||
self.assertEqual(self.api.buffers, {})
|
||||
|
||||
def test_readiness_asks_for_no_program_and_no_permission(self):
|
||||
with only_these_tools():
|
||||
self.assertTrue(paste.paste_ready())
|
||||
|
||||
def test_the_keys_go_down_in_order_and_up_in_reverse(self):
|
||||
paste.press("ctrl+v")
|
||||
keyup = 0x0002
|
||||
self.assertEqual(self.api.pressed,
|
||||
[(0x11, 0), (0x56, 0), (0x56, keyup), (0x11, keyup)])
|
||||
|
||||
def test_three_keys(self):
|
||||
paste.press("ctrl+shift+v")
|
||||
self.assertEqual([code for code, _ in self.api.pressed],
|
||||
[0x11, 0x10, 0x56, 0x56, 0x10, 0x11])
|
||||
|
||||
def test_the_other_spellings_land_on_the_same_keys(self):
|
||||
paste.press("super+enter")
|
||||
first, self.api.pressed = self.api.pressed, []
|
||||
paste.press("meta+return")
|
||||
self.assertEqual(self.api.pressed, first)
|
||||
|
||||
def test_a_key_nobody_mapped_is_refused_before_anything_is_sent(self):
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.press("ctrl+f13")
|
||||
self.assertEqual(self.api.pressed, [])
|
||||
|
||||
def test_a_press_the_system_did_not_take_says_so(self):
|
||||
self.api.send_result = 0
|
||||
with self.assertRaises(paste.PasteError) as caught:
|
||||
paste.press("ctrl+v")
|
||||
self.assertIn("SendInput", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+27
-6
@@ -15,29 +15,50 @@ from dikte import paths
|
||||
|
||||
|
||||
class Directories(unittest.TestCase):
|
||||
"""Spelled with forward slashes throughout.
|
||||
|
||||
A backslash separates on Windows only, and every one of these runs on all
|
||||
three systems: `as_posix()` is the one spelling they can all be read in.
|
||||
"""
|
||||
|
||||
def test_linux_keeps_them_apart_and_follows_xdg(self):
|
||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c",
|
||||
"XDG_DATA_HOME": "/d"}):
|
||||
config_dir, data_dir = paths.directories("linux")
|
||||
self.assertEqual(str(config_dir), "/c/dikte")
|
||||
self.assertEqual(str(data_dir), "/d/dikte")
|
||||
self.assertEqual(config_dir.as_posix(), "/c/dikte")
|
||||
self.assertEqual(data_dir.as_posix(), "/d/dikte")
|
||||
|
||||
def test_linux_without_the_variables_set(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
config_dir, data_dir = paths.directories("linux")
|
||||
self.assertTrue(str(config_dir).endswith("/.config/dikte"))
|
||||
self.assertTrue(str(data_dir).endswith("/.local/share/dikte"))
|
||||
self.assertTrue(config_dir.as_posix().endswith("/.config/dikte"))
|
||||
self.assertTrue(data_dir.as_posix().endswith("/.local/share/dikte"))
|
||||
|
||||
def test_a_mac_keeps_both_in_application_support(self):
|
||||
config_dir, data_dir = paths.directories("darwin")
|
||||
self.assertEqual(config_dir, data_dir)
|
||||
self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte"))
|
||||
self.assertTrue(config_dir.as_posix()
|
||||
.endswith("/Library/Application Support/Dikte"))
|
||||
|
||||
def test_a_mac_does_not_read_the_xdg_variables(self):
|
||||
"""A Mac with them set from some other tool still stores in one place."""
|
||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
|
||||
config_dir, _ = paths.directories("darwin")
|
||||
self.assertNotIn("/c", str(config_dir))
|
||||
self.assertNotIn("/c", config_dir.as_posix())
|
||||
|
||||
def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
|
||||
"""Settings roam with the account; several gigabytes must not."""
|
||||
with mock.patch.dict(os.environ, {"APPDATA": "C:/roam",
|
||||
"LOCALAPPDATA": "C:/local"}):
|
||||
config_dir, data_dir = paths.directories("win32")
|
||||
self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte")
|
||||
self.assertEqual(data_dir.as_posix(), "C:/local/Dikte")
|
||||
|
||||
def test_windows_without_the_variables_set(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
config_dir, data_dir = paths.directories("win32")
|
||||
self.assertTrue(config_dir.as_posix().endswith("/AppData/Roaming/Dikte"))
|
||||
self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte"))
|
||||
|
||||
|
||||
class OnePlace(unittest.TestCase):
|
||||
|
||||
@@ -7,6 +7,8 @@ test blends each icon onto a bar of its own and asks whether anything of it
|
||||
survives, once over black and once over white.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -150,6 +152,26 @@ class ApplicationIcon(DikteTest):
|
||||
self.assertFalse(icon.isNull())
|
||||
self.assertIn(48, [size.width() for size in icon.availableSizes()])
|
||||
|
||||
def test_the_windows_icon_is_one_file_holding_every_size(self):
|
||||
"""Written by hand, so the header is what a test can be wrong about:
|
||||
Windows reads the sizes out of the directory at the front rather than
|
||||
out of the images, and a 256 is written there as a zero."""
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = trayicon.write_ico(pathlib.Path(root) / "Dikte.ico")
|
||||
data = path.read_bytes()
|
||||
reserved, kind, count = struct.unpack_from("<HHH", data)
|
||||
self.assertEqual((reserved, kind), (0, 1))
|
||||
self.assertEqual(count, len(trayicon.ICO_SIZES))
|
||||
for index, size in enumerate(trayicon.ICO_SIZES):
|
||||
with self.subTest(size=size):
|
||||
(width, height, _, _, _, _,
|
||||
length, offset) = struct.unpack_from("<BBBBHHII", data,
|
||||
6 + 16 * index)
|
||||
self.assertEqual((width, height), (size % 256, size % 256))
|
||||
image = QImage.fromData(data[offset:offset + length])
|
||||
self.assertEqual((image.width(), image.height()),
|
||||
(size, size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+70
-3
@@ -16,12 +16,16 @@ from PyQt6.QtCore import QPoint, QPointF, Qt
|
||||
from PyQt6.QtGui import QWheelEvent
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from dikte import audio
|
||||
from dikte import cleanup
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
from dikte import hotkey
|
||||
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.
|
||||
@@ -100,6 +104,7 @@ CHANGED = {
|
||||
"pause_shortcut": "Meta+P",
|
||||
"evdev_hotkey": True,
|
||||
"history_limit": 50,
|
||||
"update_check": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -249,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,
|
||||
@@ -281,7 +311,7 @@ class Settings(DikteTest):
|
||||
text = self.shortcut_tab_text(window)
|
||||
self.assertIn("i3 keeps no shortcut registry", text)
|
||||
self.assertNotIn("KWin", text)
|
||||
self.assertIn("__main__.py toggle", text)
|
||||
self.assertIn(ipc.command_for("toggle"), text)
|
||||
# Not a choice to offer where it is the only mechanism there is.
|
||||
self.assertTrue(window.evdev_enabled.isHidden())
|
||||
self.assertFalse([button for button in
|
||||
@@ -458,7 +488,7 @@ class MacSettings(Settings):
|
||||
text = self.shortcut_tab_text(window)
|
||||
self.assertIn("Dikte asks macOS for these combinations", text)
|
||||
self.assertNotIn("KWin", text)
|
||||
self.assertNotIn("__main__.py toggle", text)
|
||||
self.assertNotIn(ipc.command_for("toggle"), text)
|
||||
|
||||
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
|
||||
window = self.window(cfg.Config())
|
||||
@@ -482,7 +512,7 @@ class KdeSettings(Settings):
|
||||
self.assertIn("KWin only reads shortcut settings at startup", text)
|
||||
self.assertIn("Install as a KDE shortcut", text)
|
||||
self.assertNotIn("keeps no shortcut registry", text)
|
||||
self.assertNotIn("__main__.py toggle", text)
|
||||
self.assertNotIn(ipc.command_for("toggle"), text)
|
||||
# Here it is a choice: the wait for the next login, or the key press
|
||||
# reaching the focused application as well.
|
||||
self.assertFalse(window.evdev_enabled.isHidden())
|
||||
@@ -925,6 +955,36 @@ class Overlay(DikteTest):
|
||||
self.assertFalse(widget.muted)
|
||||
|
||||
|
||||
class MeetingSources(DikteTest):
|
||||
"""What the Meeting tab says about the far side, per sound system.
|
||||
|
||||
The box that picks it is empty on a system that cannot record it, and an
|
||||
empty box with nothing next to it reads as a list that has not loaded yet.
|
||||
"""
|
||||
|
||||
def notes(self, meetings):
|
||||
with mock.patch.object(audio, "sound",
|
||||
return_value=audio.PULSE._replace(
|
||||
meetings=meetings)), \
|
||||
only_these_tools(), \
|
||||
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_transcribe_models"):
|
||||
window = settings_ui.SettingsWindow(cfg.Config())
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
return " ".join(label.text()
|
||||
for label in window.findChildren(settings_ui.QLabel))
|
||||
|
||||
def test_a_system_that_cannot_record_the_far_side_says_so(self):
|
||||
self.assertIn("nothing that records what the speakers",
|
||||
self.notes(meetings=False))
|
||||
|
||||
def test_a_system_that_can_says_nothing_of_the_sort(self):
|
||||
self.assertNotIn("nothing that records what the speakers",
|
||||
self.notes(meetings=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -932,6 +992,13 @@ if __name__ == "__main__":
|
||||
class LocalModels(DikteTest):
|
||||
"""The download boxes, without a network and without either program."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# A machine Dikte is actually installed on would otherwise answer the
|
||||
# "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"))
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf)
|
||||
self.addCleanup(window.deleteLater)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user