from __future__ import annotations from pathlib import Path import pytest from PySide6.QtWidgets import QComboBox, QLineEdit, QMessageBox from voice_transcriptor.models import AppSettings from voice_transcriptor.services.credentials import CredentialError from voice_transcriptor.services.settings import SettingsError from voice_transcriptor.services.settings import SUPPORTED_MODEL_SUGGESTIONS from voice_transcriptor.ui.settings_dialog import SettingsDialog class FakeRepository: def __init__(self) -> None: self.saved: list[AppSettings] = [] def save(self, settings: AppSettings) -> None: self.saved.append(settings) class FakeCredentials: def __init__(self, api_key: str | None = None) -> None: self.api_key = api_key self.saved_values: list[str] = [] def get_api_key(self) -> str | None: return self.api_key def has_api_key(self) -> bool: return bool(self.api_key) def set_api_key(self, value: str) -> None: self.saved_values.append(value) self.api_key = value @pytest.fixture def settings(tmp_path: Path) -> AppSettings: output_directory = tmp_path / "output" output_directory.mkdir() return AppSettings("gpt-4o-transcribe", "pt-BR", output_directory) def test_populates_fields_from_settings(qtbot, settings: AppSettings) -> None: dialog = SettingsDialog(settings, FakeRepository(), FakeCredentials()) qtbot.addWidget(dialog) assert dialog.model_input.text() == settings.model assert dialog.language_input.text() == settings.language assert dialog.output_directory_input.text() == str(settings.output_directory) assert dialog.api_key_input.text() == "" assert dialog.advanced_panel.isVisible() is False assert dialog.chunk_duration_input.value() == 900 assert dialog.chunk_overlap_input.value() == 15 assert isinstance(dialog.model_input, QComboBox) assert dialog.model_input.isEditable() assert tuple(dialog.model_input.itemText(i) for i in range(dialog.model_input.count())) == SUPPORTED_MODEL_SUGGESTIONS assert dialog.context_input.toPlainText() == settings.context_vocabulary def test_advanced_settings_are_saved(qtbot, settings: AppSettings) -> None: repository = FakeRepository() dialog = SettingsDialog(settings, repository, FakeCredentials()) qtbot.addWidget(dialog) dialog.chunk_duration_input.setValue(1200) dialog.chunk_overlap_input.setValue(20) dialog.retain_temporary_files_input.setChecked(True) dialog.save() assert repository.saved[0].chunk_duration_seconds == 1200 assert repository.saved[0].chunk_overlap_seconds == 20 assert repository.saved[0].retain_temporary_files is True def test_api_key_input_uses_password_echo_mode(qtbot, settings: AppSettings) -> None: dialog = SettingsDialog(settings, FakeRepository(), FakeCredentials()) qtbot.addWidget(dialog) assert dialog.api_key_input.echoMode() is QLineEdit.EchoMode.Password def test_blank_api_key_preserves_existing_credential(qtbot, settings: AppSettings) -> None: credentials = FakeCredentials(api_key="stored-token") repository = FakeRepository() dialog = SettingsDialog(settings, repository, credentials) qtbot.addWidget(dialog) dialog.save() assert credentials.api_key == "stored-token" assert credentials.saved_values == [] assert repository.saved == [settings] def test_save_persists_new_api_key_and_emits_saved_settings(qtbot, settings: AppSettings) -> None: credentials = FakeCredentials() repository = FakeRepository() dialog = SettingsDialog(settings, repository, credentials) qtbot.addWidget(dialog) saved_with = qtbot.waitSignal(dialog.settings_saved) supplied_key = "test-token-for-dialog" dialog.api_key_input.setText(supplied_key) dialog.model_input.setText("gpt-4o-mini-transcribe") dialog.context_input.setPlainText("Pix, Banco do Brasil") dialog.save() assert saved_with.args == [ AppSettings("gpt-4o-mini-transcribe", "pt-BR", settings.output_directory, context_vocabulary="Pix, Banco do Brasil") ] assert credentials.api_key == supplied_key assert repository.saved == saved_with.args def test_save_rejects_nonexistent_output_directory(qtbot, settings: AppSettings, tmp_path: Path) -> None: repository = FakeRepository() dialog = SettingsDialog(settings, repository, FakeCredentials()) qtbot.addWidget(dialog) warnings: list[tuple[str, str]] = [] monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr( QMessageBox, "warning", lambda parent, title, text: warnings.append((title, text)), ) try: dialog.output_directory_input.setText(str(tmp_path / "missing")) dialog.save() finally: monkeypatch.undo() assert repository.saved == [] assert warnings == [("Invalid output directory", "Choose an accessible output directory.")] assert dialog.isVisible() is False def test_save_clears_key_field_and_never_writes_key_to_widgets(qtbot, settings: AppSettings) -> None: dialog = SettingsDialog(settings, FakeRepository(), FakeCredentials()) qtbot.addWidget(dialog) supplied_key = "test-token-for-dialog" dialog.api_key_input.setText(supplied_key) dialog.save() assert dialog.api_key_input.text() == "" assert all(supplied_key not in widget.text() for widget in dialog.findChildren(QLineEdit)) def test_service_failures_show_sanitized_message_and_keep_dialog_usable( qtbot, settings: AppSettings, monkeypatch ) -> None: class FailingCredentials(FakeCredentials): def set_api_key(self, value: str) -> None: raise CredentialError("backend detail must not be shown") dialog = SettingsDialog(settings, FakeRepository(), FailingCredentials()) qtbot.addWidget(dialog) warnings: list[tuple[str, str]] = [] monkeypatch.setattr( QMessageBox, "warning", lambda parent, title, text: warnings.append((title, text)), ) dialog.api_key_input.setText("test-token-for-dialog") dialog.save() assert warnings == [("Could not save settings", "The API key could not be saved.")] assert dialog.result() == 0 assert dialog.api_key_input.text() == "" def test_settings_repository_failure_is_sanitized(qtbot, settings: AppSettings, monkeypatch) -> None: class FailingRepository(FakeRepository): def save(self, settings: AppSettings) -> None: raise SettingsError("filesystem detail must not be shown") dialog = SettingsDialog(settings, FailingRepository(), FakeCredentials()) qtbot.addWidget(dialog) warnings: list[tuple[str, str]] = [] monkeypatch.setattr( QMessageBox, "warning", lambda parent, title, text: warnings.append((title, text)), ) dialog.save() assert warnings == [("Could not save settings", "Settings could not be saved.")] assert dialog.result() == 0