Add unit tests for configuration management and settings dialog

- Implement tests for ConfigValidator to ensure proper validation of configuration settings, including valid configurations, required fields, type checks, and error handling.
- Create tests for ConfigProfile to verify profile management functionalities such as saving, loading, listing, and deleting profiles.
- Add tests for ConfigExporter to validate JSON export and import processes, including error handling for non-existent files and invalid JSON.
- Introduce tests for SettingsDialog to confirm proper initialization, tab existence, and configuration data retrieval and application.
This commit is contained in:
claudi 2026-01-29 12:52:53 +01:00
parent 5dc988005c
commit 8b0df0e04f
7 changed files with 1556 additions and 4 deletions

View file

@ -0,0 +1,263 @@
"""Configuration management with validation, profiles, and import/export."""
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from webdrop_bridge.config import Config, ConfigurationError
logger = logging.getLogger(__name__)
class ConfigValidator:
"""Validates configuration values against schema.
Provides detailed error messages for invalid configurations.
"""
# Schema definition for configuration
SCHEMA = {
"app_name": {"type": str, "min_length": 1, "max_length": 100},
"app_version": {"type": str, "pattern": r"^\d+\.\d+\.\d+$"},
"log_level": {"type": str, "allowed": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]},
"log_file": {"type": (str, type(None)), "optional": True},
"allowed_roots": {"type": list, "item_type": (str, Path), "min_items": 0},
"allowed_urls": {"type": list, "item_type": str, "min_items": 0},
"webapp_url": {"type": str, "min_length": 1},
"window_width": {"type": int, "min_value": 400, "max_value": 5000},
"window_height": {"type": int, "min_value": 300, "max_value": 5000},
"enable_logging": {"type": bool},
}
@staticmethod
def validate(config_dict: Dict[str, Any]) -> List[str]:
"""Validate configuration dictionary.
Args:
config_dict: Configuration dictionary to validate
Returns:
List of validation error messages (empty if valid)
"""
errors = []
for field, rules in ConfigValidator.SCHEMA.items():
if field not in config_dict:
if not rules.get("optional", False):
errors.append(f"Missing required field: {field}")
continue
value = config_dict[field]
# Check type
expected_type = rules.get("type")
if expected_type and not isinstance(value, expected_type):
errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}")
continue
# Check allowed values
if "allowed" in rules and value not in rules["allowed"]:
errors.append(f"{field}: must be one of {rules['allowed']}, got {value}")
# Check string length
if isinstance(value, str):
if "min_length" in rules and len(value) < rules["min_length"]:
errors.append(f"{field}: minimum length is {rules['min_length']}")
if "max_length" in rules and len(value) > rules["max_length"]:
errors.append(f"{field}: maximum length is {rules['max_length']}")
# Check numeric range
if isinstance(value, int):
if "min_value" in rules and value < rules["min_value"]:
errors.append(f"{field}: minimum value is {rules['min_value']}")
if "max_value" in rules and value > rules["max_value"]:
errors.append(f"{field}: maximum value is {rules['max_value']}")
# Check list items
if isinstance(value, list):
if "min_items" in rules and len(value) < rules["min_items"]:
errors.append(f"{field}: minimum {rules['min_items']} items required")
return errors
@staticmethod
def validate_or_raise(config_dict: Dict[str, Any]) -> None:
"""Validate configuration and raise error if invalid.
Args:
config_dict: Configuration dictionary to validate
Raises:
ConfigurationError: If configuration is invalid
"""
errors = ConfigValidator.validate(config_dict)
if errors:
raise ConfigurationError(f"Configuration validation failed:\n" + "\n".join(errors))
class ConfigProfile:
"""Manages named configuration profiles.
Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files.
"""
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
def __init__(self):
"""Initialize profile manager."""
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
def save_profile(self, profile_name: str, config: Config) -> Path:
"""Save configuration as a named profile.
Args:
profile_name: Name of the profile (e.g., "work", "personal")
config: Config object to save
Returns:
Path to the saved profile file
Raises:
ConfigurationError: If profile name is invalid
"""
if not profile_name or "/" in profile_name or "\\" in profile_name:
raise ConfigurationError(f"Invalid profile name: {profile_name}")
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
config_data = {
"app_name": config.app_name,
"app_version": config.app_version,
"log_level": config.log_level,
"log_file": str(config.log_file) if config.log_file else None,
"allowed_roots": [str(p) for p in config.allowed_roots],
"allowed_urls": config.allowed_urls,
"webapp_url": config.webapp_url,
"window_width": config.window_width,
"window_height": config.window_height,
"enable_logging": config.enable_logging,
}
try:
profile_path.write_text(json.dumps(config_data, indent=2))
logger.info(f"Profile saved: {profile_name}")
return profile_path
except (OSError, IOError) as e:
raise ConfigurationError(f"Failed to save profile {profile_name}: {e}")
def load_profile(self, profile_name: str) -> Dict[str, Any]:
"""Load configuration from a named profile.
Args:
profile_name: Name of the profile to load
Returns:
Configuration dictionary
Raises:
ConfigurationError: If profile not found or invalid
"""
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
if not profile_path.exists():
raise ConfigurationError(f"Profile not found: {profile_name}")
try:
config_data = json.loads(profile_path.read_text())
# Validate before returning
ConfigValidator.validate_or_raise(config_data)
return config_data
except json.JSONDecodeError as e:
raise ConfigurationError(f"Invalid JSON in profile {profile_name}: {e}")
def list_profiles(self) -> List[str]:
"""List all available profiles.
Returns:
List of profile names (without .json extension)
"""
if not self.PROFILES_DIR.exists():
return []
return sorted([p.stem for p in self.PROFILES_DIR.glob("*.json")])
def delete_profile(self, profile_name: str) -> None:
"""Delete a profile.
Args:
profile_name: Name of the profile to delete
Raises:
ConfigurationError: If profile not found
"""
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
if not profile_path.exists():
raise ConfigurationError(f"Profile not found: {profile_name}")
try:
profile_path.unlink()
logger.info(f"Profile deleted: {profile_name}")
except OSError as e:
raise ConfigurationError(f"Failed to delete profile {profile_name}: {e}")
class ConfigExporter:
"""Handle configuration import and export operations."""
@staticmethod
def export_to_json(config: Config, output_path: Path) -> None:
"""Export configuration to JSON file.
Args:
config: Config object to export
output_path: Path to write JSON file
Raises:
ConfigurationError: If export fails
"""
config_data = {
"app_name": config.app_name,
"app_version": config.app_version,
"log_level": config.log_level,
"log_file": str(config.log_file) if config.log_file else None,
"allowed_roots": [str(p) for p in config.allowed_roots],
"allowed_urls": config.allowed_urls,
"webapp_url": config.webapp_url,
"window_width": config.window_width,
"window_height": config.window_height,
"enable_logging": config.enable_logging,
}
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(config_data, indent=2))
logger.info(f"Configuration exported to: {output_path}")
except (OSError, IOError) as e:
raise ConfigurationError(f"Failed to export configuration: {e}")
@staticmethod
def import_from_json(input_path: Path) -> Dict[str, Any]:
"""Import configuration from JSON file.
Args:
input_path: Path to JSON file to import
Returns:
Configuration dictionary
Raises:
ConfigurationError: If import fails or validation fails
"""
if not input_path.exists():
raise ConfigurationError(f"File not found: {input_path}")
try:
config_data = json.loads(input_path.read_text())
# Validate before returning
ConfigValidator.validate_or_raise(config_data)
logger.info(f"Configuration imported from: {input_path}")
return config_data
except json.JSONDecodeError as e:
raise ConfigurationError(f"Invalid JSON file: {e}")

