mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
405 lines
21 KiB
Python
405 lines
21 KiB
Python
"""Native workspace navigation, capture boundaries and persisted results."""
|
||
|
||
from types import SimpleNamespace
|
||
from unittest import mock
|
||
|
||
from PyQt6.QtCore import Qt
|
||
from PyQt6.QtWidgets import QApplication, QMessageBox, QPushButton
|
||
|
||
from dikte import config as cfg, home_ui, i18n
|
||
from dikte.app import Dikte
|
||
from tests import test_ui
|
||
from tests.support import DikteTest
|
||
|
||
|
||
class Home(DikteTest):
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.fixture = test_ui.Settings("runTest")
|
||
self.fixture.setUp()
|
||
self.addCleanup(self.fixture.doCleanups)
|
||
self.conf = self.fixture.config(transcribe_provider="openai", openai_api_key="test",
|
||
cleanup_enabled=False)
|
||
self.settings = self.fixture.window(self.conf)
|
||
self.controller = SimpleNamespace(
|
||
conf=self.conf, state="idle", ask_state="idle", meeting_state="idle",
|
||
meeting_message="", home_messages={}, recording=False, paused=False,
|
||
paste_override={}, open_settings=mock.Mock(), reset_conversation=mock.Mock(),
|
||
_recorded_seconds=lambda: 65, meeting_elapsed=SimpleNamespace(elapsed=lambda: 90000),
|
||
)
|
||
for name in ("start", "stop", "start_ask", "stop_ask", "_toggle_pause", "_cancel",
|
||
"cancel_ask", "_toggle_meeting", "cancel_meeting"):
|
||
setattr(self.controller, name, mock.Mock())
|
||
self.window = home_ui.HomeWindow(self.controller, self.settings)
|
||
self.addCleanup(self.window.deleteLater)
|
||
self.addCleanup(self.window.close)
|
||
self.window.show()
|
||
QApplication.processEvents()
|
||
|
||
def test_daily_tasks_are_reachable_outside_configuration(self):
|
||
for mode in ("dictation", "file", "meeting", "ask", "history"):
|
||
self.window.show_mode(mode)
|
||
QApplication.processEvents()
|
||
self.assertEqual(self.window.pages.currentWidget(), self.window.mode_pages[mode])
|
||
self.window.show_mode("meeting")
|
||
self.assertTrue(self.settings.minutes_view.isVisible())
|
||
self.window.show_mode("history")
|
||
self.assertTrue(self.settings.history.isVisible())
|
||
self.assertEqual(self.settings.tabs.count(), 7)
|
||
self.assertFalse(self.settings.tabs.tabBar().isVisible())
|
||
|
||
def test_native_chrome_and_capture_geometry(self):
|
||
self.assertFalse(self.window.windowFlags() & Qt.WindowType.FramelessWindowHint)
|
||
self.assertEqual(self.window.capture_button.width(), self.window.capture_button.height())
|
||
self.assertGreaterEqual(self.window.capture_button.height(), 100)
|
||
|
||
def test_theme_preview_discard_and_save_keep_runtime_separate(self):
|
||
self.assertEqual(self.conf["theme"], "nord")
|
||
self.settings.theme_choice.setCurrentIndex(self.settings.theme_choice.findData("light"))
|
||
self.assertEqual(self.conf["theme"], "nord")
|
||
self.assertIn("#FFFFFF", self.settings.styleSheet())
|
||
self.assertNotEqual(self.window.styleSheet(), self.settings.styleSheet())
|
||
self.settings._discard_changes()
|
||
self.assertEqual(self.settings.theme_choice.currentData(), "nord")
|
||
self.settings.theme_choice.setCurrentIndex(self.settings.theme_choice.findData("dracula"))
|
||
self.settings._save()
|
||
self.assertEqual(cfg.Config()["theme"], "dracula")
|
||
self.assertEqual(self.window._theme_name, "dracula")
|
||
self.assertIn("#282A36", self.window.styleSheet())
|
||
|
||
def test_theme_colors_are_per_overlay_and_unknown_name_falls_back(self):
|
||
from dikte import overlay, theme
|
||
dark = overlay.Overlay(theme_name="dark")
|
||
light = overlay.Overlay(theme_name="light")
|
||
self.addCleanup(dark.deleteLater)
|
||
self.addCleanup(light.deleteLater)
|
||
self.assertEqual(light.colors["base"].name(), "#ffffff")
|
||
self.assertEqual(dark.colors["base"].name(), "#101010")
|
||
dark.set_theme("dracula")
|
||
self.assertEqual(light.colors["base"].name(), "#ffffff")
|
||
self.assertEqual(theme.stylesheet("unknown"), theme.stylesheet("nord"))
|
||
|
||
def test_wide_meeting_page_keeps_actions_compact(self):
|
||
self.window.resize(900, 700)
|
||
self.window.show_mode("meeting")
|
||
QApplication.processEvents()
|
||
self.assertLess(self.window.meeting_button.width(), 300)
|
||
self.assertLessEqual(self.settings.minutes_view.width(), 680)
|
||
|
||
def test_wide_windows_center_every_task_and_settings_page(self):
|
||
self.window.resize(1900, 1000)
|
||
for mode, area in self.window.mode_pages.items():
|
||
self.window.show_mode(mode)
|
||
QApplication.processEvents()
|
||
with self.subTest(mode=mode):
|
||
self.assertLessEqual(area.widget().width(), 680)
|
||
self.assertAlmostEqual(area.widget().geometry().center().x(),
|
||
area.viewport().rect().center().x(), delta=1)
|
||
self.settings.resize(1900, 1000)
|
||
self.settings.show()
|
||
for index in range(self.settings.tabs.count()):
|
||
self.settings.tabs.setCurrentIndex(index)
|
||
QApplication.processEvents()
|
||
area = self.settings.tabs.widget(index)
|
||
with self.subTest(settings=index):
|
||
self.assertAlmostEqual(area.widget().geometry().center().x(),
|
||
area.viewport().rect().center().x(), delta=1)
|
||
|
||
def test_meeting_and_assistant_actions_share_one_row(self):
|
||
self.window.resize(620, 760)
|
||
for mode, labels in (
|
||
("meeting", ("Copy", "Write it up", "Open the folder", "Delete selected", "Reload")),
|
||
("ask", (self.window.ask_button.text(), "Start a new conversation")),
|
||
):
|
||
self.window.show_mode(mode)
|
||
QApplication.processEvents()
|
||
page = self.window.mode_pages[mode]
|
||
buttons = {b.text(): b for b in page.findChildren(QPushButton)}
|
||
positions = [buttons[label].mapTo(page, buttons[label].rect().center()).y()
|
||
for label in labels]
|
||
with self.subTest(mode=mode):
|
||
self.assertLessEqual(max(positions) - min(positions), 1)
|
||
|
||
def test_empty_state_does_not_invent_a_transcript(self):
|
||
self.assertEqual(self.window.latest_text.toPlainText(), "")
|
||
self.assertFalse(self.window.copy_button.isEnabled())
|
||
self.assertFalse(self.window.open_button.isEnabled())
|
||
|
||
def test_clearing_history_removes_the_latest_preview(self):
|
||
cfg.append_history({"text": "Remove this preview"})
|
||
self.window.refresh_results()
|
||
self.window.show_mode("history")
|
||
with mock.patch.object(self.settings, "_confirm", return_value=True):
|
||
self.settings._clear_history()
|
||
self.window.show_mode("dictation")
|
||
self.assertEqual(self.window.latest_text.toPlainText(), "")
|
||
self.assertFalse(self.window.copy_button.isEnabled())
|
||
|
||
def test_missing_local_model_opens_setup_without_recording(self):
|
||
self.conf["transcribe_provider"] = "local"
|
||
self.window.refresh()
|
||
self.assertEqual(self.window.capture_status.text(), "Set up transcription")
|
||
self.window._capture()
|
||
self.controller.start.assert_not_called()
|
||
self.controller.open_settings.assert_called_once()
|
||
self.assertEqual(self.settings.tabs.currentIndex(), self.settings.api_tab_index)
|
||
self.assertEqual(self.controller.paste_override, {})
|
||
|
||
def test_button_capture_never_automatically_pastes(self):
|
||
def start():
|
||
self.assertIs(self.controller.paste_override["dictation"], False)
|
||
self.controller.state = "recording"
|
||
self.controller.recording = True
|
||
self.controller.start.side_effect = start
|
||
self.window._capture()
|
||
self.controller.start.assert_called_once()
|
||
self.assertFalse(self.controller.paste_override["dictation"])
|
||
self.assertIn("01:05", self.window.capture_status.text())
|
||
self.assertTrue(self.window.pause_button.isVisible())
|
||
self.window._capture()
|
||
self.controller.stop.assert_called_once()
|
||
|
||
def test_failed_capture_does_not_leak_a_paste_override(self):
|
||
self.window._capture()
|
||
self.assertEqual(self.controller.paste_override, {})
|
||
|
||
def test_busy_capture_can_queue_but_does_not_steal_assistant_microphone(self):
|
||
self.controller.state = "busy"
|
||
self.window.refresh()
|
||
self.assertTrue(self.window.capture_button.isEnabled())
|
||
self.controller.ask_state = "recording"
|
||
self.controller.recording = True
|
||
self.window.refresh()
|
||
self.assertFalse(self.window.capture_button.isEnabled())
|
||
self.window._capture()
|
||
self.controller.start.assert_not_called()
|
||
|
||
def test_pause_cancel_and_failures_are_visible(self):
|
||
self.controller.state = "recording"
|
||
self.controller.recording = True
|
||
self.controller.paused = True
|
||
self.window.refresh()
|
||
self.assertIn("Paused", self.window.capture_status.text())
|
||
self.window._pause()
|
||
self.controller._toggle_pause.assert_called_once()
|
||
self.window._cancel_capture()
|
||
self.controller._cancel.assert_called_once()
|
||
self.controller.home_messages["dictation"] = "Microphone permission denied"
|
||
self.window.refresh()
|
||
self.assertIn("permission denied", self.window.capture_error.text())
|
||
|
||
def test_real_latest_dictation_is_separate_from_assistant_answer(self):
|
||
cfg.append_history({"ts": "2026-09-09 12:00:00", "text": "Actual transcript"})
|
||
cfg.append_history({"mode": "ask", "text": "Actual answer"})
|
||
self.window.refresh_results()
|
||
self.assertEqual(self.window.latest_text.toPlainText(), "Actual transcript")
|
||
self.assertEqual(self.window.ask_output.toPlainText(), "Actual answer")
|
||
cursor = self.window.latest_text.textCursor()
|
||
cursor.setPosition(3)
|
||
self.window.latest_text.setTextCursor(cursor)
|
||
self.window.refresh()
|
||
self.window.refresh_results()
|
||
self.assertEqual(self.window.latest_text.textCursor().position(), 3)
|
||
cfg.clear_history()
|
||
self.window.refresh_results()
|
||
self.assertEqual(self.window.latest_text.toPlainText(), "")
|
||
|
||
def test_processing_summary_uses_full_models_and_actual_acceleration(self):
|
||
self.conf["transcribe_provider"] = "local"
|
||
self.conf["local_model"] = "ggml-large-v3-turbo-q5_0.bin"
|
||
self.conf["cleanup_enabled"] = True
|
||
self.conf["cleanup_provider"] = "local"
|
||
self.conf["local_llm_model"] = "gemma-3-4b-it-Q4_K_M.gguf"
|
||
self.conf["local_gpu"] = True
|
||
with mock.patch.object(home_ui.ggml, "state", return_value={
|
||
"whisper": {"running": True, "backend": "CPU"},
|
||
"llama": {"running": True, "backend": "Vulkan"},
|
||
}):
|
||
text = home_ui.processing_locations(self.conf)
|
||
self.assertIn("ggml-large-v3-turbo-q5_0.bin (Local CPU)", text)
|
||
self.assertIn("gemma-3-4b-it-Q4_K_M.gguf (Local GPU)", text)
|
||
self.assertNotIn("API", text)
|
||
with mock.patch.object(home_ui.ggml, "state", return_value={}):
|
||
text = home_ui.processing_locations(self.conf)
|
||
self.assertNotIn("GPU", text)
|
||
self.assertIn("(Local)", text)
|
||
self.assertIn(self.conf["meeting_model"], home_ui.processing_locations(self.conf, "meeting"))
|
||
self.conf["assistant_cleanup"] = True
|
||
self.conf["cleanup_provider"] = "gemini"
|
||
self.conf["assistant_provider"] = "codex"
|
||
text = home_ui.processing_locations(self.conf, "ask")
|
||
self.assertIn(self.conf["cleanup_gemini_model"] + " (API)", text)
|
||
self.assertIn("Codex default model (CLI)", text)
|
||
|
||
def test_timestamped_file_summary_uses_the_timestamp_model(self):
|
||
self.conf["transcribe_provider"] = "openrouter"
|
||
self.conf["openrouter_transcribe_model"] = "google/gemini-audio"
|
||
self.conf["openrouter_file_model"] = "openai/whisper-1"
|
||
text = home_ui.processing_locations(self.conf, "file", file_timestamps=True)
|
||
self.assertIn("openai/whisper-1 (API)", text)
|
||
self.assertNotIn("google/gemini-audio", text)
|
||
|
||
def test_meeting_summary_uses_segment_model_even_without_file_timestamps(self):
|
||
self.conf["transcribe_provider"] = "openai"
|
||
self.conf["transcribe_model"] = "gpt-4o-transcribe"
|
||
text = home_ui.processing_locations(self.conf, "meeting", file_timestamps=False)
|
||
self.assertIn("whisper-1 (API)", text)
|
||
self.assertNotIn("gpt-4o-transcribe", text)
|
||
self.conf["transcribe_provider"] = "openrouter"
|
||
self.conf["openrouter_file_model"] = "mistralai/voxtral-small-24b-2507"
|
||
text = home_ui.processing_locations(self.conf, "meeting", file_timestamps=False)
|
||
self.assertIn("mistralai/voxtral-small-24b-2507 (API)", text)
|
||
|
||
def test_unsupported_meeting_is_disabled(self):
|
||
self.enterContext(mock.patch.object(home_ui.audio, "sound", return_value=SimpleNamespace(meetings=False)))
|
||
self.window.show_mode("meeting")
|
||
self.assertFalse(self.window.meeting_button.isEnabled())
|
||
self.assertIn("not supported", self.window.meeting_hint.text())
|
||
self.window._meeting()
|
||
self.controller._toggle_meeting.assert_not_called()
|
||
|
||
def test_assistant_scope_uses_actual_shortcut_and_permissions(self):
|
||
self.conf["assistant_provider"] = "codex"
|
||
self.conf["assistant_shortcut"] = "Ctrl+Alt+A"
|
||
self.conf["assistant_dir"] = self.root
|
||
self.conf["assistant_codex_sandbox"] = "danger-full-access"
|
||
self.window.show_mode("ask")
|
||
self.assertIn("Ctrl+Alt+A", self.window.ask_scope.text())
|
||
self.assertIn(self.root, self.window.ask_scope.text())
|
||
self.assertIn("No sandbox at all", self.window.ask_scope.text())
|
||
self.assertEqual(self.window.ask_button.text(), "Set up assistant")
|
||
self.window._ask()
|
||
self.controller.start_ask.assert_not_called()
|
||
self.controller.open_settings.assert_called_once()
|
||
|
||
def test_failed_settings_save_keeps_runtime_config_and_form_edits(self):
|
||
before = dict(self.conf.data)
|
||
self.settings.auto_paste.setChecked(not self.conf["auto_paste"])
|
||
self.assertEqual(self.settings.dirty_label.text(), "Unsaved changes")
|
||
with mock.patch.object(self.conf, "save", side_effect=OSError("disk full")), mock.patch.object(QMessageBox, "warning"):
|
||
self.settings._save()
|
||
self.assertEqual(self.conf.data, before)
|
||
self.assertNotEqual(self.settings.auto_paste.isChecked(), self.conf["auto_paste"])
|
||
self.settings.file_path = "/tmp/chosen.wav"
|
||
self.settings._discard_changes()
|
||
self.assertEqual(self.settings.dirty_label.text(), "")
|
||
self.assertEqual(self.settings.file_path, "/tmp/chosen.wav")
|
||
|
||
def test_small_window_keeps_navigation_and_footer_accessible(self):
|
||
self.window.resize(460, 460)
|
||
QApplication.processEvents()
|
||
for mode in ("dictation", "file", "meeting", "ask", "history"):
|
||
self.window.show_mode(mode)
|
||
QApplication.processEvents()
|
||
if self.window.footer.isVisible():
|
||
self.assertTrue(self.window.rect().contains(self.window.footer.geometry()))
|
||
self.assertTrue(self.window.rect().contains(self.window.mode_buttons["dictation"].geometry().topLeft()))
|
||
|
||
def test_apply_merges_unrelated_cli_changes_and_keeps_user_edits(self):
|
||
self.settings.auto_paste.setChecked(False)
|
||
self.conf["shortcut"] = "Ctrl+Shift+F9"
|
||
self.conf["groq_transcribe_model"] = "external-model"
|
||
self.settings.refresh_configuration()
|
||
self.assertFalse(self.settings.auto_paste.isChecked())
|
||
self.settings._save()
|
||
self.assertFalse(self.conf["auto_paste"])
|
||
self.assertEqual(self.conf["shortcut"], "Ctrl+Shift+F9")
|
||
self.assertEqual(self.conf["groq_transcribe_model"], "external-model")
|
||
self.assertEqual(self.settings._shortcut_rows["toggle"][0].currentText(), "Ctrl+Shift+F9")
|
||
self.assertEqual(self.settings.dirty_label.text(), "")
|
||
|
||
def test_clean_form_refreshes_from_cli_without_changing_file_result(self):
|
||
self.settings.file_output.setPlainText("Existing file result")
|
||
self.conf["shortcut"] = "Ctrl+Alt+F9"
|
||
self.settings.refresh_configuration()
|
||
self.assertEqual(self.settings._shortcut_rows["toggle"][0].currentText(), "Ctrl+Alt+F9")
|
||
self.assertEqual(self.settings.file_output.toPlainText(), "Existing file result")
|
||
self.assertEqual(self.settings.dirty_label.text(), "")
|
||
|
||
def test_cached_provider_model_edits_remain_dirty_after_switching_back(self):
|
||
self.settings._select_data(self.settings.transcribe_provider, "groq")
|
||
self.settings.transcribe_model.setCurrentText("my-groq-model")
|
||
self.settings._select_data(self.settings.transcribe_provider, "openai")
|
||
self.assertEqual(self.settings.dirty_label.text(), "Unsaved changes")
|
||
self.settings._save()
|
||
self.assertEqual(self.conf["groq_transcribe_model"], "my-groq-model")
|
||
|
||
def test_cli_selected_new_source_is_resolved_when_settings_reopens(self):
|
||
self.conf["mic_target"] = "new-usb"
|
||
self.settings.refresh_configuration()
|
||
with mock.patch.object(home_ui.audio, "list_sources", return_value=[("new-usb", "New USB microphone")]), mock.patch.object(home_ui.audio, "list_monitors", return_value=[]):
|
||
self.settings.refresh_sources()
|
||
self.assertEqual(self.settings.mic.currentData(), "new-usb")
|
||
self.assertEqual(self.settings.mic.currentText(), "New USB microphone")
|
||
self.settings._save()
|
||
self.assertEqual(self.conf["mic_target"], "new-usb")
|
||
|
||
def test_language_rebuild_preserves_file_result_and_active_mode(self):
|
||
from dikte.meeting import MeetingPipeline
|
||
controller = Dikte.__new__(Dikte)
|
||
controller.__dict__.update(vars(self.controller))
|
||
controller.meetings = MeetingPipeline(self.conf)
|
||
controller._make_settings()
|
||
old_settings = controller.settings_window
|
||
old_home = home_ui.HomeWindow(controller, old_settings)
|
||
controller.home_window = old_home
|
||
old_home.show_mode("file")
|
||
old_home.show()
|
||
old_settings.file_path = "/tmp/chosen.wav"
|
||
old_settings.file_label.setText("chosen.wav")
|
||
old_settings.file_output.setPlainText("Retained transcript")
|
||
old_settings.file_segments = [{"text": "Retained transcript", "start": 0, "end": 2}]
|
||
i18n.set_language("tr")
|
||
controller._reopen_settings()
|
||
self.addCleanup(controller.settings_window.deleteLater)
|
||
self.addCleanup(controller.settings_window.close)
|
||
self.addCleanup(controller.home_window.deleteLater)
|
||
self.addCleanup(controller.home_window.close)
|
||
self.assertEqual(controller.home_window.mode, "file")
|
||
self.assertEqual(controller.settings_window.file_path, "/tmp/chosen.wav")
|
||
self.assertEqual(controller.settings_window.file_output.toPlainText(), "Retained transcript")
|
||
self.assertTrue(controller.settings_window.file_save_srt.isEnabled())
|
||
self.assertEqual(controller.home_window.mode_buttons["file"].text(), "Dosya")
|
||
|
||
def test_disconnected_configured_source_survives_an_unrelated_apply(self):
|
||
self.conf["mic_target"] = "disconnected-usb"
|
||
self.settings.refresh_configuration()
|
||
self.assertEqual(self.settings.mic.currentData(), "disconnected-usb")
|
||
self.settings.auto_paste.setChecked(False)
|
||
self.settings._save()
|
||
self.assertEqual(self.conf["mic_target"], "disconnected-usb")
|
||
|
||
def test_source_refresh_keeps_selection_and_discovers_hotplugged_devices(self):
|
||
self.settings.mic.addItem("Old microphone", "old")
|
||
self.settings.mic.setCurrentIndex(self.settings.mic.findData("old"))
|
||
with mock.patch.object(home_ui.audio, "list_sources", return_value=[("usb", "USB microphone")]), mock.patch.object(home_ui.audio, "list_monitors", return_value=[("loop", "Loopback")]):
|
||
self.settings.refresh_sources()
|
||
self.assertEqual(self.settings.mic.currentData(), "old")
|
||
self.assertGreaterEqual(self.settings.mic.findData("usb"), 0)
|
||
self.assertGreaterEqual(self.settings.meeting_mic.findData("usb"), 0)
|
||
self.assertGreaterEqual(self.settings.meeting_system.findData("loop"), 0)
|
||
|
||
def test_missing_assistant_directory_displays_the_actual_fallback(self):
|
||
self.conf["assistant_provider"] = "codex"
|
||
self.conf["assistant_dir"] = "/does/not/exist/dikte-test"
|
||
self.window.show_mode("ask")
|
||
self.assertNotIn(self.conf["assistant_dir"], self.window.ask_scope.text())
|
||
self.assertIn(home_ui.assistant.working_dir(self.conf), self.window.ask_scope.text())
|
||
|
||
def test_turkish_task_labels_and_runtime_status(self):
|
||
i18n.set_language("tr")
|
||
self.window.refresh()
|
||
self.assertEqual(self.window.capture_status.text(), "Konuşmaya hazır")
|
||
self.assertIn("Dikte:", self.window.capture_models.text())
|
||
self.assertIn("Temizleme:", self.window.capture_models.text())
|
||
|
||
def test_completed_run_refreshes_workspace_without_changing_controller_state(self):
|
||
controller = Dikte.__new__(Dikte)
|
||
controller.home_messages = {}
|
||
controller.home_window = self.window
|
||
controller._waiters = {}
|
||
cfg.append_history({"text": "Finished"})
|
||
controller._settle("dictation", {"ok": True, "text": "Finished"})
|
||
self.assertEqual(self.window.latest_text.toPlainText(), "Finished")
|
||
self.assertEqual(controller.home_messages["dictation"], "Transcript ready")
|