Merge master: the modules moved into a package

Every file this branch touches moved into dikte/, so the merge is mostly the
rename following the edits. What needed a hand:

hotkey.py: master replaced the _macos()/_gnome() pair with one backend()
chooser, and this branch had added _windows() to the pair. Windows is a fifth
value of the chooser now, and everything that used to ask "macOS or Windows?"
asks backend() instead. The key is held by the running process there, so
installs_shortcuts() and shortcut_needs_restart() are both false for it, and
desktop_name() says Windows.

install.ps1 and the Windows README name dikte/__main__.py, the entry point the
Linux and macOS installers were pointed at in the same commit. The Start Menu
entry, the autostart entry and the dikte.cmd shim all come off one $entry
variable.

settings_ui.py: the shortcut tab now has a Windows sentence of its own, with
the Turkish for it. Falling through to the branch master wrote for a desktop
with no registry would have told a Windows user to check /dev/input. Nothing
covers that branch: there is no Windows Settings test class, the way there is
one for macOS.

CONTRIBUTING: the chooser it names is backend() now, and the test count is the
merged one, 1067 of 1110 running anywhere.
This commit is contained in:
2026-08-16 14:10:28 +03:00
54 changed files with 1688 additions and 573 deletions
+3 -3
View File
@@ -22,9 +22,9 @@ import urllib.request
import wave
from unittest import mock
import assistant
import config as cfg
import i18n
from dikte import assistant
from dikte import config as cfg
from dikte import i18n
# 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
+2 -2
View File
@@ -16,8 +16,8 @@ import threading
import time
import unittest
import api
import ggml
from dikte import api
from dikte import ggml
from tests.support import (
DikteTest,
fake_urlopen,
+1 -1
View File
@@ -14,7 +14,7 @@ import time
import unittest
from unittest import mock
import assistant
from dikte import assistant
from tests.support import DikteTest, fake_urlopen, only_these_tools
+66 -1
View File
@@ -21,7 +21,7 @@ import unittest
import wave
from unittest import mock
import audio
from dikte import audio
from tests.support import (
DikteTest,
FakeCompleted,
@@ -280,6 +280,26 @@ class _StalledStream:
self._released.set()
class _HeldStream:
"""A capture that is paused and taken up again partway through, the way a
key press lands in the middle of a recording rather than between two."""
def __init__(self, data, recorder, pause_at, resume_at=None):
self._data = io.BytesIO(data)
self._recorder = recorder
self._pause_at = pause_at
self._resume_at = resume_at
self.reads = 0
def read(self, size):
if self.reads == self._pause_at:
self._recorder.pause()
elif self.reads == self._resume_at:
self._recorder.pause(False)
self.reads += 1
return self._data.read(size)
class RecordingCommand(OnLinux, DikteTest):
"""Which program captures the microphone, and how it is asked to."""
@@ -499,6 +519,51 @@ class RecorderChain(OnLinux, DikteTest):
self.assertEqual(len(failures), 1)
self.assertIn("0.3", failures[0])
def held(self, data, pause_at, resume_at=None):
"""Record `data` with the recorder paused for part of it."""
recorder = audio.Recorder()
results = []
failures = []
recorder.stopped.connect(lambda *args: results.append(args))
recorder.failed.connect(failures.append)
proc = FakeProcess(data)
proc.stdout = _HeldStream(data, recorder, pause_at, resume_at)
with only_these_tools("pw-record"), \
mock.patch.object(subprocess, "Popen", return_value=proc):
recorder.start()
recorder._thread.join(timeout=5)
# Nothing has ended the capture: a pause holds the microphone.
self.assertEqual(proc.signals, [])
recorder.stop()
return results, failures
def test_what_is_said_while_it_is_held_is_not_in_the_recording(self):
"""The phone call in the middle of a dictation is the whole feature: it
must not reach the transcript, and the two halves must meet."""
results, failures = self.held(tone(2.0), pause_at=8, resume_at=16)
self.assertEqual(failures, [])
path, duration, _ = results[0]
self.addCleanup(os.unlink, path)
dropped = 8 * audio.CHUNK_FRAMES
self.assertAlmostEqual(duration, (2 * audio.RATE - dropped) / audio.RATE,
places=3)
def test_a_recording_held_all_the_way_through_captured_nothing(self):
results, failures = self.held(tone(2.0), pause_at=0)
self.assertEqual(results, [])
self.assertIn("0.3", failures[0])
def test_a_pause_does_not_outlive_the_recording_it_was_asked_for(self):
recorder = audio.Recorder()
recorder.pause()
proc = FakeProcess(tone(0.5))
with only_these_tools("pw-record"), \
mock.patch.object(subprocess, "Popen", return_value=proc):
recorder.start()
self.assertFalse(recorder.paused)
recorder._thread.join(timeout=5)
recorder.cancel()
def test_a_recorder_that_could_not_start(self):
recorder = audio.Recorder()
failures = []
+3 -3
View File
@@ -11,9 +11,9 @@ import subprocess
import unittest
from unittest import mock
import api
import cleanup
import ggml
from dikte import api
from dikte import cleanup
from dikte import ggml
from tests.support import DikteTest, fake_urlopen, sent_json, url_error
from tests.test_api import FakeServer, chat_reply
+16 -8
View File
@@ -12,13 +12,13 @@ import json
import unittest
from unittest import mock
import audio
import cli
import config as cfg
import ggml
import hotkey
import ipc
import paste
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 ipc
from dikte import paste
from tests.support import DikteTest, fake_urlopen, only_these_tools
@@ -165,7 +165,7 @@ class Parser(unittest.TestCase):
name)
def test_every_verb_is_wired_to_something(self):
for verb in ("record", "toggle", "start", "stop", "cancel", "ask",
for verb in ("record", "toggle", "start", "stop", "pause", "cancel", "ask",
"session", "transcribe", "meeting", "meetings", "history",
"config", "prompt", "devices", "models", "test-key",
"doctor", "shortcut", "status", "settings", "restart",
@@ -660,6 +660,14 @@ class Replies(DikteTest):
captured():
self.assertEqual(cli.run(["cancel"]), 0)
def test_pausing_a_recording_nobody_started_is_not_a_failure_either(self):
"""A key that pauses can be pressed when there is nothing to pause, and
it must not start an application to tell you so."""
with mock.patch.object(ipc, "send", return_value=None), \
mock.patch.object(cli, "launch_gui") as launched, captured():
self.assertEqual(cli.run(["pause"]), 0)
self.assertFalse(launched.called)
class TranscribeRunsHere(DikteTest):
"""`dikte transcribe` runs in this process, not in the instance."""
+6 -6
View File
@@ -12,12 +12,12 @@ import sys
import unittest
from unittest import mock
import api
import cleanup
import config as cfg
import ggml
import i18n
import paste
from dikte import api
from dikte import cleanup
from dikte import config as cfg
from dikte import ggml
from dikte import i18n
from dikte import paste
from tests.support import DikteTest
+2 -2
View File
@@ -13,8 +13,8 @@ import unittest
import wave
from unittest import mock
import api
import filetranscribe as ft
from dikte import api
from dikte import filetranscribe as ft
from tests.support import DikteTest, make_wav, silence, tone
+14 -4
View File
@@ -18,8 +18,8 @@ import time
import zipfile
from unittest import mock
import ggml
import hub
from dikte import ggml
from dikte import hub
from tests.support import (DikteTest, fake_urlopen, http_error, json_body,
linux_only, url_error)
@@ -449,7 +449,7 @@ class Catalogue(Local):
STAND_IN = textwrap.dedent("""
import http.server, sys, threading, time
import http.server, socketserver, sys, threading, time
args = sys.argv[1:]
@@ -475,7 +475,17 @@ STAND_IN = textwrap.dedent("""
def log_message(self, *a):
pass
server = http.server.HTTPServer((opt("--host"), int(opt("--port"))), Handler)
# The same server, without the reverse lookup of the address it bound.
# http.server asks the resolver for the name behind 127.0.0.1 in between
# binding and listening; on a Mac nothing answers and the call sits in a
# timeout for half a minute, all of it with the port still closed and a
# start waiting on it.
class Bound(http.server.HTTPServer):
def server_bind(self):
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]
server = Bound((opt("--host"), int(opt("--port"))), Handler)
print("listening on " + opt("--port"), flush=True)
server.serve_forever()
""")
+75 -13
View File
@@ -10,8 +10,8 @@ from unittest import mock
from PyQt6.QtCore import Qt
import config as cfg
import hotkey
from dikte import config as cfg
from dikte import hotkey
from tests.support import DikteTest, FakeCompleted, linux_only
SHORTCUTS_RC = """[services][dikte-toggle.desktop]
@@ -178,38 +178,69 @@ class Bindings(DikteTest):
class Chooser(DikteTest):
"""Which desktop is asked to register the shortcut."""
"""Which mechanism the session gets, and everything keyed off that."""
def setUp(self):
super().setUp()
self.patch_attr(hotkey.sys, "platform", "linux")
self.addCleanup(hotkey._REGISTERED.clear)
@contextlib.contextmanager
def under(self, desktop, has_gsettings=True):
"""A session that says it is this desktop, with or without gsettings."""
def under(self, desktop, tools=True):
"""A session that says it is this desktop, with or without its tools."""
with mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": desktop}), \
mock.patch.object(hotkey.shutil, "which",
return_value="/usr/bin/gsettings"
if has_gsettings else None):
return_value="/usr/bin/tool" if tools else None):
yield
def test_gnome_when_the_session_says_so_and_gsettings_is_there(self):
with self.under("GNOME"):
self.assertEqual(hotkey.backend(), hotkey.GNOME)
self.assertEqual(hotkey.desktop_name(), "GNOME")
def test_kde_otherwise(self):
def test_kde_when_the_session_says_so_and_kwriteconfig_is_there(self):
with self.under("KDE"):
self.assertEqual(hotkey.backend(), hotkey.KDE)
self.assertEqual(hotkey.desktop_name(), "KDE")
def test_a_gnome_session_with_no_gsettings_falls_back(self):
"""Nothing to write the binding with, so KDE's file is the only try."""
with self.under("GNOME", has_gsettings=False):
self.assertEqual(hotkey.desktop_name(), "KDE")
def test_a_desktop_with_no_registry_is_the_listeners(self):
"""The bug this replaced: i3 was told KDE, and KWin was not running."""
for desktop in ("i3", "XFCE", "X-Cinnamon", "sway", "MATE", ""):
with self.subTest(desktop=desktop), self.under(desktop):
self.assertEqual(hotkey.backend(), hotkey.LISTENER)
def test_the_desktop_that_has_no_registry_is_called_by_its_own_name(self):
with self.under("i3"):
self.assertEqual(hotkey.desktop_name(), "i3")
with self.under("XFCE:GNOME-Flashback", tools=False):
self.assertEqual(hotkey.desktop_name(), "XFCE")
with self.under(""):
self.assertEqual(hotkey.desktop_name(), "This desktop")
def test_a_gnome_session_with_no_gsettings_falls_back_to_the_listener(self):
"""Nothing to write the binding with, and KDE's file is not an answer:
KWin is no more running here than it is on i3."""
with self.under("GNOME", tools=False):
self.assertEqual(hotkey.backend(), hotkey.LISTENER)
def test_the_desktop_is_matched_loosely(self):
for desktop in ("GNOME", "ubuntu:GNOME", "gnome"):
with self.subTest(desktop=desktop), self.under(desktop):
self.assertEqual(hotkey.desktop_name(), "GNOME")
self.assertEqual(hotkey.backend(), hotkey.GNOME)
for desktop in ("KDE", "KDE:plasma", "plasma"):
with self.subTest(desktop=desktop), self.under(desktop):
self.assertEqual(hotkey.backend(), hotkey.KDE)
def test_only_a_registry_is_installed_into_and_only_kwin_waits(self):
with self.under("KDE"):
self.assertTrue(hotkey.installs_shortcuts())
self.assertTrue(hotkey.shortcut_needs_restart())
with self.under("GNOME"):
self.assertTrue(hotkey.installs_shortcuts())
self.assertFalse(hotkey.shortcut_needs_restart())
with self.under("i3"):
self.assertFalse(hotkey.installs_shortcuts())
self.assertFalse(hotkey.shortcut_needs_restart())
def test_installing_goes_to_whichever_it_is(self):
with self.under("GNOME"), \
@@ -224,6 +255,20 @@ class Chooser(DikteTest):
hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
kde.assert_called_once()
def test_a_desktop_with_no_registry_installs_nothing_anywhere(self):
with self.under("i3"), \
mock.patch.object(hotkey, "install_kde_shortcut") as kde, \
mock.patch.object(hotkey, "install_gnome_shortcut") as gnome:
ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space")
hotkey.remove_shortcut()
self.assertIsNone(hotkey.shortcut_status())
kde.assert_not_called()
gnome.assert_not_called()
self.assertTrue(ok)
self.assertIn("i3", message)
self.assertNotIn("log out", message)
def test_removing_and_reading_back_go_to_the_same_one(self):
with self.under("GNOME"), \
mock.patch.object(hotkey, "remove_gnome_shortcut") as remove, \
@@ -234,6 +279,17 @@ class Chooser(DikteTest):
remove.assert_called_once()
status.assert_called_once()
def test_only_kde_has_a_list_of_conflicts_to_read(self):
"""A leftover kglobalshortcutsrc from a Plasma the user has since left
would otherwise refuse combinations nothing is holding."""
rc = self.path("kglobalshortcutsrc")
rc.write_text(SHORTCUTS_RC, encoding="utf-8")
self.patch_attr(hotkey, "SHORTCUTS_FILE", rc)
with self.under("KDE"):
self.assertTrue(hotkey.conflicting_shortcuts("Meta+W"))
with self.under("i3"):
self.assertEqual(hotkey.conflicting_shortcuts("Meta+W"), [])
@linux_only
class GnomeAccelerator(DikteTest):
@@ -376,6 +432,12 @@ class KdeShortcut(DikteTest):
self.rc = self.path("kglobalshortcutsrc")
self.patch_attr(hotkey, "APPLICATIONS_DIR", self.apps)
self.patch_attr(hotkey, "SHORTCUTS_FILE", self.rc)
# A Plasma session with kwriteconfig6 on it, whatever the machine
# running the suite happens to be logged into.
session = mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"})
session.start()
self.addCleanup(session.stop)
self.patch_attr(hotkey.shutil, "which", lambda _name: "/usr/bin/tool")
def test_installing_writes_a_desktop_file_kwin_will_launch(self):
with mock.patch.object(subprocess, "run", return_value=FakeCompleted()):
+1 -1
View File
@@ -2,7 +2,7 @@
import json
import hub
from dikte import hub
from tests.support import DikteTest, fake_urlopen, http_error, url_error
RELEASE = {
+1 -1
View File
@@ -9,7 +9,7 @@ import string
import unittest
from unittest import mock
import i18n
from dikte import i18n
from tests.support import DikteTest
+2 -2
View File
@@ -11,7 +11,7 @@ import sys
import unittest
from unittest import mock
import ipc
from dikte import ipc
class FakeSocket:
@@ -57,7 +57,7 @@ class FakeSocket:
class Paths(unittest.TestCase):
def test_script_path_points_at_dikte(self):
self.assertTrue(ipc.script_path().endswith("dikte.py"))
self.assertTrue(ipc.script_path().endswith("dikte/__main__.py"))
self.assertTrue(os.path.exists(ipc.script_path()))
def test_the_shortcut_command_runs_it_with_this_interpreter(self):
+3 -3
View File
@@ -11,9 +11,9 @@ import unittest
import wave
from unittest import mock
import api
import config as cfg
import meeting
from dikte import api
from dikte import config as cfg
from dikte import meeting
from tests.support import DikteTest, make_wav, silence, speech, stereo, tone
+1 -1
View File
@@ -23,7 +23,7 @@ import unittest
from typing import ClassVar
from unittest import mock
import paste
from dikte import paste
from tests.support import DikteTest, FakeCompleted, only_these_tools
+3 -3
View File
@@ -9,9 +9,9 @@ import os
import unittest
from unittest import mock
import config as cfg
import ggml
import paths
from dikte import config as cfg
from dikte import ggml
from dikte import paths
class Directories(unittest.TestCase):
+155
View File
@@ -0,0 +1,155 @@
"""The drawn icons, checked against the thing that made them necessary.
A tray never says what colour it is painting over, so the only question worth
asking of these pixmaps is whether they can be seen at all: a glyph drawn in
black on i3's black bar is an empty slot, which is what issue #27 was. So the
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 sys
import tempfile
import unittest
from unittest import mock
from PyQt6.QtGui import QImage
from PyQt6.QtWidgets import QApplication
from dikte import trayicon
from tests.support import DikteTest
# One application for the whole run; Qt allows no second one.
_app = QApplication.instance() or QApplication([])
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
# Breeze's own panel, as the middle case: not every bar is at one end.
CHARCOAL = (0x31, 0x36, 0x3B)
def _pixels(pixmap):
image = pixmap.toImage().convertToFormat(QImage.Format.Format_ARGB32)
for y in range(image.height()):
for x in range(image.width()):
yield image.pixelColor(x, y)
def _stands_out_from(pixmap, background):
"""True when some pixel of this icon differs from the bar behind it.
The icons are transparent everywhere they are not drawn, so the tray gets
the blend rather than the pixmap, and a pixel that blends back into the bar
is a pixel nobody sees. 60 out of 255 is the gap being asked for, which is
well under black against white and well over the antialiasing at an edge.
"""
for colour in _pixels(pixmap):
weight = colour.alphaF()
ink = (colour.red(), colour.green(), colour.blue())
for channel, behind in zip(ink, background):
if abs(channel * weight + behind * (1 - weight) - behind) > 60:
return True
return False
def _drawn(pixmap):
"""True when anything at all was painted onto this pixmap."""
return any(colour.alpha() > 128 for colour in _pixels(pixmap))
class Tray(DikteTest):
"""The four state icons, on a session whose theme has none of them."""
def setUp(self):
super().setUp()
# Held between calls on purpose, so a test does not read what the one
# before it drew on another platform.
self.patch_attr(trayicon, "_cache", {})
def test_a_name_we_do_not_draw_is_a_null_icon(self):
# app.py asks the theme first and falls through to here, so anything
# answered with a picture would be one the theme should have given.
self.assertTrue(trayicon.icon("emblem-important").isNull())
def test_every_state_has_a_shape(self):
for name in trayicon.SHAPES:
with self.subTest(name=name):
icon = trayicon.icon(name)
self.assertFalse(icon.isNull())
for size in trayicon.SIZES:
self.assertTrue(_drawn(icon.pixmap(size, size)))
def test_visible_on_a_bar_of_any_colour(self):
# The regression: on X11 the icon is composited over a bar whose colour
# nobody declares, and i3's is black.
with mock.patch.object(sys, "platform", "linux"):
for name in trayicon.SHAPES:
for size in trayicon.SIZES:
pixmap = trayicon.icon(name).pixmap(size, size)
for background in (BLACK, WHITE, CHARCOAL):
with self.subTest(name=name, size=size, bar=background):
self.assertTrue(_stands_out_from(pixmap, background))
def test_x11_is_not_handed_a_mask(self):
# Only macOS recolours one. Setting it elsewhere would promise a
# recolouring that never comes, and the outline would be the only thing
# keeping the icon visible either way.
with mock.patch.object(sys, "platform", "linux"):
self.assertFalse(trayicon.icon("media-record").isMask())
def test_macos_gets_a_flat_black_stencil(self):
# There the colour is thrown away and only the coverage is read, so an
# outline would come back as part of the glyph.
with mock.patch.object(sys, "platform", "darwin"):
icon = trayicon.icon("audio-input-microphone")
self.assertTrue(icon.isMask())
for colour in _pixels(icon.pixmap(22, 22)):
if colour.alpha() > 128:
self.assertEqual(
(colour.red(), colour.green(), colour.blue()), BLACK)
def test_the_two_platforms_do_not_share_a_cached_icon(self):
with mock.patch.object(sys, "platform", "darwin"):
self.assertTrue(trayicon.icon("media-record").isMask())
with mock.patch.object(sys, "platform", "linux"):
self.assertFalse(trayicon.icon("media-record").isMask())
class ApplicationIcon(DikteTest):
"""The picture the menu entry, the task bar and the Finder are given."""
def test_written_where_every_desktop_looks(self):
with tempfile.TemporaryDirectory() as root:
written = trayicon.write_hicolor(root)
self.assertEqual(len(written), len(trayicon.HICOLOR_SIZES))
for size, path in zip(trayicon.HICOLOR_SIZES, written):
with self.subTest(size=size):
self.assertEqual(
path.parts[-3:], (f"{size}x{size}", "apps", "dikte.png"))
self.assertTrue(path.is_file())
image = QImage(str(path))
self.assertEqual((image.width(), image.height()),
(size, size))
def test_the_installed_name_is_the_one_the_entries_use(self):
# install.sh writes Icon=dikte into both .desktop files, and a name that
# matches no installed file is the blank slot all over again.
with tempfile.TemporaryDirectory() as root:
self.assertTrue(
all(path.name == "dikte.png"
for path in trayicon.write_hicolor(root)))
def test_a_tile_rather_than_a_stencil(self):
# Coloured on purpose: this one is composited onto backgrounds that are
# nothing like a tray, so it carries its own ground.
pixmap = trayicon.app_pixmap(64)
for background in (BLACK, WHITE):
self.assertTrue(_stands_out_from(pixmap, background))
def test_offered_at_the_sizes_a_window_asks_for(self):
icon = trayicon.app_icon()
self.assertFalse(icon.isNull())
self.assertIn(48, [size.width() for size in icon.availableSizes()])
if __name__ == "__main__":
unittest.main()
+172 -10
View File
@@ -6,21 +6,24 @@ save, so a setting added to one half and not the other is silently reset the
next time anybody presses Save. That is the failure this catches.
"""
import os
import sys
import unittest
from typing import ClassVar
from unittest import mock
from PyQt6.QtCore import QPoint, QPointF, Qt
from PyQt6.QtGui import QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox
import audio
import cleanup
import config as cfg
import ggml
import hotkey
import overlay as overlay_module
import paste
import settings_ui
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 overlay as overlay_module
from dikte import paste
from dikte import settings_ui
from tests.support import DikteTest, only_these_tools
# One application for the whole run; Qt allows no second one.
@@ -96,6 +99,7 @@ CHANGED = {
"file_cleanup": False,
"shortcut": "Ctrl+Alt+Space",
"cancel_shortcut": "Meta+Shift+Space",
"pause_shortcut": "Meta+P",
"evdev_hotkey": True,
"history_limit": 50,
}
@@ -106,18 +110,30 @@ class Settings(DikteTest):
# one. Everything else about the window is the same on both.
changed = CHANGED
platform = "linux"
# A session with no shortcut registry, which is what most Linux desktops
# are. The subclasses below stand on the other two. Pinned rather than
# inherited from whatever the machine running the suite is logged into,
# since half the shortcut tab is built from the answer.
desktop = "i3"
tools: ClassVar[tuple] = ()
def setUp(self):
super().setUp()
# No pactl, no model lists over the network, and no modal dialogue
# waiting for somebody to press OK.
self.enterContext(mock.patch.object(sys, "platform", self.platform))
self.enterContext(only_these_tools())
self.enterContext(only_these_tools(*self.tools))
self.enterContext(mock.patch.dict(
os.environ, {"XDG_CURRENT_DESKTOP": self.desktop}))
self.enterContext(mock.patch.object(QMessageBox, "information"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
"_load_models"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
"_load_transcribe_models"))
# The local model boxes fetch their own list the moment they are shown,
# from a thread, which is nobody's test failing but a real request.
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
"_fetch_models"))
self.enterContext(mock.patch.object(settings_ui.hotkey, "APPLICATIONS_DIR",
self.path("applications")))
self.enterContext(mock.patch.object(settings_ui.hotkey, "SHORTCUTS_FILE",
@@ -129,12 +145,81 @@ class Settings(DikteTest):
self.addCleanup(window.close)
return window
@staticmethod
def wheel():
"""One notch of a mouse wheel, rolled downwards."""
return QWheelEvent(QPointF(5, 5), QPointF(5, 5), QPoint(0, 0),
QPoint(0, -120), Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase, False)
def test_the_window_opens_with_every_tab_on_it(self):
window = self.window(cfg.Config())
tabs = window.findChildren(settings_ui.QTabWidget)[0]
self.assertEqual(tabs.count(), 9)
self.assertEqual(window.windowTitle(), "Dikte Settings")
def test_no_tab_can_stretch_the_window_past_a_small_screen(self):
# A tab that keeps its full height hands that height to the window as a
# minimum, and a tall one then carries Save off the bottom of a laptop
# screen with no way to drag it back. Each tab scrolls instead.
window = self.window(cfg.Config())
for index in range(window.tabs.count()):
window.tabs.setCurrentIndex(index)
self.assertLess(window.minimumSizeHint().height(), 500,
window.tabs.tabText(index))
def test_the_window_cannot_be_dragged_down_to_a_stub(self):
# A tab that scrolls asks for no height of its own, which leaves nothing
# to stop the window being pulled down to a tab bar and half a button.
window = self.window(cfg.Config())
window.resize(1, 1)
self.assertGreaterEqual(window.width(), 500)
self.assertGreaterEqual(window.height(), 360)
def test_the_wheel_passes_over_a_box_it_was_not_aimed_at(self):
# Every tab scrolls now, and a combo box reads the wheel as a change of
# value: rolling down the page with the pointer over the language box
# would pick another language on the way past, and Save would write it
# down. The box takes the wheel once it has been clicked into.
window = self.window(cfg.Config())
# Shown and activated, because a box in a window nobody is looking at
# can be given the focus but never has it.
window.show()
window.activateWindow()
QApplication.processEvents()
box = window.ui_language
# Not the wheel focus a combo box has by default: Qt hands the focus
# over before it delivers the wheel, which would make "has the focus"
# true for the very roll being refused.
self.assertEqual(box.focusPolicy(), Qt.FocusPolicy.StrongFocus)
before = box.currentIndex()
rolled = self.wheel()
QApplication.sendEvent(box, rolled)
self.assertEqual(box.currentIndex(), before)
# Refused, not swallowed. An unaccepted wheel event is the one Qt
# carries on up to the scroll area, so the page moves instead.
self.assertFalse(rolled.isAccepted())
box.setFocus()
QApplication.sendEvent(box, self.wheel())
self.assertNotEqual(box.currentIndex(), before)
def test_a_wrapped_label_keeps_the_room_its_lines_need(self):
# The program path shares a row with a button, and a row is measured
# before its width is known: the label has to claim the second line back
# itself, and give it up again when the window is widened.
label = settings_ui.WrappedLabel()
# Shown, because a hidden widget is told about its new size only once
# somebody looks at it, and the height is worked out from that size.
label.show()
self.addCleanup(label.deleteLater)
line = label.fontMetrics().height()
label.resize(120, line)
label.setText("Installed on the system: /opt/homebrew/bin/whisper-server")
self.assertGreater(label.minimumHeight(), line)
label.resize(2000, line)
self.assertLessEqual(label.minimumHeight(), line)
def test_saving_without_touching_anything_changes_nothing(self):
"""Every widget has to load what is stored, or Save writes its default
over it. This says so for the whole table at once."""
@@ -180,6 +265,31 @@ class Settings(DikteTest):
window = self.window(cfg.Config())
self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS))
def shortcut_tab_text(self, window):
"""Everything the shortcut tab says, as one string."""
return "\n".join(
widget.text() for widget in
window.findChildren(settings_ui.QLabel)
+ window.findChildren(settings_ui.QLineEdit)
+ window.findChildren(settings_ui.QPushButton)
)
def test_the_shortcut_tab_talks_about_this_session_and_no_other(self):
"""A desktop with no registry is told the truth: nothing is installed
anywhere, Dikte is listening, and here is the command to bind if the
desktop should own the keys instead. It used to be promised a KWin that
was not running."""
window = self.window(cfg.Config())
text = self.shortcut_tab_text(window)
self.assertIn("i3 keeps no shortcut registry", text)
self.assertNotIn("KWin", text)
self.assertIn("__main__.py 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
window.findChildren(settings_ui.QPushButton)
if "install" in button.text().lower()])
def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self):
"""The application is unusable without the toggle, so that one box
falls back. The rest stay empty, which is how they are switched off.
@@ -196,6 +306,7 @@ class Settings(DikteTest):
self.assertTrue(conf["shortcut"])
self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle"))
self.assertEqual(conf["cancel_shortcut"], "")
self.assertEqual(conf["pause_shortcut"], "")
self.assertEqual(conf["assistant_shortcut"], "")
self.assertEqual(conf["meeting_shortcut"], "")
@@ -340,7 +451,16 @@ class MacSettings(Settings):
def test_the_listener_is_not_offered_as_a_choice(self):
"""It is the whole mechanism there; turning it off would leave nothing."""
window = self.window(cfg.Config())
self.assertFalse(window.evdev_enabled.isVisible())
self.assertTrue(window.evdev_enabled.isHidden())
def test_the_shortcut_tab_talks_about_this_session_and_no_other(self):
"""Carbon holds the keys here, so there is no command to bind and no
/dev/input to be let into."""
window = self.window(cfg.Config())
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)
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
window = self.window(cfg.Config())
@@ -349,6 +469,27 @@ class MacSettings(Settings):
self.assertEqual(offered, paste.MACOS.shortcuts)
class KdeSettings(Settings):
"""The same window on the one desktop that keeps a registry and makes you
wait for it. Nothing here is about KDE: it is the rest of the window,
checked on the platform where Install, Remove and the listener's own
checkbox are all on screen."""
desktop = "KDE"
tools: ClassVar[tuple] = ("kwriteconfig6",)
def test_the_shortcut_tab_talks_about_this_session_and_no_other(self):
window = self.window(cfg.Config())
text = self.shortcut_tab_text(window)
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)
# 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())
class Overlay(DikteTest):
def overlay(self, **kwargs):
widget = overlay_module.Overlay(**kwargs)
@@ -393,6 +534,27 @@ class Overlay(DikteTest):
widget._conceal()
self.assertFalse(widget.showing)
def test_a_held_recording_says_so_and_stops_moving(self):
"""Everything about the ribbon says a recording is running; a pause the
ribbon did not show would leave all of it saying the opposite."""
widget = self.overlay()
widget.show_recording()
widget.push_level(0.8)
widget.set_paused(True)
levels = list(widget.levels)
widget._tick()
self.assertEqual(widget.levels, levels)
widget.set_paused(False)
widget._tick()
self.assertNotEqual(widget.levels, levels)
def test_a_new_recording_is_never_the_last_one_still_held(self):
widget = self.overlay()
widget.show_recording()
widget.set_paused(True)
widget.show_recording()
self.assertFalse(widget.paused)
def test_a_meeting_shows_both_sides(self):
widget = self.overlay()
widget.show_meeting()
+1 -1
View File
@@ -2,7 +2,7 @@
import unittest
import vad
from dikte import vad
from tests.support import DikteTest
CHUNK = 1024 / 16000 # what worker.py feeds it: one chunk of the level meter
+5 -5
View File
@@ -11,11 +11,11 @@ import os
import unittest
from unittest import mock
import api
import assistant
import config as cfg
import paste
import worker
from dikte import api
from dikte import assistant
from dikte import config as cfg
from dikte import paste
from dikte import worker
from tests.support import DikteTest, make_wav, speech