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