mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Opening the connection takes ten or twenty seconds, and the byte counts under the model box only start after it. Until then the line read "X has not been downloaded yet" beside a button that had just turned into Stop, so a download that was running looked like a click that had not landed. The stop had the same gap the other way round: should_stop is read between blocks, and the wait for the server to answer is not between blocks, so pressing Stop during it changed nothing on screen either.
1729 lines
80 KiB
Python
1729 lines
80 KiB
Python
"""The two windows, on Qt's offscreen platform.
|
||
|
||
Not a look at the pixels: what these hold onto is the round trip through the
|
||
settings window. Every tab loads a setting into a widget and writes it back on
|
||
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 json
|
||
import os
|
||
import sys
|
||
import time
|
||
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
|
||
|
||
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 hub
|
||
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 dikte.i18n import t
|
||
from tests.support import DikteTest, only_these_tools
|
||
|
||
# The harness below replaces this method on the class so that opening a window
|
||
# in a test never calls anybody; taken here, before any test runs, so the two
|
||
# tests about what it does when called still have the real one.
|
||
REAL_LOAD_HOSTED_MODELS = settings_ui.SettingsWindow._load_hosted_models
|
||
|
||
# One application for the whole run; Qt allows no second one.
|
||
_app = QApplication.instance() or QApplication([])
|
||
|
||
|
||
# A valid non-default value for every setting the window shows. Anything the
|
||
# window does not touch is left out: the round trip cannot lose what it never
|
||
# reads.
|
||
CHANGED = {
|
||
"ui_language": "tr",
|
||
"language": "tr",
|
||
"auto_paste": False,
|
||
"paste_shortcut": "ctrl+shift+v",
|
||
"restore_clipboard": True,
|
||
"overlay_corner": "top-right",
|
||
"overlay_screen": "DP-1",
|
||
"overlay_follows_pointer": True,
|
||
"max_seconds": 120,
|
||
"skip_silent": False,
|
||
"silence_db": -42.0,
|
||
"filter_hallucinations": False,
|
||
"keep_audio": True,
|
||
"openai_api_key": "sk-test-key",
|
||
"groq_api_key": "gsk-test-key",
|
||
"openrouter_api_key": "sk-or-test-key",
|
||
"gemini_api_key": "AIza-test-key",
|
||
"transcribe_provider": "openrouter",
|
||
"transcribe_model": "whisper-1",
|
||
"groq_transcribe_model": "whisper-large-v3",
|
||
"openrouter_transcribe_model": "openai/whisper-1",
|
||
"cleanup_enabled": False,
|
||
"cleanup_provider": "local",
|
||
"cleanup_model": "some/other-model",
|
||
"cleanup_claude_model": "opus",
|
||
"cleanup_codex_model": "gpt-5",
|
||
"cleanup_gemini_model": "gemini-2.5-flash",
|
||
"cleanup_agy_model": "gemini-3.1-pro-low",
|
||
"cleanup_reasoning": "high",
|
||
"local_model": "ggml-small.bin",
|
||
"local_gpu": False,
|
||
"local_preload": False,
|
||
"local_threads": 6,
|
||
"local_llm_model": "gemma-3-4b-it-Q4_K_M.gguf",
|
||
"local_llm_repo": "ggml-org/gemma-4-E2B-it-GGUF",
|
||
"local_llm_gpu": False,
|
||
"local_llm_preload": True,
|
||
"local_llm_reasoning": "low",
|
||
"cleanup_prompt": "Only fix the punctuation.",
|
||
"file_cleanup_prompt": "Keep the stamps where they are.",
|
||
"transcribe_prompt": "Paraşüt, OpenFrame",
|
||
"assistant_provider": "codex",
|
||
"assistant_model": "opus",
|
||
"assistant_permission_mode": "manual",
|
||
"assistant_codex_model": "gpt-5",
|
||
"assistant_codex_sandbox": "read-only",
|
||
"assistant_openrouter_model": "some/agent-model",
|
||
"assistant_agy_model": "gemini-3.1-pro-low",
|
||
"assistant_reasoning": "high",
|
||
"assistant_dir": "/tmp",
|
||
"assistant_timeout": 600,
|
||
"assistant_session_minutes": 90,
|
||
"assistant_paste": False,
|
||
"assistant_cleanup": True,
|
||
"assistant_prompt": "Answer in one sentence.",
|
||
"assistant_shortcut": "Meta+A",
|
||
"meeting_self_name": "Yusuf",
|
||
"meeting_other_name": "Ayşe",
|
||
"meeting_participants": "Mehmet",
|
||
"meeting_model": "some/meeting-model",
|
||
"meeting_reasoning": "medium",
|
||
"meeting_language": "tr",
|
||
"meeting_cleanup": False,
|
||
"meeting_max_seconds": 7200,
|
||
"meeting_keep_audio": True,
|
||
"meeting_shortcut": "Meta+M",
|
||
"meeting_prompt": "Write it as bullet points.",
|
||
"file_timestamps": True,
|
||
"file_cleanup": False,
|
||
"shortcut": "Ctrl+Alt+Space",
|
||
"cancel_shortcut": "Meta+Shift+Space",
|
||
"pause_shortcut": "Meta+P",
|
||
"evdev_hotkey": True,
|
||
"history_limit": 50,
|
||
"update_check": False,
|
||
}
|
||
|
||
|
||
class Settings(DikteTest):
|
||
# What a Mac shows instead, where the combination on offer is a different
|
||
# 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.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"))
|
||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_codex_models"))
|
||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_agy_models"))
|
||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_hosted_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",
|
||
self.path("kglobalshortcutsrc")))
|
||
|
||
def window(self, conf):
|
||
window = settings_ui.SettingsWindow(conf)
|
||
self.addCleanup(window.deleteLater)
|
||
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(), 10)
|
||
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."""
|
||
conf = cfg.Config()
|
||
before = dict(conf.data)
|
||
self.window(conf)._save()
|
||
self.assertEqual(conf.data, before)
|
||
|
||
def test_a_setting_of_your_own_survives_the_round_trip(self):
|
||
self.write_config(self.changed)
|
||
conf = cfg.Config()
|
||
self.window(conf)._save()
|
||
stored = self.read_config_file()
|
||
for key, value in self.changed.items():
|
||
with self.subTest(key=key):
|
||
self.assertEqual(stored[key], value)
|
||
|
||
def test_only_a_save_that_changed_the_language_says_so(self):
|
||
# The owner answers language_changed by replacing the window, so a
|
||
# save that left the language alone must keep quiet.
|
||
i18n = settings_ui.i18n
|
||
self.addCleanup(i18n.set_language, i18n.language())
|
||
window = self.window(cfg.Config())
|
||
heard = []
|
||
window.language_changed.connect(lambda: heard.append(True))
|
||
window._save()
|
||
self.assertEqual(heard, [])
|
||
other = "en" if i18n.language() == "tr" else "tr"
|
||
window._select_data(window.ui_language, other)
|
||
window._save()
|
||
self.assertEqual(heard, [True])
|
||
|
||
def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self):
|
||
"""An OpenRouter id and a Claude alias are not the same field."""
|
||
window = self.window(cfg.Config())
|
||
boxes = {"openrouter": window.cleanup_model_row,
|
||
"opencode": window.cleanup_opencode_model_row,
|
||
"claude": window.cleanup_claude_model,
|
||
"codex": window.cleanup_codex_model}
|
||
for provider, box in boxes.items():
|
||
with self.subTest(provider=provider):
|
||
window._select_data(window.cleanup_provider, provider)
|
||
shown = [name for name, other in boxes.items()
|
||
if not other.isHidden()]
|
||
self.assertEqual(shown, [provider])
|
||
self.assertFalse(box.isHidden())
|
||
|
||
def test_codex_answering_refills_both_of_its_boxes(self):
|
||
"""The list Codex gave replaces the built-in one, in both places, and
|
||
neither loses what was already picked."""
|
||
conf = self.config(cleanup_codex_model="my-own-model")
|
||
window = self.window(conf)
|
||
window._on_codex_models_loaded(["gpt-6", "gpt-6-mini"])
|
||
for combo in (window.cleanup_codex_model, window.assistant_codex_model):
|
||
with self.subTest(combo=combo.objectName() or "combo"):
|
||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||
self.assertEqual(offered[1:], ["gpt-6", "gpt-6-mini"])
|
||
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||
"my-own-model")
|
||
|
||
def test_opencode_answering_refills_both_of_its_boxes(self):
|
||
"""The fetched catalog replaces the built-in list in the cleanup and
|
||
agent boxes alike, and neither loses what was picked."""
|
||
conf = self.config(cleanup_opencode_model="my-own-model")
|
||
window = self.window(conf)
|
||
window._on_opencode_models_loaded(["glm-9", "kimi-k9"], "")
|
||
for combo in (window.cleanup_opencode_model,
|
||
window.assistant_opencode_model):
|
||
with self.subTest(combo=combo.objectName() or "combo"):
|
||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||
self.assertEqual(offered, ["glm-9", "kimi-k9"])
|
||
self.assertEqual(window.cleanup_opencode_model.currentText(),
|
||
"my-own-model")
|
||
|
||
def test_opencode_s_list_arriving_at_open_leaves_the_other_boxes_alone(self):
|
||
conf = self.config(cleanup_opencode_model="my-own-model",
|
||
meeting_model="some/meeting-model")
|
||
window = self.window(conf)
|
||
before = [window.meeting_model.itemText(i)
|
||
for i in range(window.meeting_model.count())]
|
||
window._on_hosted_models_loaded("opencode", ["glm-5.3", "kimi-k3"])
|
||
combo = window.cleanup_opencode_model
|
||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||
self.assertEqual(offered, ["glm-5.3", "kimi-k3"])
|
||
self.assertEqual(combo.currentText(), "my-own-model")
|
||
self.assertEqual([window.meeting_model.itemText(i)
|
||
for i in range(window.meeting_model.count())], before)
|
||
|
||
def test_opencode_cleanup_offers_a_fetch_button_of_its_own(self):
|
||
"""The OpenRouter button leaves the screen with its box, so OpenCode Go
|
||
carries its own."""
|
||
window = self.window(cfg.Config())
|
||
window._select_data(window.cleanup_provider, "opencode")
|
||
self.assertFalse(window.cleanup_opencode_model_row.isHidden())
|
||
self.assertTrue(window.cleanup_model_row.isHidden())
|
||
|
||
def test_agy_answering_refills_both_of_its_boxes(self):
|
||
"""The same arrangement as Codex: both boxes, nothing chosen is lost."""
|
||
conf = self.config(cleanup_agy_model="my-own-model")
|
||
window = self.window(conf)
|
||
window._on_agy_models_loaded(["gemini-4-flash-low", "gemini-4-pro-low"])
|
||
for combo in (window.cleanup_agy_model, window.assistant_agy_model):
|
||
with self.subTest(combo=combo.objectName() or "combo"):
|
||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||
self.assertEqual(offered[1:],
|
||
["gemini-4-flash-low", "gemini-4-pro-low"])
|
||
self.assertEqual(window.cleanup_agy_model.currentText(), "my-own-model")
|
||
|
||
def test_openrouter_s_list_arriving_at_open_refills_cleanup_and_meetings(self):
|
||
conf = self.config(cleanup_model="my/own-model")
|
||
window = self.window(conf)
|
||
window._on_hosted_models_loaded("openrouter", ["a/one", "b/two"])
|
||
for combo in (window.cleanup_model, window.meeting_model):
|
||
with self.subTest(combo=combo.objectName() or "combo"):
|
||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||
self.assertEqual(offered, ["a/one", "b/two"])
|
||
self.assertEqual(window.cleanup_model.currentText(), "my/own-model")
|
||
|
||
def test_google_s_list_arriving_at_open_refills_its_own_box_only(self):
|
||
window = self.window(self.config(cleanup_gemini_model="gemini-x"))
|
||
before = window.cleanup_model.count()
|
||
window._on_hosted_models_loaded("gemini", ["gemini-4-flash"])
|
||
offered = [window.cleanup_gemini_model.itemText(i)
|
||
for i in range(window.cleanup_gemini_model.count())]
|
||
self.assertEqual(offered, ["gemini-4-flash"])
|
||
self.assertEqual(window.cleanup_gemini_model.currentText(), "gemini-x")
|
||
self.assertEqual(window.cleanup_model.count(), before)
|
||
|
||
def test_no_key_no_call_home_at_open(self):
|
||
"""Opening Settings is not consent to be talked about to two vendors."""
|
||
window = self.window(self.config())
|
||
with mock.patch.dict(os.environ, {}, clear=True), \
|
||
mock.patch.object(settings_ui.threading, "Thread") as thread:
|
||
REAL_LOAD_HOSTED_MODELS(window)
|
||
thread.assert_not_called()
|
||
|
||
def test_a_key_on_file_is_fetched_with_at_open(self):
|
||
window = self.window(self.config(openrouter_api_key="sk-or-x",
|
||
gemini_api_key="AIza-x",
|
||
opencode_api_key="opencode-x"))
|
||
with mock.patch.object(settings_ui.threading, "Thread") as thread:
|
||
REAL_LOAD_HOSTED_MODELS(window)
|
||
self.assertEqual(thread.call_count, 3)
|
||
|
||
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,
|
||
"openrouter_base_url": "http://localhost:1234/v1"})
|
||
conf = cfg.Config()
|
||
self.window(conf)._save()
|
||
stored = self.read_config_file()
|
||
self.assertEqual(stored["speech_margin_db"], 15.0)
|
||
self.assertEqual(stored["openrouter_base_url"], "http://localhost:1234/v1")
|
||
|
||
def test_every_global_shortcut_has_a_row_of_its_own(self):
|
||
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(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
|
||
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.
|
||
|
||
Which combination it falls back to is the platform's and is pinned in
|
||
test_hotkey; MacSettings runs this too, and there the answer is not
|
||
Ctrl+Space.
|
||
"""
|
||
conf = cfg.Config()
|
||
window = self.window(conf)
|
||
for box, _status, _missing in window._shortcut_rows.values():
|
||
box.setCurrentText("")
|
||
window._save()
|
||
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"], "")
|
||
|
||
def test_installing_the_discard_key_writes_its_own_entry(self):
|
||
conf = cfg.Config()
|
||
window = self.window(conf)
|
||
window._shortcut_rows["cancel"][0].setCurrentText("Meta+Shift+Space")
|
||
with mock.patch.object(settings_ui.hotkey, "install_shortcut",
|
||
return_value=(True, "saved")) as install:
|
||
window._install_shortcut("cancel")
|
||
combo, command = install.call_args.args
|
||
self.assertEqual(combo, "Meta+Shift+Space")
|
||
self.assertTrue(command.endswith(" cancel"))
|
||
self.assertEqual(install.call_args.kwargs["desktop_id"],
|
||
hotkey.CANCEL_DESKTOP_ID)
|
||
self.assertEqual(conf["cancel_shortcut"], "Meta+Shift+Space")
|
||
|
||
def test_a_prompt_left_at_its_default_is_stored_as_empty(self):
|
||
"""So that switching the interface language switches the prompt too."""
|
||
conf = cfg.Config()
|
||
self.window(conf)._save()
|
||
self.assertEqual(conf["cleanup_prompt"], "")
|
||
self.assertEqual(conf["meeting_prompt"], "")
|
||
self.assertEqual(conf["assistant_prompt"], "")
|
||
|
||
def test_each_provider_keeps_its_own_transcription_model(self):
|
||
self.write_config({"transcribe_provider": "openai",
|
||
"transcribe_model": "gpt-4o-transcribe",
|
||
"groq_transcribe_model": "whisper-large-v3",
|
||
"openrouter_transcribe_model": "openai/whisper-1"})
|
||
conf = cfg.Config()
|
||
window = self.window(conf)
|
||
for provider in ("groq", "openrouter"):
|
||
window.transcribe_provider.setCurrentIndex(
|
||
window.transcribe_provider.findData(provider))
|
||
window._save()
|
||
self.assertEqual(conf["transcribe_provider"], "openrouter")
|
||
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
||
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
|
||
|
||
def test_the_file_model_is_saved_and_only_shown_for_openrouter(self):
|
||
self.write_config({"transcribe_provider": "openrouter",
|
||
"openrouter_file_model": "openai/whisper-large-v3"})
|
||
conf = cfg.Config()
|
||
window = self.window(conf)
|
||
self.assertEqual(window.file_model.currentText(), "openai/whisper-large-v3")
|
||
self.assertTrue(window.stt_form.isRowVisible(window.file_model_row))
|
||
window.file_model.setCurrentText(" deepgram/nova-3 ")
|
||
window._save()
|
||
self.assertEqual(conf["openrouter_file_model"], "deepgram/nova-3")
|
||
window.transcribe_provider.setCurrentIndex(
|
||
window.transcribe_provider.findData("openai"))
|
||
self.assertFalse(window.stt_form.isRowVisible(window.file_model_row))
|
||
|
||
def test_the_provider_box_offers_every_provider_config_knows(self):
|
||
window = self.window(cfg.Config())
|
||
offered = [window.transcribe_provider.itemData(i)
|
||
for i in range(window.transcribe_provider.count())]
|
||
self.assertEqual(offered, ["local"] + list(cfg.TRANSCRIBERS))
|
||
|
||
def test_the_cleanup_box_offers_everyone_cleanup_py_dispatches_to(self):
|
||
window = self.window(cfg.Config())
|
||
offered = [window.cleanup_provider.itemData(i)
|
||
for i in range(window.cleanup_provider.count())]
|
||
self.assertEqual(sorted(offered), sorted(cleanup.PROVIDERS))
|
||
|
||
def test_the_answer_to_a_test_lands_under_the_key_it_was_asked_about(self):
|
||
"""One signal serves all three buttons, so it carries which one asked."""
|
||
window = self.window(cfg.Config())
|
||
window._on_test_done("groq", True, "it works")
|
||
button, answer = window._testers["groq"]
|
||
self.assertEqual(answer.text(), "✓ it works")
|
||
self.assertTrue(button.isEnabled())
|
||
self.assertEqual(window._testers["openai"][1].text(), "")
|
||
|
||
def test_a_key_lands_in_the_field_of_its_own_provider(self):
|
||
self.write_config({"groq_api_key": "gsk-mine"})
|
||
window = self.window(cfg.Config())
|
||
self.assertEqual(window.groq_key.text(), "gsk-mine")
|
||
self.assertEqual(window.openai_key.text(), "")
|
||
|
||
def test_saving_applies_the_lowered_history_limit_at_once(self):
|
||
for index in range(10):
|
||
cfg.append_history({"ts": "now", "text": str(index)})
|
||
self.write_config({"history_limit": 3})
|
||
self.window(cfg.Config())._save()
|
||
self.assertEqual(len(cfg.read_history()), 3)
|
||
|
||
def test_saving_tells_whoever_is_listening(self):
|
||
conf = cfg.Config()
|
||
window = self.window(conf)
|
||
applied = []
|
||
window.applied.connect(lambda: applied.append(True))
|
||
window._save()
|
||
self.assertEqual(applied, [True])
|
||
|
||
def test_the_window_is_readable_in_turkish_too(self):
|
||
self.write_config({"ui_language": "tr"})
|
||
window = self.window(cfg.Config())
|
||
self.assertEqual(window.windowTitle(), "Dikte Ayarları")
|
||
|
||
def test_the_audio_file_switches_are_kept_without_the_save_button(self):
|
||
"""They are ticked to transcribe one file, not to fill in a form."""
|
||
self.write_config({"file_timestamps": False, "file_cleanup": True})
|
||
window = self.window(cfg.Config())
|
||
window.file_timestamps.setChecked(True)
|
||
window.file_cleanup.setChecked(False)
|
||
stored = self.read_config_file()
|
||
self.assertTrue(stored["file_timestamps"])
|
||
self.assertFalse(stored["file_cleanup"])
|
||
|
||
def test_loading_the_audio_file_tab_is_not_taken_for_a_change(self):
|
||
self.write_config({"file_timestamps": True, "file_cleanup": False})
|
||
conf = cfg.Config()
|
||
with mock.patch.object(conf, "save") as save:
|
||
window = self.window(conf)
|
||
save.assert_not_called()
|
||
self.assertTrue(window.file_timestamps.isChecked())
|
||
self.assertFalse(window.file_cleanup.isChecked())
|
||
|
||
def test_the_run_button_comes_back_when_the_stop_lands(self):
|
||
"""In whichever language, since the worker says so through t() too."""
|
||
for language in ("auto", "tr"):
|
||
with self.subTest(language=language):
|
||
self.write_config({"ui_language": language})
|
||
window = self.window(cfg.Config())
|
||
window.file_run.setEnabled(False)
|
||
window._on_file_progress(settings_ui.t("Stopped."))
|
||
self.assertTrue(window.file_run.isEnabled())
|
||
|
||
def test_stop_leaves_nothing_to_press_twice(self):
|
||
window = self.window(cfg.Config())
|
||
with mock.patch.object(window.transcriber, "stop") as stop:
|
||
window.file_stop.setEnabled(True)
|
||
window._stop_file()
|
||
stop.assert_called_once_with()
|
||
self.assertFalse(window.file_stop.isEnabled())
|
||
|
||
|
||
class MacSettings(Settings):
|
||
"""The same window and the same round trip, standing on a Mac.
|
||
|
||
Nothing here is about macOS: it is the rest of the window, checked on the
|
||
platform where three of its widgets are gone and one offers other keys.
|
||
"""
|
||
|
||
platform = "darwin"
|
||
changed: ClassVar[dict] = {**CHANGED, "paste_shortcut": "cmd+shift+v"}
|
||
|
||
def test_there_is_no_install_button_where_nothing_is_installed(self):
|
||
window = self.window(cfg.Config())
|
||
labels = [button.text() for button in
|
||
window.findChildren(settings_ui.QPushButton)]
|
||
self.assertFalse([text for text in labels if "shortcut" in text.lower()])
|
||
|
||
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.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(ipc.command_for("toggle"), text)
|
||
|
||
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
|
||
window = self.window(cfg.Config())
|
||
offered = [window.paste_shortcut.itemText(index)
|
||
for index in range(window.paste_shortcut.count())]
|
||
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(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())
|
||
|
||
|
||
|
||
|
||
class FakeAppKit:
|
||
"""The Objective-C runtime, answering rather than being asked.
|
||
|
||
Stands in for what mac_window._appkit() loads, so what the indicator's
|
||
window would have been sent can be read off `told` on any machine. The
|
||
selectors come back as the names they were registered under, which is what
|
||
lets the tests below name the message they mean.
|
||
"""
|
||
|
||
def __init__(self, window=4242, panel=True, mask=0xe):
|
||
self.objc = self
|
||
self.window, self.panel, self.mask = window, panel, mask
|
||
self.told = {}
|
||
|
||
def objc_getClass(self, name):
|
||
return 1 if name == b"NSPanel" else 0
|
||
|
||
def selector(self, name):
|
||
return name.decode()
|
||
|
||
def shared(self, _class_name, _selector):
|
||
return 1
|
||
|
||
def ask(self, _to, selector):
|
||
return self.window if selector == "window" else 0
|
||
|
||
def ask_unsigned(self, _to, _selector):
|
||
return self.mask
|
||
|
||
def ask_of_class(self, _to, _selector, _klass):
|
||
return self.panel
|
||
|
||
def tell_bool(self, _to, selector, value):
|
||
self.told[selector] = value
|
||
|
||
tell_unsigned = tell_bool
|
||
|
||
|
||
class MacIndicatorWindow(DikteTest):
|
||
"""Which of the three AppKit settings the indicator's window is sent.
|
||
|
||
The nonactivating bit is the one worth a test of its own: it is legal on an
|
||
NSPanel and nowhere else, and sending it to a plain NSWindow raises an
|
||
Objective-C exception, which through ctypes is not something Python can
|
||
catch. It takes the whole process down. `_is_panel` is the only thing
|
||
standing between the two, so what it answers has to decide what is sent.
|
||
"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
from dikte import mac_window
|
||
self.mac_window = mac_window
|
||
self.patch_attr(mac_window.QGuiApplication, "platformName",
|
||
staticmethod(lambda: "cocoa"))
|
||
|
||
def told(self, **kwargs):
|
||
"""What keep_on_screen sends an indicator, against this AppKit."""
|
||
appkit = FakeAppKit(**kwargs)
|
||
self.patch_attr(self.mac_window, "_appkit", lambda: appkit)
|
||
widget = overlay_module.Overlay()
|
||
self.addCleanup(widget.deleteLater)
|
||
self.addCleanup(widget.close)
|
||
self.answered = self.mac_window.keep_on_screen(widget)
|
||
return appkit.told
|
||
|
||
def test_a_window_that_is_not_a_panel_is_never_sent_the_style_mask(self):
|
||
"""The message that would kill the process. The other two still go."""
|
||
told = self.told(panel=False)
|
||
self.assertTrue(self.answered)
|
||
self.assertNotIn("setStyleMask:", told)
|
||
self.assertIs(told["setHidesOnDeactivate:"], False)
|
||
self.assertEqual(told["setCollectionBehavior:"],
|
||
self.mac_window.BEHAVIOUR)
|
||
|
||
def test_a_panel_without_the_bit_is_sent_the_mask_with_it_added(self):
|
||
told = self.told(panel=True, mask=0xe)
|
||
self.assertEqual(told["setStyleMask:"],
|
||
0xe | self.mac_window.NONACTIVATING_PANEL)
|
||
|
||
def test_a_panel_that_already_has_the_bit_is_left_alone(self):
|
||
"""Every dictation runs this again, and a mask Cocoa did not need is a
|
||
window it rebuilds underneath the indicator."""
|
||
told = self.told(panel=True,
|
||
mask=0xe | self.mac_window.NONACTIVATING_PANEL)
|
||
self.assertNotIn("setStyleMask:", told)
|
||
|
||
def test_a_window_the_view_does_not_have_yet_is_not_messaged(self):
|
||
self.assertEqual(self.told(window=0), {})
|
||
self.assertFalse(self.answered)
|
||
|
||
def test_nothing_is_sent_anywhere_but_cocoa(self):
|
||
"""Every other platform hands out a winId that means something else
|
||
entirely, and messaging it crashes the run."""
|
||
self.patch_attr(self.mac_window.QGuiApplication, "platformName",
|
||
staticmethod(lambda: "offscreen"))
|
||
self.assertEqual(self.told(), {})
|
||
self.assertFalse(self.answered)
|
||
|
||
|
||
class GivingTheFrontBack(DikteTest):
|
||
"""Opening the microphone brings Dikte to the front, and the window the
|
||
user was dictating into goes inactive with the caret in it. Nothing can be
|
||
asked of the capture session, so the front is put back afterwards.
|
||
|
||
The watch is driven by hand here: what matters is what it decides, not how
|
||
long Qt takes to tick.
|
||
"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
from dikte import app as dikte_module
|
||
from dikte import mac_window
|
||
self.dikte = dikte_module
|
||
self.activated = []
|
||
self.patch_attr(mac_window, "activate", self.activate)
|
||
self.ticks = []
|
||
outer = self
|
||
|
||
class FakeTimer:
|
||
"""Records what it was asked to do and hands over the tick."""
|
||
|
||
def __init__(self, _parent):
|
||
self.interval = None
|
||
self.running = False
|
||
outer.ticks.append(self)
|
||
|
||
def setInterval(self, milliseconds):
|
||
self.interval = milliseconds
|
||
|
||
def start(self):
|
||
self.running = True
|
||
|
||
def stop(self):
|
||
self.running = False
|
||
|
||
@property
|
||
def timeout(self):
|
||
return self
|
||
|
||
def connect(self, slot):
|
||
self.tick = slot
|
||
|
||
self.patch_attr(dikte_module, "QTimer", FakeTimer)
|
||
|
||
class BareDikte:
|
||
"""As much of the application as this one method touches."""
|
||
|
||
app = None
|
||
_front_watch = None
|
||
_the_front = dikte_module.Dikte._the_front
|
||
_give_the_front_back = dikte_module.Dikte._give_the_front_back
|
||
_stop_watching_the_front = dikte_module.Dikte._stop_watching_the_front
|
||
|
||
self.bare = BareDikte
|
||
|
||
def activate(self, pid):
|
||
self.activated.append(pid)
|
||
return True
|
||
|
||
def watching(self, was_in_front, dikte_in_front, on=None):
|
||
from dikte import mac_window
|
||
self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front)
|
||
dikte = on if on is not None else self.bare()
|
||
dikte._give_the_front_back(was_in_front)
|
||
return self.ticks[-1] if self.ticks else None
|
||
|
||
def test_the_front_goes_back_to_whoever_had_it(self):
|
||
watch = self.watching(4242, dikte_in_front=True)
|
||
watch.tick()
|
||
self.assertEqual(self.activated, [4242])
|
||
self.assertTrue(watch.running) # accepted is not the same as landed
|
||
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False)
|
||
watch.tick()
|
||
self.assertFalse(watch.running)
|
||
|
||
def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self):
|
||
watch = self.watching(4242, dikte_in_front=True)
|
||
watch.tick()
|
||
watch.tick()
|
||
self.assertEqual(self.activated, [4242])
|
||
self.assertTrue(watch.running)
|
||
|
||
def test_an_accepted_restore_that_never_lands_still_times_out(self):
|
||
watch = self.watching(4242, dikte_in_front=True)
|
||
watch.tick()
|
||
with mock.patch.object(self.dikte.time, "monotonic",
|
||
return_value=self.dikte.time.monotonic() + 60):
|
||
watch.tick()
|
||
self.assertFalse(watch.running)
|
||
self.assertEqual(self.activated, [4242])
|
||
|
||
def test_a_restore_the_system_refused_is_retried(self):
|
||
from dikte import mac_window
|
||
self.patch_attr(mac_window, "activate",
|
||
lambda pid: self.activated.append(pid) or False)
|
||
watch = self.watching(4242, dikte_in_front=True)
|
||
watch.tick()
|
||
watch.tick()
|
||
self.assertEqual(self.activated, [4242, 4242])
|
||
self.assertTrue(watch.running)
|
||
|
||
def test_a_front_that_was_never_taken_is_left_where_it_is(self):
|
||
"""The microphone does not always take it, and pulling an application
|
||
forward that is already there is one flicker for nothing."""
|
||
watch = self.watching(4242, dikte_in_front=False)
|
||
watch.tick()
|
||
self.assertEqual(self.activated, [])
|
||
self.assertTrue(watch.running) # still waiting for the moment
|
||
|
||
def test_it_gives_up_rather_than_watching_for_ever(self):
|
||
watch = self.watching(4242, dikte_in_front=False)
|
||
with mock.patch.object(self.dikte.time, "monotonic",
|
||
return_value=self.dikte.time.monotonic() + 60):
|
||
watch.tick()
|
||
self.assertFalse(watch.running)
|
||
self.assertEqual(self.activated, [])
|
||
|
||
def test_a_dictation_started_in_dikte_itself_watches_nothing(self):
|
||
"""Settings is a window of ours, and the front is already where it
|
||
belongs."""
|
||
self.assertIsNone(self.watching(os.getpid(), dikte_in_front=True))
|
||
|
||
def test_a_second_recording_calls_off_the_watch_the_first_one_left(self):
|
||
"""The older watch remembers where the older recording started, and by
|
||
now that is the wrong window to be pulling forward."""
|
||
dikte = self.bare()
|
||
first = self.watching(4242, dikte_in_front=False, on=dikte)
|
||
second = self.watching(1111, dikte_in_front=False, on=dikte)
|
||
self.assertFalse(first.running)
|
||
self.assertTrue(second.running)
|
||
second.tick() # and the survivor is the new one
|
||
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: True)
|
||
second.tick()
|
||
self.assertEqual(self.activated, [1111])
|
||
|
||
def test_a_recording_nobody_needs_watching_for_still_calls_off_the_old_one(self):
|
||
"""Starting the next one from Dikte's own window is not a reason to
|
||
leave the last one's watch running."""
|
||
dikte = self.bare()
|
||
first = self.watching(4242, dikte_in_front=False, on=dikte)
|
||
self.watching(os.getpid(), dikte_in_front=False, on=dikte)
|
||
self.assertFalse(first.running)
|
||
self.assertIsNone(dikte._front_watch)
|
||
|
||
def test_nobody_in_front_is_nobody_to_go_back_to(self):
|
||
self.assertIsNone(self.watching(None, dikte_in_front=True))
|
||
|
||
def mac_window_module(self):
|
||
from dikte import mac_window
|
||
return mac_window
|
||
|
||
|
||
class EveryRecordingProtectsTheFront(DikteTest):
|
||
"""Three ways in, dictation, agent and meeting, and all three open the same
|
||
avfoundation capture, so all three take the front the same way. What is
|
||
checked here is the order: the front has to be noted before the microphone
|
||
is opened, and the watch armed after, or there is nothing to go back to.
|
||
"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
from dikte import app as dikte_module
|
||
self.dikte = dikte_module
|
||
self.order = []
|
||
|
||
def app(self, **attributes):
|
||
"""A Dikte that records the order it does things in, and nothing else.
|
||
|
||
The methods under test are called unbound against it, so the stand-ins
|
||
go on the object rather than on the class.
|
||
"""
|
||
dikte = mock.Mock(**attributes)
|
||
dikte._run_id = 0
|
||
dikte.conf = {"mic_target": "", "max_seconds": 60,
|
||
"meeting_mic_target": "", "meeting_system_target": "",
|
||
"meeting_max_seconds": 60}
|
||
dikte._the_front.side_effect = lambda: self.order.append("noted") or 4242
|
||
dikte._give_the_front_back.side_effect = (
|
||
lambda pid: self.order.append(f"watching {pid}"))
|
||
dikte._begin_recording = (
|
||
lambda owner: self.dikte.Dikte._begin_recording(dikte, owner))
|
||
dikte.recorder.start.side_effect = (
|
||
lambda *_a: self.order.append("microphone"))
|
||
dikte.meeting_recorder.start.side_effect = (
|
||
lambda *_a: self.order.append("microphone"))
|
||
return dikte
|
||
|
||
def test_a_dictation_notes_the_front_before_the_indicator_is_even_shown(self):
|
||
dikte = self.app(state=self.dikte.IDLE, recording=False)
|
||
dikte.overlay.show_recording.side_effect = (
|
||
lambda *_a: self.order.append("indicator"))
|
||
self.dikte.Dikte.start(dikte)
|
||
self.assertEqual(self.order,
|
||
["noted", "indicator", "microphone", "watching 4242"])
|
||
|
||
def test_the_agent_does_the_same(self):
|
||
dikte = self.app(ask_state=self.dikte.IDLE, recording=False)
|
||
self.dikte.Dikte.start_ask(dikte)
|
||
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
|
||
|
||
def test_a_meeting_does_the_same(self):
|
||
"""The one most worth protecting: the user is in a call."""
|
||
dikte = self.app(meeting_state=self.dikte.M_IDLE)
|
||
dikte.meeting_recorder.active = True
|
||
self.dikte.Dikte.start_meeting(dikte)
|
||
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
|
||
|
||
def test_a_meeting_whose_microphone_never_opened_watches_nothing(self):
|
||
dikte = self.app(meeting_state=self.dikte.M_IDLE)
|
||
dikte.meeting_recorder.active = False
|
||
self.dikte.Dikte.start_meeting(dikte)
|
||
self.assertEqual(self.order, ["noted", "microphone"])
|
||
|
||
class Overlay(DikteTest):
|
||
def overlay(self, **kwargs):
|
||
widget = overlay_module.Overlay(**kwargs)
|
||
self.addCleanup(widget.deleteLater)
|
||
self.addCleanup(widget.close)
|
||
return widget
|
||
|
||
def test_it_never_takes_focus(self):
|
||
"""It appears while you are typing; stealing the keyboard would be rude."""
|
||
from PyQt6.QtCore import Qt
|
||
flags = self.overlay().windowFlags()
|
||
self.assertTrue(flags & Qt.WindowType.WindowDoesNotAcceptFocus)
|
||
self.assertTrue(flags & Qt.WindowType.WindowStaysOnTopHint)
|
||
|
||
def test_it_lets_a_click_through_to_whatever_is_under_it(self):
|
||
"""It stays mapped while idle, so without this its corner of the screen
|
||
would stop taking clicks for good. The widget attribute is not enough:
|
||
on a top-level window it only makes Qt drop the event it already took."""
|
||
from PyQt6.QtCore import Qt
|
||
flags = self.overlay().windowFlags()
|
||
self.assertTrue(flags & Qt.WindowType.WindowTransparentForInput)
|
||
|
||
def test_the_one_that_takes_clicks_shrinks_out_of_the_way(self):
|
||
"""It has to stay clickable, so it cannot be transparent to input; it
|
||
gets out of the way by leaving nothing there to click instead."""
|
||
widget = self.overlay(dismissable=True)
|
||
widget.show_busy("Asking Claude…")
|
||
self.assertGreater(widget.width(), 1)
|
||
widget.dismiss()
|
||
self.assertEqual((widget.width(), widget.height()), (1, 1))
|
||
widget.show_busy("Asking Claude…")
|
||
self.assertGreater(widget.width(), 1)
|
||
|
||
def test_recording_then_working_then_done(self):
|
||
widget = self.overlay()
|
||
widget.show_recording()
|
||
self.assertTrue(widget.showing)
|
||
widget.push_level(0.5)
|
||
widget.set_seconds(3)
|
||
widget.show_busy("Transcribing…")
|
||
widget.show_done("Pasted")
|
||
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()
|
||
widget.push_levels(0.4, 0.7)
|
||
self.assertTrue(widget.showing)
|
||
|
||
def test_every_corner_is_understood(self):
|
||
for corner in ("top-left", "top-right", "bottom-left", "bottom-right"):
|
||
with self.subTest(corner=corner):
|
||
widget = self.overlay(corner=corner)
|
||
widget.show_recording()
|
||
widget._reposition()
|
||
|
||
def test_a_named_screen_is_used_instead_of_the_pointer_screen(self):
|
||
screen = mock.Mock()
|
||
screen.name.return_value = "DP-1"
|
||
screen.availableGeometry.return_value = settings_ui.QRect(1920, 0, 1920, 1080)
|
||
widget = self.overlay(screen_name="DP-1")
|
||
with mock.patch.object(QApplication, "screens", return_value=[screen]), \
|
||
mock.patch.object(QApplication, "screenAt") as screen_at:
|
||
widget._reposition()
|
||
screen_at.assert_not_called()
|
||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||
|
||
def _screen(self, name, area):
|
||
screen = mock.Mock()
|
||
screen.name.return_value = name
|
||
screen.availableGeometry.return_value = area
|
||
return screen
|
||
|
||
def _kwin(self, *answer):
|
||
kwin = mock.Mock()
|
||
kwin.isValid.return_value = True
|
||
kwin.call.return_value.arguments.return_value = list(answer)
|
||
return kwin
|
||
|
||
def test_the_compositor_says_which_screen_the_pointer_is_on(self):
|
||
"""Wayland tells a client where the pointer is only while it is over one
|
||
of that client's own windows, so QCursor.pos() comes back at the origin
|
||
and every indicator lands on whichever screen holds it. KWin knows."""
|
||
screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
|
||
self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))]
|
||
widget = self.overlay()
|
||
with mock.patch.object(overlay_module, "_kwin", self._kwin("DP-2")), \
|
||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||
mock.patch.object(QApplication, "screenAt") as screen_at:
|
||
widget._reposition()
|
||
screen_at.assert_not_called()
|
||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||
|
||
def test_the_pointer_decides_when_the_compositor_will_not_say(self):
|
||
"""Every desktop but Plasma, and Plasma while KWin is being replaced."""
|
||
screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080))]
|
||
widget = self.overlay()
|
||
with mock.patch.object(overlay_module, "_kwin", self._kwin()), \
|
||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||
mock.patch.object(QApplication, "screenAt",
|
||
return_value=screens[0]) as screen_at:
|
||
widget._reposition()
|
||
screen_at.assert_called()
|
||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||
|
||
def _two_screens(self):
|
||
return [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
|
||
self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))]
|
||
|
||
def _ticks_on(self, widget, screens, kwin):
|
||
"""Run the ribbon long enough for one look at where the pointer is."""
|
||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||
mock.patch.object(QApplication, "screenAt", return_value=screens[0]):
|
||
for _ in range(overlay_module.FOLLOW_EVERY):
|
||
widget._tick()
|
||
|
||
def test_it_can_be_told_to_keep_up_with_the_pointer(self):
|
||
"""The screen it started on is not always the screen you end up on."""
|
||
screens = self._two_screens()
|
||
kwin = self._kwin("DP-2")
|
||
widget = self.overlay(follow_pointer=True)
|
||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||
widget.show_recording()
|
||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||
self._ticks_on(widget, screens, kwin)
|
||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||
|
||
def test_it_stays_where_it_appeared_unless_it_was_told_otherwise(self):
|
||
"""Left off, because an indicator that jumps desks mid-sentence is one
|
||
more thing moving while you are trying to talk."""
|
||
screens = self._two_screens()
|
||
kwin = self._kwin("DP-2")
|
||
widget = self.overlay()
|
||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||
widget.show_recording()
|
||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||
self._ticks_on(widget, screens, kwin)
|
||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||
|
||
def test_a_named_screen_is_never_left_for_the_pointer(self):
|
||
"""Naming one is the whole answer; following it would undo the naming."""
|
||
screens = self._two_screens()
|
||
kwin = self._kwin("DP-2")
|
||
widget = self.overlay(screen_name="DP-1", follow_pointer=True)
|
||
with mock.patch.object(QApplication, "screens", return_value=screens):
|
||
widget.show_recording()
|
||
self._ticks_on(widget, screens, kwin)
|
||
kwin.call.assert_not_called()
|
||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||
|
||
def test_the_one_on_top_goes_where_the_one_underneath_is(self):
|
||
"""Asking for itself would put the pair on two monitors, with this one
|
||
raised over a ribbon that is not underneath it."""
|
||
screens = self._two_screens()
|
||
kwin = self._kwin("DP-2")
|
||
first = self.overlay()
|
||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||
first.show_recording()
|
||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||
second = self.overlay(below=first)
|
||
second.show_busy("Asking Claude…")
|
||
self.assertEqual(first.pos(), QPoint(1948, 995))
|
||
self.assertEqual(second.pos(), QPoint(1948, 929))
|
||
|
||
def test_the_compositor_is_asked_only_now_and_then(self):
|
||
"""Every tick would be thirty conversations a second about a hand
|
||
moving a mouse."""
|
||
screens = self._two_screens()
|
||
kwin = self._kwin("DP-2")
|
||
widget = self.overlay(follow_pointer=True)
|
||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||
widget.show_recording()
|
||
kwin.call.reset_mock()
|
||
self._ticks_on(widget, screens, kwin)
|
||
self.assertEqual(kwin.call.call_count, 1)
|
||
|
||
def test_a_warning_and_an_error_both_show(self):
|
||
widget = self.overlay()
|
||
widget.show_warning("cleanup failed")
|
||
widget.show_error("no microphone")
|
||
|
||
def test_one_indicator_can_stack_above_another(self):
|
||
first = self.overlay()
|
||
first.show_recording()
|
||
second = self.overlay(below=first)
|
||
second.show_busy("Asking Claude…")
|
||
self.assertTrue(second.showing)
|
||
|
||
def test_a_job_in_progress_can_be_waved_away(self):
|
||
"""Ten minutes of work should not have to be watched for ten minutes."""
|
||
widget = self.overlay(dismissable=True)
|
||
widget.show_busy("Asking Claude…")
|
||
widget.dismiss()
|
||
self.assertFalse(widget.showing)
|
||
|
||
def test_progress_stays_away_once_it_was_waved_off(self):
|
||
widget = self.overlay(dismissable=True)
|
||
widget.show_busy("Asking Claude…")
|
||
widget.muted = True
|
||
widget.dismiss()
|
||
widget.show_busy("Reading a web page…")
|
||
self.assertFalse(widget.showing)
|
||
|
||
def test_the_outcome_shows_even_so(self):
|
||
"""Waving it away asks not to be watched, not to be kept in the dark."""
|
||
widget = self.overlay(dismissable=True)
|
||
widget.show_busy("Asking Claude…")
|
||
widget.muted = True
|
||
widget.dismiss()
|
||
widget.show_done("Pasted")
|
||
self.assertTrue(widget.showing)
|
||
|
||
def test_a_new_run_starts_visible_whatever_the_last_one_did(self):
|
||
widget = self.overlay(dismissable=True)
|
||
widget.muted = True
|
||
widget.show_recording()
|
||
self.assertTrue(widget.showing)
|
||
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"), \
|
||
mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_codex_models"), \
|
||
mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_agy_models"), \
|
||
mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_hosted_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()
|
||
|
||
|
||
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"))
|
||
# And one with Codex on it would ask it for its model list.
|
||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_codex_models"))
|
||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_agy_models"))
|
||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||
"_load_hosted_models"))
|
||
|
||
def window(self, conf):
|
||
window = settings_ui.SettingsWindow(conf)
|
||
self.addCleanup(window.deleteLater)
|
||
self.addCleanup(window.close)
|
||
return window
|
||
|
||
def test_it_opens_where_the_missing_model_is_fixed(self):
|
||
# Nothing can transcribe on a fresh install, which is why this window
|
||
# was opened at all.
|
||
window = self.window(cfg.Config())
|
||
self.assertEqual(window.tabs.currentIndex(), window.api_tab_index)
|
||
|
||
def test_it_opens_where_it_was_left_when_everything_works(self):
|
||
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test")
|
||
self.assertEqual(self.window(conf).tabs.currentIndex(), 0)
|
||
|
||
def test_a_model_that_is_not_here_yet_survives_a_save(self):
|
||
# The box is filled from what is on this disk, so a model that was
|
||
# deleted from underneath is not in the list. Dropping it on save would
|
||
# quietly empty the setting instead of asking for the download again.
|
||
conf = self.config(local_model="ggml-large-v3-turbo-q5_0.bin")
|
||
with mock.patch.object(QMessageBox, "information"):
|
||
self.window(conf)._save()
|
||
self.assertEqual(conf["local_model"], "ggml-large-v3-turbo-q5_0.bin")
|
||
|
||
def test_nothing_is_fetched_for_a_window_nobody_opened(self):
|
||
# DikteTest closes the network, so a request would fail the test. The
|
||
# lists are asked for when the box is shown, not when it is built.
|
||
window = self.window(cfg.Config())
|
||
self.assertTrue(window.local_whisper._pending)
|
||
|
||
def test_a_model_bigger_than_two_gigabytes_counts_up_rather_than_down(self):
|
||
# Qt's int is C++'s 32-bit one, and a 2.3 GB model is more than fits in
|
||
# it: the count came out the far side negative, at "-1%".
|
||
box = self.window(cfg.Config()).local_llm
|
||
box._report("model", 1_048_576, 2_489_757_856)
|
||
_app.processEvents()
|
||
self.assertIn("2.3 GB", box.status.text())
|
||
self.assertNotIn("-", box.status.text())
|
||
|
||
def test_each_download_reports_into_its_own_label(self):
|
||
"""The two can run at once; the tag, not a flag read later, says
|
||
which label the bytes belong to."""
|
||
box = self.window(cfg.Config()).local_llm
|
||
box._report("program", 10, 100)
|
||
box._report("model", 20, 100)
|
||
_app.processEvents()
|
||
self.assertIn("10", box.program_label.text())
|
||
self.assertIn("20", box.status.text())
|
||
|
||
def test_a_download_says_something_before_the_first_byte(self):
|
||
# Opening the connection takes ten or twenty seconds, and the byte
|
||
# counts only start after it. The line underneath still read "has not
|
||
# been downloaded yet" beside a button that now said Stop, so a
|
||
# download that had started looked like a click that had not landed.
|
||
box = self.window(cfg.Config()).local_llm
|
||
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||
box.repo.blockSignals(True)
|
||
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||
box.repo.blockSignals(False)
|
||
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||
with mock.patch.object(settings_ui.threading, "Thread"):
|
||
box._download()
|
||
self.assertIn("Starting", box.status.text())
|
||
# And the same again for the stop, which is read between blocks and so
|
||
# not read at all while the connection is still being opened.
|
||
with mock.patch.object(settings_ui.threading, "Thread"):
|
||
box._download()
|
||
self.assertTrue(box._stop)
|
||
self.assertIn("Stopping", box.status.text())
|
||
|
||
def test_a_long_model_name_is_not_cut_in_half(self):
|
||
# The list under a combo box takes the box's width and elides what does
|
||
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
||
box = self.window(cfg.Config()).local_llm
|
||
box.repo.addItem("ggml-org/a-model-with-a-name-that-runs-on-and-on-GGUF")
|
||
box._fit_popup(box.repo)
|
||
view = box.repo.view()
|
||
self.assertEqual(view.textElideMode(), settings_ui.Qt.TextElideMode.ElideNone)
|
||
widest = max(box.repo.fontMetrics().horizontalAdvance(box.repo.itemText(row))
|
||
for row in range(box.repo.count()))
|
||
self.assertGreaterEqual(view.minimumWidth(), widest)
|
||
|
||
@staticmethod
|
||
def _item(name, size=1 << 20):
|
||
return hub.Item(name, f"https://example.invalid/{name}", size, "")
|
||
|
||
@staticmethod
|
||
def _rows(box):
|
||
"""Every row's text, headings included."""
|
||
return [box.model.itemText(row) for row in range(box.model.count())]
|
||
|
||
@staticmethod
|
||
def _repos(box):
|
||
return [box.repo.itemText(row) for row in range(box.repo.count())]
|
||
|
||
@staticmethod
|
||
def _roomy():
|
||
"""Stand on a machine with room for every suggestion.
|
||
|
||
The order the publishers come in follows the memory, so a test that
|
||
reads it has to say which machine it is standing on. A build runner
|
||
with 7 GB in it puts the two Gemma 4 rows last and is right to.
|
||
"""
|
||
return mock.patch.object(ggml, "total_memory", return_value=64 << 30)
|
||
|
||
@staticmethod
|
||
def _offered(box):
|
||
"""The model names in the box, headings and duplicates left out."""
|
||
names = []
|
||
for row in range(box.model.count()):
|
||
name = box.model.itemData(row)
|
||
if name and name not in names:
|
||
names.append(name)
|
||
return names
|
||
|
||
def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self):
|
||
# The model the settings name is not in the list any more, so its row
|
||
# was rebuilt from the name alone and carries no file to fetch. The
|
||
# button stayed lit and the press did nothing at all.
|
||
box = self.window(self.config(local_llm_model="gone.gguf")).local_llm
|
||
box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF")
|
||
self.assertEqual(box.selected(), "gone.gguf")
|
||
self.assertFalse(box.download_button.isEnabled())
|
||
self.assertIn("gone.gguf", box.status.text())
|
||
self.assertIn("publisher", box.status.text())
|
||
|
||
def test_a_model_without_its_program_does_not_say_it_is_ready(self):
|
||
# The model runs on the program above it, and "Ready" over a missing
|
||
# one is what had people asking why nothing transcribed.
|
||
box = self.window(cfg.Config()).local_whisper
|
||
path = ggml.whisper_model_path("ggml-small.bin")
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_bytes(b"not really a model")
|
||
box.load("ggml-small.bin")
|
||
self.assertFalse(ggml.program_path(ggml.WHISPER))
|
||
self.assertNotIn("Ready", box.status.text())
|
||
self.assertIn("program", box.status.text())
|
||
|
||
def test_changing_the_publisher_changes_the_model(self):
|
||
# The model chosen under the old publisher is not published by the new
|
||
# one. Carried over, it was added back as "not downloaded" and selected
|
||
# again, and the box looked as though the change had not taken.
|
||
box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf",
|
||
local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm
|
||
box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF")
|
||
box.repo.blockSignals(True)
|
||
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||
box.repo.blockSignals(False)
|
||
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
|
||
self.assertEqual(self._offered(box), ["SmolLM3-Q4_K_M.gguf"])
|
||
|
||
def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self):
|
||
# Every change starts its own request, and they do not come back in the
|
||
# order they went out.
|
||
box = self.window(cfg.Config()).local_llm
|
||
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||
box.repo.blockSignals(True)
|
||
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||
box.repo.blockSignals(False)
|
||
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||
box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")],
|
||
"ggml-org/gemma-3-4b-it-GGUF")], "")
|
||
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
|
||
|
||
def test_the_publisher_box_is_not_asked_on_every_keystroke(self):
|
||
box = self.window(cfg.Config()).local_llm
|
||
with mock.patch.object(box, "_fetch_models") as fetch:
|
||
for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"):
|
||
box.repo.setCurrentText(text)
|
||
fetch.assert_not_called()
|
||
box._later.setInterval(0)
|
||
box._later.start()
|
||
_app.processEvents()
|
||
time.sleep(0.05)
|
||
_app.processEvents()
|
||
self.assertEqual(fetch.call_count, 1)
|
||
def test_the_models_are_grouped_by_the_model_rather_than_by_size(self):
|
||
# Sorted by size alone, the turbo files land between the two medium
|
||
# ones, half a screen from the model they are a copy of.
|
||
box = self.window(cfg.Config()).local_whisper
|
||
with mock.patch.object(ggml, "total_memory", return_value=8 << 30), \
|
||
mock.patch.object(ggml, "accelerator", return_value=""):
|
||
box._on_listed([("models", [
|
||
self._item("ggml-medium-q5_0.bin", 539 << 20),
|
||
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||
self._item("ggml-medium-q8_0.bin", 823 << 20),
|
||
self._item("ggml-large-v3-turbo.bin", 1624 << 20),
|
||
], "")], "")
|
||
rows = self._rows(box)
|
||
# The two medium files under one heading, the two turbo ones under
|
||
# theirs, and the model rather than the file deciding the order.
|
||
self.assertEqual(rows[rows.index("medium"):],
|
||
["medium",
|
||
"ggml-medium-q5_0.bin (539.0 MB, 5-bit)",
|
||
"ggml-medium-q8_0.bin (823.0 MB, 8-bit)",
|
||
"large-v3-turbo",
|
||
"ggml-large-v3-turbo-q5_0.bin "
|
||
"(574.0 MB, 5-bit, recommended)",
|
||
"ggml-large-v3-turbo.bin (1.6 GB, 16-bit)"])
|
||
# A heading is not a model, and nothing can be saved from one.
|
||
self.assertIsNone(box.model.itemData(rows.index("medium")))
|
||
|
||
def test_the_row_for_this_machine_is_on_top_and_says_so(self):
|
||
box = self.window(cfg.Config()).local_whisper
|
||
with mock.patch.object(ggml, "total_memory", return_value=8 << 30), \
|
||
mock.patch.object(ggml, "accelerator", return_value=""):
|
||
box._on_listed([("models", [
|
||
self._item("ggml-tiny.bin", 77 << 20),
|
||
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||
], "")], "")
|
||
self.assertEqual(box.selected(), "ggml-large-v3-turbo-q5_0.bin")
|
||
self.assertEqual(box.model.itemData(1), "ggml-large-v3-turbo-q5_0.bin")
|
||
self.assertIn(t("recommended"), box.model.itemText(1))
|
||
|
||
def test_a_model_the_memory_cannot_hold_says_so_on_its_row(self):
|
||
box = self.window(cfg.Config()).local_llm
|
||
box.repo.blockSignals(True)
|
||
box.repo.setCurrentText("ggml-org/x-GGUF")
|
||
box.repo.blockSignals(False)
|
||
with mock.patch.object(ggml, "total_memory", return_value=8 << 30):
|
||
box._on_listed([("models", [
|
||
self._item("small-Q4_0.gguf", 1 << 30),
|
||
self._item("huge-Q8_0.gguf", 12 << 30),
|
||
], "ggml-org/x-GGUF")], "")
|
||
rows = {box.model.itemData(row): box.model.itemText(row)
|
||
for row in range(box.model.count())}
|
||
self.assertNotIn(t("too big for this machine"), rows["small-Q4_0.gguf"])
|
||
self.assertIn(t("too big for this machine"), rows["huge-Q8_0.gguf"])
|
||
|
||
def test_a_recommended_row_is_not_listed_twice_after_a_download(self):
|
||
# It has a row of its own on top as well as one in its group, and
|
||
# reading the rows back the way a finished download does was doubling
|
||
# it in the list every time.
|
||
box = self.window(cfg.Config()).local_whisper
|
||
with self._roomy():
|
||
box._on_listed([("models", [
|
||
self._item("ggml-tiny.bin", 77 << 20),
|
||
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||
], "")], "")
|
||
before = self._offered(box)
|
||
box._fill_models_from_current()
|
||
self.assertEqual(self._offered(box), before)
|
||
names = [box.model.itemData(row) for row in range(box.model.count())]
|
||
self.assertEqual(len([n for n in names if n]), len(before) + 1)
|
||
|
||
def test_a_processor_build_is_not_recommended_the_accurate_model(self):
|
||
# The Vulkan loader is on the machine but what was installed is the
|
||
# processor build, so there is no card in play whatever the loader
|
||
# says, and a 1 GB model on a processor is a wait somebody is sitting
|
||
# through with a sentence half typed.
|
||
binary = self.path("bin/whisper/v1.9.3/whisper-server")
|
||
binary.parent.mkdir(parents=True)
|
||
binary.write_text("")
|
||
binary.chmod(0o755)
|
||
self.path("bin/whisper/installed.json").write_text(json.dumps(
|
||
{"tag": "v1.9.3", "binary": str(binary), "backend": "processor"}))
|
||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||
box = self.window(cfg.Config()).local_whisper
|
||
with mock.patch.object(ggml, "total_memory", return_value=32 << 30), \
|
||
mock.patch.object(ggml, "accelerator", return_value="Vulkan"):
|
||
self.assertEqual(box._suggested(), ggml.SUGGESTED_WHISPER)
|
||
|
||
def test_a_publisher_with_nothing_to_offer_says_why(self):
|
||
# Half of what ggml-org publishes is split across files or past the
|
||
# size cap, and an empty box read as though the click had not landed.
|
||
box = self.window(cfg.Config()).local_llm
|
||
box.repo.blockSignals(True)
|
||
box.repo.setCurrentText("ggml-org/gpt-oss-120b-GGUF")
|
||
box.repo.blockSignals(False)
|
||
box._on_listed([("models", [], "ggml-org/gpt-oss-120b-GGUF")], "")
|
||
self.assertIn("ggml-org/gpt-oss-120b-GGUF", box.status.text())
|
||
self.assertIn("publisher", box.status.text())
|
||
|
||
def test_an_empty_box_nobody_has_asked_yet_is_not_a_publisher_fault(self):
|
||
box = self.window(cfg.Config()).local_llm
|
||
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||
self.assertNotIn("publisher", box.status.text())
|
||
|
||
def test_only_the_suggested_publishers_are_offered_to_start_with(self):
|
||
# Forty repository ids is not a choice anybody can make.
|
||
box = self.window(cfg.Config()).local_llm
|
||
with self._roomy():
|
||
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||
"ggml-org/something-else-GGUF"], "")], "")
|
||
self.assertEqual(self._repos(box), list(ggml.SUGGESTED_LLM))
|
||
|
||
def test_a_suggestion_missing_from_the_listing_is_still_offered(self):
|
||
# The listing is the forty repositories touched most recently, and a
|
||
# publisher that has not been updated in a season falls off it while
|
||
# still being the one to point at.
|
||
box = self.window(cfg.Config()).local_llm
|
||
box._on_listed([("repos", ["ggml-org/something-else-GGUF"], "")], "")
|
||
self.assertIn(ggml.SUGGESTED_LLM[0], self._repos(box))
|
||
|
||
def test_the_switch_brings_the_rest_and_keeps_them_apart(self):
|
||
box = self.window(cfg.Config()).local_llm
|
||
with self._roomy():
|
||
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||
"ggml-org/something-else-GGUF"], "")], "")
|
||
box.every_repo.setChecked(True)
|
||
rows = self._repos(box)
|
||
self.assertEqual(rows[:len(ggml.SUGGESTED_LLM)],
|
||
list(ggml.SUGGESTED_LLM))
|
||
# A separator rather than a heading: the box is typed into as well as
|
||
# chosen from, and a heading would land in the field as a repository.
|
||
self.assertEqual(rows[len(ggml.SUGGESTED_LLM)], "")
|
||
self.assertEqual(rows[-1], "ggml-org/something-else-GGUF")
|
||
|
||
def test_a_publisher_typed_in_is_not_dropped_by_the_next_fetch(self):
|
||
box = self.window(cfg.Config()).local_llm
|
||
box.repo.blockSignals(True)
|
||
box.repo.setCurrentText("ggml-org/something-else-GGUF")
|
||
box.repo.blockSignals(False)
|
||
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||
"ggml-org/something-else-GGUF"], "")], "")
|
||
self.assertFalse(box.every_repo.isChecked())
|
||
self.assertIn("ggml-org/something-else-GGUF", self._repos(box))
|
||
self.assertEqual(box.repository(), "ggml-org/something-else-GGUF")
|
||
|
||
def test_the_chosen_publisher_is_said_in_words(self):
|
||
# A repository id names the publisher, the parameter count and the
|
||
# shape of the weights, and none of that says whether to click it.
|
||
box = self.window(cfg.Config()).local_llm
|
||
box.repo.setCurrentText(ggml.SUGGESTED_LLM[0])
|
||
self.assertTrue(box.repo_note.text())
|
||
box.repo.setCurrentText("ggml-org/nobody-wrote-a-note-GGUF")
|
||
self.assertEqual(box.repo_note.text(), "")
|
||
|
||
def test_the_box_says_what_this_machine_will_run_on(self):
|
||
box = self.window(cfg.Config()).local_whisper
|
||
with mock.patch.object(ggml, "accelerator", return_value="Vulkan"), \
|
||
mock.patch.object(ggml, "total_memory", return_value=32 << 30):
|
||
box._show_machine()
|
||
self.assertIn("Vulkan", box.machine_label.text())
|
||
self.assertIn("32.0 GB", box.machine_label.text())
|
||
|
||
def test_a_machine_with_no_card_is_told_it_is_on_the_processor(self):
|
||
box = self.window(cfg.Config()).local_whisper
|
||
with mock.patch.object(ggml, "accelerator", return_value=""), \
|
||
mock.patch.object(ggml, "total_memory", return_value=8 << 30):
|
||
box._show_machine()
|
||
self.assertIn("processor", box.machine_label.text())
|
||
|
||
def test_a_processor_build_where_the_vulkan_one_belongs_says_so(self):
|
||
# The Vulkan whisper-server is published by hand, and until it is
|
||
# there the download lands upstream's processor build. Said nowhere,
|
||
# an idle graphics card looks exactly like one that is being used.
|
||
binary = self.path("bin/whisper/v1.9.3/whisper-server")
|
||
binary.parent.mkdir(parents=True)
|
||
binary.write_text("")
|
||
binary.chmod(0o755)
|
||
self.path("bin/whisper/installed.json").write_text(json.dumps(
|
||
{"tag": "v1.9.3", "binary": str(binary), "backend": "processor"}))
|
||
# A whisper-server on this machine's PATH would win over the download.
|
||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||
label = self.window(cfg.Config()).local_whisper.program_label.text()
|
||
self.assertIn("v1.9.3", label)
|
||
self.assertIn("Vulkan", label)
|
||
|
||
def test_an_ordinary_install_is_reported_without_a_word_about_vulkan(self):
|
||
binary = self.path("bin/whisper/v1.9.3/whisper-server")
|
||
binary.parent.mkdir(parents=True)
|
||
binary.write_text("")
|
||
binary.chmod(0o755)
|
||
self.path("bin/whisper/installed.json").write_text(json.dumps(
|
||
{"tag": "v1.9.3", "binary": str(binary)}))
|
||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||
label = self.window(cfg.Config()).local_whisper.program_label.text()
|
||
self.assertIn("v1.9.3", label)
|
||
self.assertNotIn("Vulkan", label)
|
||
|
||
def test_a_downloaded_program_can_still_be_asked_for_again(self):
|
||
# The button used to disappear the moment anything landed, which left
|
||
# no way to pick up a newer whisper.cpp, or the Vulkan build on a
|
||
# machine whose driver was installed after Dikte was.
|
||
binary = self.path("bin/whisper/v1.9.3/whisper-server")
|
||
binary.parent.mkdir(parents=True)
|
||
binary.write_text("")
|
||
binary.chmod(0o755)
|
||
self.path("bin/whisper/installed.json").write_text(json.dumps(
|
||
{"tag": "v1.9.3", "binary": str(binary)}))
|
||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||
box = self.window(cfg.Config()).local_whisper
|
||
self.assertTrue(box.install_button.isVisibleTo(box))
|
||
self.assertEqual(box.install_button.text(), t("Download again"))
|
||
|
||
def test_a_system_copy_is_not_offered_for_download(self):
|
||
# Nothing Dikte downloads would be run while one is on the PATH.
|
||
self.patch_attr(ggml.shutil, "which", lambda name: "/usr/bin/" + name)
|
||
box = self.window(cfg.Config()).local_whisper
|
||
self.assertFalse(box.install_button.isVisibleTo(box))
|
||
|
||
def test_only_the_chosen_transcriber_is_on_screen(self):
|
||
window = self.window(self.config(transcribe_provider="openai"))
|
||
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))
|
||
self.assertFalse(window.stt_form.isRowVisible(window.local_whisper))
|
||
window._select_data(window.transcribe_provider, "local")
|
||
self.assertFalse(window.stt_form.isRowVisible(window.transcribe_model_row))
|
||
self.assertTrue(window.stt_form.isRowVisible(window.local_whisper))
|
||
|
||
def test_only_the_chosen_cleaner_is_on_screen(self):
|
||
window = self.window(cfg.Config())
|
||
self.assertTrue(window.cleanup_form.isRowVisible(window.cleanup_model_row))
|
||
self.assertFalse(window.cleanup_form.isRowVisible(window.local_llm))
|
||
window._select_data(window.cleanup_provider, "local")
|
||
self.assertTrue(window.cleanup_form.isRowVisible(window.local_llm))
|
||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_model_row))
|
||
# Its own thinking box, because the two default to opposite things.
|
||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
||
|
||
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
||
window = self.window(cfg.Config())
|
||
rows = {"openrouter": window.cleanup_model_row,
|
||
"gemini": window.cleanup_gemini_model_row,
|
||
"claude": window.cleanup_claude_model,
|
||
"codex": window.cleanup_codex_model,
|
||
"agy": window.cleanup_agy_model}
|
||
for chosen, row in rows.items():
|
||
with self.subTest(provider=chosen):
|
||
window._select_data(window.cleanup_provider, chosen)
|
||
for name, other in rows.items():
|
||
self.assertEqual(window.cleanup_form.isRowVisible(other),
|
||
name == chosen)
|
||
|
||
def test_each_agent_brings_its_own_box_and_no_other(self):
|
||
window = self.window(cfg.Config())
|
||
boxes = {"claude": window.claude_box, "codex": window.codex_box,
|
||
"agy": window.agy_box, "openrouter": window.openrouter_box}
|
||
for chosen, box in boxes.items():
|
||
with self.subTest(provider=chosen):
|
||
window._select_data(window.assistant_provider, chosen)
|
||
for name, other in boxes.items():
|
||
# isHidden rather than isVisible: the window itself is never
|
||
# shown in a test, so nothing in it is ever visible.
|
||
self.assertEqual(other.isHidden(), name != chosen)
|