View file

@ -0,0 +1,434 @@
"""Settings dialog for configuration management."""
from pathlib import Path
from typing import List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QSpinBox,
QTabWidget,
QVBoxLayout,
QWidget,
)
from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
class SettingsDialog(QDialog):
"""Dialog for managing application settings and configuration.
Provides tabs for:
- Paths: Manage allowed root directories
- URLs: Manage allowed web URLs
- Logging: Configure logging settings
- Window: Manage window size and behavior
- Profiles: Save/load/delete configuration profiles
"""
def __init__(self, config: Config, parent=None):
"""Initialize the settings dialog.
Args:
config: Current application configuration
parent: Parent widget
"""
super().__init__(parent)
self.config = config
self.profile_manager = ConfigProfile()
self.setWindowTitle("Settings")
self.setGeometry(100, 100, 600, 500)
self.setup_ui()
def setup_ui(self) -> None:
"""Set up the dialog UI with tabs."""
layout = QVBoxLayout()
# Create tab widget
self.tabs = QTabWidget()
# Add tabs
self.tabs.addTab(self._create_paths_tab(), "Paths")
self.tabs.addTab(self._create_urls_tab(), "URLs")
self.tabs.addTab(self._create_logging_tab(), "Logging")
self.tabs.addTab(self._create_window_tab(), "Window")
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
layout.addWidget(self.tabs)
# Add buttons
button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.setLayout(layout)
def _create_paths_tab(self) -> QWidget:
"""Create paths configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed root directories for file access:"))
# List widget for paths
self.paths_list = QListWidget()
for path in self.config.allowed_roots:
self.paths_list.addItem(str(path))
layout.addWidget(self.paths_list)
# Buttons for path management
button_layout = QHBoxLayout()
add_path_btn = QPushButton("Add Path")
add_path_btn.clicked.connect(self._add_path)
button_layout.addWidget(add_path_btn)
remove_path_btn = QPushButton("Remove Selected")
remove_path_btn.clicked.connect(self._remove_path)
button_layout.addWidget(remove_path_btn)
layout.addLayout(button_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_urls_tab(self) -> QWidget:
"""Create URLs configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
# List widget for URLs
self.urls_list = QListWidget()
for url in self.config.allowed_urls:
self.urls_list.addItem(url)
layout.addWidget(self.urls_list)
# Buttons for URL management
button_layout = QHBoxLayout()
add_url_btn = QPushButton("Add URL")
add_url_btn.clicked.connect(self._add_url)
button_layout.addWidget(add_url_btn)
remove_url_btn = QPushButton("Remove Selected")
remove_url_btn.clicked.connect(self._remove_url)
button_layout.addWidget(remove_url_btn)
layout.addLayout(button_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_logging_tab(self) -> QWidget:
"""Create logging configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
# Log level selection
layout.addWidget(QLabel("Log Level:"))
self.log_level_combo = self._create_log_level_widget()
layout.addWidget(self.log_level_combo)
# Log file path
layout.addWidget(QLabel("Log File (optional):"))
log_file_layout = QHBoxLayout()
self.log_file_input = QLineEdit()
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
log_file_layout.addWidget(self.log_file_input)
browse_btn = QPushButton("Browse...")
browse_btn.clicked.connect(self._browse_log_file)
log_file_layout.addWidget(browse_btn)
layout.addLayout(log_file_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_window_tab(self) -> QWidget:
"""Create window settings tab."""
widget = QWidget()
layout = QVBoxLayout()
# Window width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Window Width:"))
self.width_spin = QSpinBox()
self.width_spin.setMinimum(400)
self.width_spin.setMaximum(5000)
self.width_spin.setValue(self.config.window_width)
width_layout.addWidget(self.width_spin)
width_layout.addStretch()
layout.addLayout(width_layout)
# Window height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Window Height:"))
self.height_spin = QSpinBox()
self.height_spin.setMinimum(300)
self.height_spin.setMaximum(5000)
self.height_spin.setValue(self.config.window_height)
height_layout.addWidget(self.height_spin)
height_layout.addStretch()
layout.addLayout(height_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_profiles_tab(self) -> QWidget:
"""Create profiles management tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Saved Configuration Profiles:"))
# List of profiles
self.profiles_list = QListWidget()
self._refresh_profiles_list()
layout.addWidget(self.profiles_list)
# Profile management buttons
button_layout = QHBoxLayout()
save_profile_btn = QPushButton("Save as Profile")
save_profile_btn.clicked.connect(self._save_profile)
button_layout.addWidget(save_profile_btn)
load_profile_btn = QPushButton("Load Profile")
load_profile_btn.clicked.connect(self._load_profile)
button_layout.addWidget(load_profile_btn)
delete_profile_btn = QPushButton("Delete Profile")
delete_profile_btn.clicked.connect(self._delete_profile)
button_layout.addWidget(delete_profile_btn)
layout.addLayout(button_layout)
# Export/Import buttons
export_layout = QHBoxLayout()
export_btn = QPushButton("Export Configuration")
export_btn.clicked.connect(self._export_config)
export_layout.addWidget(export_btn)
import_btn = QPushButton("Import Configuration")
import_btn.clicked.connect(self._import_config)
export_layout.addWidget(import_btn)
layout.addLayout(export_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_log_level_widget(self) -> QWidget:
"""Create log level selection widget."""
from PySide6.QtWidgets import QComboBox
combo = QComboBox()
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
combo.addItems(levels)
combo.setCurrentText(self.config.log_level)
return combo
def _add_path(self) -> None:
"""Add a new allowed path."""
path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow")
if path:
self.paths_list.addItem(path)
def _remove_path(self) -> None:
"""Remove selected path."""
if self.paths_list.currentItem():
self.paths_list.takeItem(self.paths_list.row(self.paths_list.currentItem()))
def _add_url(self) -> None:
"""Add a new allowed URL."""
from PySide6.QtWidgets import QInputDialog
url, ok = QInputDialog.getText(
self,
"Add URL",
"Enter URL pattern (e.g., http://example.com or http://*.example.com):"
)
if ok and url:
self.urls_list.addItem(url)
def _remove_url(self) -> None:
"""Remove selected URL."""
if self.urls_list.currentItem():
self.urls_list.takeItem(self.urls_list.row(self.urls_list.currentItem()))
def _browse_log_file(self) -> None:
"""Browse for log file location."""
file_path, _ = QFileDialog.getSaveFileName(
self,
"Select Log File",
str(Path.home()),
"Log Files (*.log);;All Files (*)"
)
if file_path:
self.log_file_input.setText(file_path)
def _refresh_profiles_list(self) -> None:
"""Refresh the list of available profiles."""
self.profiles_list.clear()
for profile_name in self.profile_manager.list_profiles():
self.profiles_list.addItem(profile_name)
def _save_profile(self) -> None:
"""Save current configuration as a profile."""
from PySide6.QtWidgets import QInputDialog
profile_name, ok = QInputDialog.getText(
self,
"Save Profile",
"Enter profile name (e.g., work, personal):"
)
if ok and profile_name:
try:
self.profile_manager.save_profile(profile_name, self.config)
self._refresh_profiles_list()
except ConfigurationError as e:
self._show_error(f"Failed to save profile: {e}")
def _load_profile(self) -> None:
"""Load a saved profile."""
current_item = self.profiles_list.currentItem()
if not current_item:
self._show_error("Please select a profile to load")
return
profile_name = current_item.text()
try:
config_data = self.profile_manager.load_profile(profile_name)
self._apply_config_data(config_data)
except ConfigurationError as e:
self._show_error(f"Failed to load profile: {e}")
def _delete_profile(self) -> None:
"""Delete a saved profile."""
current_item = self.profiles_list.currentItem()
if not current_item:
self._show_error("Please select a profile to delete")
return
profile_name = current_item.text()
try:
self.profile_manager.delete_profile(profile_name)
self._refresh_profiles_list()
except ConfigurationError as e:
self._show_error(f"Failed to delete profile: {e}")
def _export_config(self) -> None:
"""Export configuration to file."""
file_path, _ = QFileDialog.getSaveFileName(
self,
"Export Configuration",
str(Path.home()),
"JSON Files (*.json);;All Files (*)"
)
if file_path:
try:
ConfigExporter.export_to_json(self.config, Path(file_path))
except ConfigurationError as e:
self._show_error(f"Failed to export configuration: {e}")
def _import_config(self) -> None:
"""Import configuration from file."""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Import Configuration",
str(Path.home()),
"JSON Files (*.json);;All Files (*)"
)
if file_path:
try:
config_data = ConfigExporter.import_from_json(Path(file_path))
self._apply_config_data(config_data)
except ConfigurationError as e:
self._show_error(f"Failed to import configuration: {e}")
def _apply_config_data(self, config_data: dict) -> None:
"""Apply imported configuration data to UI.
Args:
config_data: Configuration dictionary
"""
# Apply paths
self.paths_list.clear()
for path in config_data.get("allowed_roots", []):
self.paths_list.addItem(str(path))
# Apply URLs
self.urls_list.clear()
for url in config_data.get("allowed_urls", []):
self.urls_list.addItem(url)
# Apply logging settings
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
log_file = config_data.get("log_file")
self.log_file_input.setText(str(log_file) if log_file else "")
# Apply window settings
self.width_spin.setValue(config_data.get("window_width", 800))
self.height_spin.setValue(config_data.get("window_height", 600))
def get_config_data(self) -> dict:
"""Get updated configuration data from dialog.
Returns:
Configuration dictionary
Raises:
ConfigurationError: If configuration is invalid
"""
config_data = {
"app_name": self.config.app_name,
"app_version": self.config.app_version,
"log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None,
"allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())],
"allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())],
"webapp_url": self.config.webapp_url,
"window_width": self.width_spin.value(),
"window_height": self.height_spin.value(),
"enable_logging": self.config.enable_logging,
}
# Validate
ConfigValidator.validate_or_raise(config_data)
return config_data
def _show_error(self, message: str) -> None:
"""Show error message to user.
Args:
message: Error message
"""
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error", message)