3 changed files with 314 additions and 0 deletions
@ -0,0 +1 @@ |
|||||||
|
"""Native user-interface components for Voice Transcriptor.""" |
||||||
@ -0,0 +1,144 @@ |
|||||||
|
"""Modal editor for non-secret settings and the stored API key.""" |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import os |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
from PySide6.QtCore import Signal |
||||||
|
from PySide6.QtWidgets import ( |
||||||
|
QDialog, |
||||||
|
QDialogButtonBox, |
||||||
|
QFileDialog, |
||||||
|
QFormLayout, |
||||||
|
QHBoxLayout, |
||||||
|
QLineEdit, |
||||||
|
QMessageBox, |
||||||
|
QPushButton, |
||||||
|
QVBoxLayout, |
||||||
|
) |
||||||
|
|
||||||
|
from voice_transcriptor.models import AppSettings |
||||||
|
from voice_transcriptor.services.credentials import CredentialError, CredentialService |
||||||
|
from voice_transcriptor.services.settings import SettingsError, SettingsRepository |
||||||
|
|
||||||
|
|
||||||
|
class SettingsDialog(QDialog): |
||||||
|
"""Edit application settings while keeping the API key out of normal storage.""" |
||||||
|
|
||||||
|
settings_saved = Signal(AppSettings) |
||||||
|
|
||||||
|
def __init__( |
||||||
|
self, |
||||||
|
settings: AppSettings, |
||||||
|
repository: SettingsRepository, |
||||||
|
credentials: CredentialService, |
||||||
|
parent=None, |
||||||
|
) -> None: |
||||||
|
super().__init__(parent) |
||||||
|
self._repository = repository |
||||||
|
self._credentials = credentials |
||||||
|
self.setWindowTitle("Settings") |
||||||
|
|
||||||
|
self.model_input = QLineEdit(settings.model, self) |
||||||
|
self.model_input.setObjectName("modelInput") |
||||||
|
self.language_input = QLineEdit(settings.language, self) |
||||||
|
self.language_input.setObjectName("languageInput") |
||||||
|
self.api_key_input = QLineEdit(self) |
||||||
|
self.api_key_input.setObjectName("apiKeyInput") |
||||||
|
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password) |
||||||
|
self.output_directory_input = QLineEdit(str(settings.output_directory), self) |
||||||
|
self.output_directory_input.setObjectName("outputDirectoryInput") |
||||||
|
self.choose_directory_button = QPushButton("Browse…", self) |
||||||
|
self.choose_directory_button.setObjectName("chooseDirectoryButton") |
||||||
|
self.choose_directory_button.clicked.connect(self.choose_output_directory) |
||||||
|
|
||||||
|
output_directory_layout = QHBoxLayout() |
||||||
|
output_directory_layout.addWidget(self.output_directory_input) |
||||||
|
output_directory_layout.addWidget(self.choose_directory_button) |
||||||
|
|
||||||
|
form = QFormLayout() |
||||||
|
form.addRow("OpenAI API key", self.api_key_input) |
||||||
|
form.addRow("Model", self.model_input) |
||||||
|
form.addRow("Language", self.language_input) |
||||||
|
form.addRow("Output directory", output_directory_layout) |
||||||
|
|
||||||
|
self.button_box = QDialogButtonBox( |
||||||
|
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel, |
||||||
|
parent=self, |
||||||
|
) |
||||||
|
self.save_button = self.button_box.button(QDialogButtonBox.StandardButton.Save) |
||||||
|
self.save_button.setObjectName("saveButton") |
||||||
|
self.cancel_button = self.button_box.button(QDialogButtonBox.StandardButton.Cancel) |
||||||
|
self.cancel_button.setObjectName("cancelButton") |
||||||
|
self.button_box.accepted.connect(self.save) |
||||||
|
self.button_box.rejected.connect(self.reject) |
||||||
|
|
||||||
|
layout = QVBoxLayout(self) |
||||||
|
layout.addLayout(form) |
||||||
|
layout.addWidget(self.button_box) |
||||||
|
|
||||||
|
def choose_output_directory(self) -> None: |
||||||
|
directory = QFileDialog.getExistingDirectory( |
||||||
|
self, |
||||||
|
"Choose output directory", |
||||||
|
self.output_directory_input.text(), |
||||||
|
) |
||||||
|
if directory: |
||||||
|
self.output_directory_input.setText(directory) |
||||||
|
|
||||||
|
def save(self) -> None: |
||||||
|
"""Persist valid settings and, only when supplied, replace the API key.""" |
||||||
|
api_key = self.api_key_input.text().strip() |
||||||
|
self.api_key_input.clear() |
||||||
|
|
||||||
|
output_directory = self._output_directory() |
||||||
|
if output_directory is None: |
||||||
|
QMessageBox.warning( |
||||||
|
self, |
||||||
|
"Invalid output directory", |
||||||
|
"Choose an accessible output directory.", |
||||||
|
) |
||||||
|
return |
||||||
|
|
||||||
|
model = self.model_input.text().strip() |
||||||
|
language = self.language_input.text().strip() |
||||||
|
if not model or not language: |
||||||
|
QMessageBox.warning(self, "Invalid settings", "Model and language are required.") |
||||||
|
return |
||||||
|
|
||||||
|
updated_settings = AppSettings(model, language, output_directory) |
||||||
|
try: |
||||||
|
self._repository.save(updated_settings) |
||||||
|
except SettingsError: |
||||||
|
QMessageBox.warning(self, "Could not save settings", "Settings could not be saved.") |
||||||
|
return |
||||||
|
|
||||||
|
if api_key: |
||||||
|
try: |
||||||
|
self._credentials.set_api_key(api_key) |
||||||
|
except CredentialError: |
||||||
|
QMessageBox.warning( |
||||||
|
self, |
||||||
|
"Could not save settings", |
||||||
|
"The API key could not be saved.", |
||||||
|
) |
||||||
|
return |
||||||
|
|
||||||
|
self.settings_saved.emit(updated_settings) |
||||||
|
self.accept() |
||||||
|
|
||||||
|
def _output_directory(self) -> Path | None: |
||||||
|
value = self.output_directory_input.text().strip() |
||||||
|
if not value: |
||||||
|
return None |
||||||
|
|
||||||
|
directory = Path(value) |
||||||
|
try: |
||||||
|
if not directory.is_dir(): |
||||||
|
return None |
||||||
|
if not os.access(directory, os.R_OK | os.W_OK): |
||||||
|
return None |
||||||
|
except OSError: |
||||||
|
return None |
||||||
|
return directory |
||||||
@ -0,0 +1,169 @@ |
|||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
import pytest |
||||||
|
from PySide6.QtWidgets import 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.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() == "" |
||||||
|
|
||||||
|
|
||||||
|
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.save() |
||||||
|
|
||||||
|
assert saved_with.args == [ |
||||||
|
AppSettings("gpt-4o-mini-transcribe", "pt-BR", settings.output_directory) |
||||||
|
] |
||||||
|
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 |
||||||
Loading…
Reference in new issue