mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
+3
-3
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user