feat: enhance drag-and-drop functionality to support multiple file paths and URLs

This commit is contained in:
claudi 2026-03-04 13:43:21 +01:00
parent 1e848e84b2
commit c612072dc8
5 changed files with 384 additions and 480 deletions

View file

@ -2,7 +2,7 @@
import logging
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, Union
from PySide6.QtCore import QMimeData, Qt, QUrl, Signal
from PySide6.QtGui import QDrag
@ -21,14 +21,18 @@ class DragInterceptor(QWidget):
Intercepts drag events from web content, converts Azure Blob Storage URLs
to local paths, validates them, and initiates native Qt drag operations.
Supports both single and multiple file drag operations.
Signals:
drag_started: Emitted when a drag operation begins successfully
(source_urls_or_paths: str, local_paths: str - comma-separated for multiple)
drag_failed: Emitted when drag initiation fails
(source_urls_or_paths: str, error_message: str)
"""
# Signals with string parameters
drag_started = Signal(str, str) # (url_or_path, local_path)
drag_failed = Signal(str, str) # (url_or_path, error_message)
drag_started = Signal(str, str) # (source_urls_or_paths, local_paths)
drag_failed = Signal(str, str) # (source_urls_or_paths, error_message)
def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the drag interceptor.
@ -40,83 +44,123 @@ class DragInterceptor(QWidget):
super().__init__(parent)
self.config = config
self._validator = PathValidator(
config.allowed_roots,
check_file_exists=config.check_file_exists
config.allowed_roots, check_file_exists=config.check_file_exists
)
self._url_converter = URLConverter(config)
def handle_drag(self, text: str) -> bool:
"""Handle drag event from web view.
def handle_drag(self, text_or_list: Union[str, List[str]]) -> bool:
"""Handle drag event from web view (single or multiple files).
Determines if the text is an Azure URL or file path, converts if needed,
Determines if the text/list contains Azure URLs or file paths, converts if needed,
validates, and initiates native drag operation.
Supports:
- Single string (backward compatible)
- List of strings (multiple drag support)
Args:
text: Azure Blob Storage URL or file path from web drag
text_or_list: Azure URL/file path (str) or list of URLs/paths (List[str])
Returns:
True if native drag was initiated, False otherwise
"""
if not text or not text.strip():
# Normalize input to list
if isinstance(text_or_list, str):
text_list = [text_or_list]
elif isinstance(text_or_list, (list, tuple)):
text_list = list(text_or_list)
else:
error_msg = f"Unexpected drag data type: {type(text_or_list)}"
logger.error(error_msg)
self.drag_failed.emit("", error_msg)
return False
# Validate that we have content
if not text_list or all(not t or not str(t).strip() for t in text_list):
error_msg = "Empty drag text"
logger.warning(error_msg)
self.drag_failed.emit("", error_msg)
return False
text = text.strip()
logger.debug(f"Handling drag for text: {text}")
# Clean up text items
text_list = [str(t).strip() for t in text_list if str(t).strip()]
logger.debug(f"Handling drag for {len(text_list)} item(s)")
# Check if it's an Azure URL and convert to local path
if self._url_converter.is_azure_url(text):
local_path = self._url_converter.convert_url_to_path(text)
if local_path is None:
error_msg = "No mapping found for URL"
logger.warning(f"{error_msg}: {text}")
# Convert each text to local path
local_paths = []
source_texts = []
for text in text_list:
# Check if it's an Azure URL and convert to local path
if self._url_converter.is_azure_url(text):
local_path = self._url_converter.convert_url_to_path(text)
if local_path is None:
error_msg = f"No mapping found for URL: {text}"
logger.warning(error_msg)
self.drag_failed.emit(text, error_msg)
return False
source_texts.append(text)
else:
# Treat as direct file path
local_path = Path(text)
source_texts.append(text)
# Validate the path
try:
self._validator.validate(local_path)
except ValidationError as e:
error_msg = str(e)
logger.warning(f"Validation failed for {local_path}: {error_msg}")
self.drag_failed.emit(text, error_msg)
return False
source_text = text
else:
# Treat as direct file path
local_path = Path(text)
source_text = text
# Validate the path
try:
self._validator.validate(local_path)
except ValidationError as e:
error_msg = str(e)
logger.warning(f"Validation failed for {local_path}: {error_msg}")
self.drag_failed.emit(source_text, error_msg)
return False
local_paths.append(local_path)
logger.info(f"Initiating drag for: {local_path}")
logger.info(
f"Initiating drag for {len(local_paths)} file(s): {[str(p) for p in local_paths]}"
)
# Create native file drag
success = self._create_native_drag(local_path)
# Create native file drag with all paths
success = self._create_native_drag(local_paths)
if success:
self.drag_started.emit(source_text, str(local_path))
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
paths_str = (
" | ".join(str(p) for p in local_paths)
if len(local_paths) > 1
else str(local_paths[0])
)
self.drag_started.emit(source_str, paths_str)
else:
error_msg = "Failed to create native drag operation"
logger.error(error_msg)
self.drag_failed.emit(source_text, error_msg)
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
self.drag_failed.emit(source_str, error_msg)
return success
def _create_native_drag(self, file_path: Path) -> bool:
def _create_native_drag(self, file_paths: Union[Path, List[Path]]) -> bool:
"""Create a native file system drag operation.
Args:
file_path: Local file path to drag
file_paths: Single local file path or list of local file paths
Returns:
True if drag was created successfully
"""
try:
# Create MIME data with file URL
# Normalize to list
if isinstance(file_paths, Path):
paths_list = [file_paths]
else:
paths_list = list(file_paths)
# Create MIME data with file URLs
mime_data = QMimeData()
file_url = QUrl.fromLocalFile(str(file_path))
mime_data.setUrls([file_url])
file_urls = [QUrl.fromLocalFile(str(p)) for p in paths_list]
mime_data.setUrls(file_urls)
logger.debug(f"Creating drag with {len(file_urls)} file(s)")
# Create and execute drag
drag = QDrag(self)

View file

@ -11,7 +11,7 @@
console.log('%c[WebDrop Intercept] Script loaded - INTERCEPT_ENABLED=' + INTERCEPT_ENABLED, 'background: #2196F3; color: white; font-weight: bold; padding: 4px 8px;');
var currentDragUrl = null;
var currentDragUrls = []; // Array to support multiple URLs
var angularDragHandlers = [];
var originalAddEventListener = EventTarget.prototype.addEventListener;
var listenerPatchActive = true;
@ -60,8 +60,14 @@
DataTransfer.prototype.setData = function(format, data) {
if (format === 'text/plain' || format === 'text/uri-list') {
currentDragUrl = data;
console.log('%c[Intercept] Captured URL:', 'color: #4CAF50; font-weight: bold;', data.substring(0, 80));
// text/uri-list contains newline-separated URLs
// text/plain may be single URL or multiple newline-separated URLs
currentDragUrls = data.trim().split('\n').filter(function(url) {
return url.trim().length > 0;
}).map(function(url) {
return url.trim();
});
console.log('%c[Intercept] Captured ' + currentDragUrls.length + ' URL(s)', 'color: #4CAF50; font-weight: bold;', currentDragUrls[0].substring(0, 60));
}
return originalSetData.call(this, format, data);
};
@ -94,7 +100,7 @@
// Register OUR handler in capture phase
originalAddEventListener.call(document, 'dragstart', function(e) {
currentDragUrl = null; // Reset
currentDragUrls = []; // Reset
// Call Angular's handlers first to let them set the data
var handled = 0;
@ -111,33 +117,41 @@
}
}
console.log('[Intercept] Called', handled, 'Angular handlers, URL:', currentDragUrl ? currentDragUrl.substring(0, 60) : 'none');
console.log('[Intercept] Called', handled, 'Angular handlers, URLs:', currentDragUrls.length, 'URL(s)', currentDragUrls.length > 0 ? currentDragUrls[0].substring(0, 60) : 'none');
// NOW check if we should intercept
// Intercept any drag with a URL that matches our configured mappings
if (currentDragUrl) {
// Intercept any drag with URLs that match our configured mappings
if (currentDragUrls.length > 0) {
var shouldIntercept = false;
// Check against configured URL mappings
// Check each URL against configured URL mappings
// Intercept if ANY URL matches
if (window.webdropConfig && window.webdropConfig.urlMappings) {
for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) {
var mapping = window.webdropConfig.urlMappings[j];
if (currentDragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
shouldIntercept = true;
console.log('[Intercept] URL matches mapping for:', mapping.local_path);
break;
for (var k = 0; k < currentDragUrls.length; k++) {
var dragUrl = currentDragUrls[k];
for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) {
var mapping = window.webdropConfig.urlMappings[j];
if (dragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
shouldIntercept = true;
console.log('[Intercept] URL #' + (k+1) + ' matches mapping for:', mapping.local_path);
break;
}
}
if (shouldIntercept) break;
}
} else {
// Fallback: Check for legacy Z: drive pattern if no config available
shouldIntercept = /^z:/i.test(currentDragUrl);
if (shouldIntercept) {
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
for (var k = 0; k < currentDragUrls.length; k++) {
if (/^z:/i.test(currentDragUrls[k])) {
shouldIntercept = true;
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
break;
}
}
}
if (shouldIntercept) {
console.log('%c[Intercept] PREVENTING browser drag, using Qt',
console.log('%c[Intercept] PREVENTING browser drag, using Qt for ' + currentDragUrls.length + ' file(s)',
'background: #F44336; color: white; font-weight: bold; padding: 4px 8px;');
e.preventDefault();
@ -145,14 +159,15 @@
ensureChannel(function() {
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
console.log('%c[Intercept] → Qt: start_file_drag', 'color: #9C27B0; font-weight: bold;');
window.bridge.start_file_drag(currentDragUrl);
console.log('%c[Intercept] → Qt: start_file_drag with ' + currentDragUrls.length + ' file(s)', 'color: #9C27B0; font-weight: bold;');
// Pass as JSON string to avoid Qt WebChannel array conversion issues
window.bridge.start_file_drag(JSON.stringify(currentDragUrls));
} else {
console.error('[Intercept] bridge.start_file_drag not available!');
}
});
currentDragUrl = null;
currentDragUrls = [];
return false;
}
}

View file

@ -7,7 +7,7 @@ import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Optional, Union
from PySide6.QtCore import (
QEvent,
@ -312,21 +312,34 @@ class _DragBridge(QObject):
self.window = window
@Slot(str)
def start_file_drag(self, path_text: str) -> None:
"""Start a native file drag for the given path or Azure URL.
def start_file_drag(self, paths_text: str) -> None:
"""Start a native file drag for the given path(s) or Azure URL(s).
Called from JavaScript when user drags item(s).
Accepts either:
- Single file path string or Azure URL
- JSON array string of file paths or Azure URLs (multiple drag support)
Called from JavaScript when user drags an item.
Accepts either local file paths or Azure Blob Storage URLs.
Defers execution to avoid Qt drag manager state issues.
Args:
path_text: File path string or Azure URL to drag
paths_text: String (single path/URL) or JSON array string (multiple paths/URLs)
"""
logger.debug(f"Bridge: start_file_drag called for {path_text}")
logger.debug(f"Bridge: start_file_drag called with {len(paths_text)} chars")
# Defer to avoid drag manager state issues
# handle_drag() handles URL conversion and validation internally
QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(path_text))
# Try to parse as JSON array first (for multiple-drag support)
paths_list: Union[str, list] = paths_text
if paths_text.startswith("["):
try:
parsed = json.loads(paths_text)
if isinstance(parsed, list):
paths_list = parsed
logger.debug(f"Parsed JSON array with {len(parsed)} item(s)")
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"Failed to parse JSON array: {e}, treating as single string")
# Handle both single string and list
QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(paths_list))
@Slot(str)
def debug_log(self, message: str) -> None:

View file

@ -82,6 +82,7 @@ class TestDragInterceptorValidation:
mock_drag_instance = MagicMock()
# Simulate successful copy action
from PySide6.QtCore import Qt
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
@ -136,7 +137,7 @@ class TestDragInterceptorAzureURL:
url_mappings=[
URLMapping(
url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
local_path=str(tmp_path)
local_path=str(tmp_path),
)
],
check_file_exists=True,
@ -150,6 +151,7 @@ class TestDragInterceptorAzureURL:
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
from PySide6.QtCore import Qt
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
@ -196,6 +198,7 @@ class TestDragInterceptorSignals:
interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path)))
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
@ -235,3 +238,234 @@ class TestDragInterceptorSignals:
# Verify result and signal emission
assert result is False
assert len(signal_spy) == 1
class TestDragInterceptorMultipleDrags:
"""Test multiple file drag support."""
def test_handle_drag_with_list_single_item(self, qtbot, tmp_path):
"""Test handle_drag with list containing single file path."""
test_file = tmp_path / "test.txt"
test_file.write_text("content")
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[],
check_file_exists=True,
)
interceptor = DragInterceptor(config)
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.handle_drag([str(test_file)])
assert result is True
def test_handle_drag_with_multiple_files(self, qtbot, tmp_path):
"""Test handle_drag with list of multiple file paths."""
# Create multiple test files
test_file1 = tmp_path / "test1.txt"
test_file1.write_text("content1")
test_file2 = tmp_path / "test2.txt"
test_file2.write_text("content2")
test_file3 = tmp_path / "test3.txt"
test_file3.write_text("content3")
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[],
check_file_exists=True,
)
interceptor = DragInterceptor(config)
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.handle_drag(
[
str(test_file1),
str(test_file2),
str(test_file3),
]
)
assert result is True
def test_handle_drag_with_multiple_azure_urls(self, qtbot, tmp_path):
"""Test handle_drag with list of multiple Azure URLs."""
from webdrop_bridge.config import URLMapping
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[
URLMapping(
url_prefix="https://produktagravitystg.file.core.windows.net/produktagravitysync/",
local_path=str(tmp_path),
)
],
check_file_exists=False, # Don't check file existence for this test
)
interceptor = DragInterceptor(config)
# Multiple Azure URLs (as would be in a multi-drag)
azure_urls = [
"https://produktagravitystg.file.core.windows.net/produktagravitysync/axtZdPVjs5iUaKU2muKMFN1WZ/igkjieyjcko.jpg",
"https://produktagravitystg.file.core.windows.net/produktagravitysync/aWd7mDjnsm2w0PHU9AryQBYz2/457101023fd46d673e2ce6642f78fb0d62736f0f06c7.jpg",
]
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.handle_drag(azure_urls)
assert result is True
# Verify QDrag.exec was called (meaning drag was set up correctly)
mock_drag_instance.exec.assert_called_once()
def test_handle_drag_mixed_urls_and_paths(self, qtbot, tmp_path):
"""Test handle_drag with mixed Azure URLs and local paths."""
from webdrop_bridge.config import URLMapping
# Create test file
test_file = tmp_path / "local_file.txt"
test_file.write_text("local content")
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[
URLMapping(
url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
local_path=str(tmp_path),
)
],
check_file_exists=False, # Don't check existence for remote files
)
interceptor = DragInterceptor(config)
mixed_items = [
str(test_file), # local path
"https://devagravitystg.file.core.windows.net/devagravitysync/remote.jpg", # Azure URL
]
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.handle_drag(mixed_items)
assert result is True
def test_handle_drag_multiple_empty_list(self, qtbot, test_config):
"""Test handle_drag with empty list fails."""
interceptor = DragInterceptor(test_config)
with qtbot.waitSignal(interceptor.drag_failed):
result = interceptor.handle_drag([])
assert result is False
def test_handle_drag_multiple_one_invalid_fails(self, qtbot, tmp_path):
"""Test handle_drag with multiple files fails if one is invalid."""
test_file1 = tmp_path / "test1.txt"
test_file1.write_text("content1")
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[],
check_file_exists=True,
)
interceptor = DragInterceptor(config)
# One valid, one invalid
files = [
str(test_file1),
"/etc/passwd", # Invalid - outside allowed roots
]
with qtbot.waitSignal(interceptor.drag_failed):
result = interceptor.handle_drag(files)
assert result is False
def test_handle_drag_multiple_signal_with_pipes(self, qtbot, tmp_path):
"""Test drag_started signal contains pipe-separated paths for multiple files."""
test_file1 = tmp_path / "test1.txt"
test_file1.write_text("content1")
test_file2 = tmp_path / "test2.txt"
test_file2.write_text("content2")
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[],
check_file_exists=True,
)
interceptor = DragInterceptor(config)
signal_spy = []
interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path)))
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.handle_drag([str(test_file1), str(test_file2)])
assert result is True
assert len(signal_spy) == 1
# Multiple paths should be separated by " | "
assert " | " in signal_spy[0][1]

View file

@ -82,136 +82,6 @@ class TestMainWindowInitialization:
assert window.drag_interceptor is not None
class TestMainWindowNavigation:
"""Test navigation toolbar and functionality."""
def test_navigation_toolbar_created(self, qtbot, sample_config):
"""Test navigation toolbar is created."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
toolbars = window.findChildren(QToolBar)
assert len(toolbars) > 0
def test_navigation_toolbar_not_movable(self, qtbot, sample_config):
"""Test navigation toolbar is not movable (locked for Kiosk-mode)."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
toolbar = window.findChild(QToolBar)
assert toolbar is not None
assert not toolbar.isMovable()
def test_navigate_home(self, qtbot, sample_config):
"""Test home button navigation."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch.object(window.web_view, "load") as mock_load:
window._navigate_home()
mock_load.assert_called_once()
def test_navigate_home_with_http_url(self, qtbot, tmp_path):
"""Test home navigation with HTTP URL."""
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="http://localhost:8000",
window_width=800,
window_height=600,
enable_logging=False,
)
window = MainWindow(config)
qtbot.addWidget(window)
with patch.object(window.web_view, "load") as mock_load:
window._navigate_home()
# Verify load was called with HTTP URL
call_args = mock_load.call_args
url = call_args[0][0]
assert url.scheme() == "http"
def test_navigate_home_with_file_url(self, qtbot, sample_config):
"""Test home navigation with file:// URL."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch.object(window.web_view, "load") as mock_load:
window._navigate_home()
call_args = mock_load.call_args
url = call_args[0][0]
assert url.scheme() == "file"
class TestMainWindowWebAppLoading:
"""Test web application loading."""
def test_load_local_webapp_file(self, qtbot, sample_config):
"""Test loading local webapp file."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Window should load without errors
assert window.web_view is not None
def test_load_remote_webapp_url(self, qtbot, tmp_path):
"""Test loading remote webapp URL."""
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=["localhost"],
webapp_url="http://localhost:3000",
window_width=800,
window_height=600,
enable_logging=False,
)
window = MainWindow(config)
qtbot.addWidget(window)
assert window.web_view is not None
def test_load_nonexistent_file_shows_welcome_page(self, qtbot, tmp_path):
"""Test loading nonexistent file shows welcome page HTML."""
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="/nonexistent/file.html",
window_width=800,
window_height=600,
enable_logging=False,
)
with patch.object(config, "webapp_url", "/nonexistent/file.html"):
window = MainWindow(config)
qtbot.addWidget(window)
with patch.object(
window.web_view, "setHtml"
) as mock_set_html:
window._load_webapp()
mock_set_html.assert_called_once()
# Verify welcome page is shown instead of error
call_args = mock_set_html.call_args[0][0]
assert "WebDrop Bridge" in call_args
assert "Application Ready" in call_args
class TestMainWindowDragIntegration:
"""Test drag-and-drop integration."""
@ -231,12 +101,10 @@ class TestMainWindowDragIntegration:
assert window.drag_interceptor.drag_started is not None
assert window.drag_interceptor.drag_failed is not None
def test_handle_drag_delegates_to_interceptor(
self, qtbot, sample_config, tmp_path
):
def test_handle_drag_delegates_to_interceptor(self, qtbot, sample_config, tmp_path):
"""Test drag handling delegates to interceptor."""
from PySide6.QtCore import QCoreApplication
window = MainWindow(sample_config)
qtbot.addWidget(window)
@ -244,13 +112,11 @@ class TestMainWindowDragIntegration:
test_file = sample_config.allowed_roots[0] / "test.txt"
test_file.write_text("test")
with patch.object(
window.drag_interceptor, "handle_drag"
) as mock_drag:
with patch.object(window.drag_interceptor, "handle_drag") as mock_drag:
mock_drag.return_value = True
# Call through bridge
window._drag_bridge.start_file_drag(str(test_file))
# Process deferred QTimer.singleShot(0, ...) call
QCoreApplication.processEvents()
@ -276,278 +142,10 @@ class TestMainWindowDragIntegration:
class TestMainWindowURLWhitelist:
"""Test URL whitelisting integration."""
def test_restricted_web_view_receives_allowed_urls(
self, qtbot, sample_config
):
def test_restricted_web_view_receives_allowed_urls(self, qtbot, sample_config):
"""Test RestrictedWebEngineView receives allowed URLs from config."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# web_view should have allowed_urls configured
assert window.web_view.allowed_urls == sample_config.allowed_urls
def test_empty_allowed_urls_list(self, qtbot, tmp_path):
"""Test with empty allowed URLs (no restriction)."""
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[], # Empty = no restriction
webapp_url="http://localhost",
window_width=800,
window_height=600,
enable_logging=False,
)
window = MainWindow(config)
qtbot.addWidget(window)
assert window.web_view.allowed_urls == []
class TestMainWindowSignals:
"""Test signal connections."""
def test_drag_started_signal_connection(self, qtbot, sample_config):
"""Test drag_started signal is connected to handler."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch.object(window, "_on_drag_started") as mock_handler:
window.drag_interceptor.drag_started.emit("/path/to/file", "Z:\\local\\file")
mock_handler.assert_called_once()
def test_drag_failed_signal_connection(self, qtbot, sample_config):
"""Test drag_failed signal is connected to handler."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch.object(window, "_on_drag_failed") as mock_handler:
window.drag_interceptor.drag_failed.emit("https://example.com/file", "File not found")
mock_handler.assert_called_once()
class TestMainWindowMenuBar:
"""Test toolbar help actions integration."""
def test_navigation_toolbar_created(self, qtbot, sample_config):
"""Test navigation toolbar is created with help buttons."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Check that toolbar exists
assert len(window.findChildren(QToolBar)) > 0
toolbar = window.findChildren(QToolBar)[0]
assert toolbar is not None
def test_window_has_check_for_updates_signal(self, qtbot, sample_config):
"""Test window has check_for_updates signal."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Test that signal exists
assert hasattr(window, "check_for_updates")
# Test that signal is callable (can be emitted)
assert callable(window.check_for_updates.emit)
def test_on_check_for_updates_method_exists(self, qtbot, sample_config):
"""Test _on_manual_check_for_updates method exists."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Test that the method exists
assert hasattr(window, "_on_manual_check_for_updates")
assert callable(window._on_manual_check_for_updates)
def test_show_about_dialog_method_exists(self, qtbot, sample_config):
"""Test _show_about_dialog method exists."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Test that the method exists
assert hasattr(window, "_show_about_dialog")
assert callable(window._show_about_dialog)
class TestMainWindowStatusBar:
"""Test status bar and update status."""
def test_status_bar_created(self, qtbot, sample_config):
"""Test status bar is created."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
assert window.statusBar() is not None
assert hasattr(window, "status_bar")
def test_update_status_label_created(self, qtbot, sample_config):
"""Test update status label exists."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
assert hasattr(window, "update_status_label")
assert window.update_status_label is not None
def test_set_update_status_text_only(self, qtbot, sample_config):
"""Test setting update status with text only."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
window.set_update_status("Checking for updates")
assert "Checking for updates" in window.update_status_label.text()
def test_set_update_status_with_emoji(self, qtbot, sample_config):
"""Test setting update status with emoji."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
window.set_update_status("Checking", emoji="🔄")
assert "🔄" in window.update_status_label.text()
assert "Checking" in window.update_status_label.text()
def test_set_update_status_checking(self, qtbot, sample_config):
"""Test checking for updates status."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
window.set_update_status("Checking for updates", emoji="🔄")
assert "🔄" in window.update_status_label.text()
def test_set_update_status_available(self, qtbot, sample_config):
"""Test update available status."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
window.set_update_status("Update available v0.0.2", emoji="")
assert "" in window.update_status_label.text()
def test_set_update_status_downloading(self, qtbot, sample_config):
"""Test downloading status."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
window.set_update_status("Downloading update", emoji="⬇️")
assert "⬇️" in window.update_status_label.text()
def test_set_update_status_error(self, qtbot, sample_config):
"""Test error status."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
window.set_update_status("Update check failed", emoji="⚠️")
assert "⚠️" in window.update_status_label.text()
class TestMainWindowStylesheet:
"""Test stylesheet application."""
def test_stylesheet_loading_gracefully_handles_missing_file(
self, qtbot, sample_config
):
"""Test missing stylesheet doesn't crash application."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Should not raise even if stylesheet missing
window._apply_stylesheet()
def test_stylesheet_loading_with_nonexistent_file(
self, qtbot, sample_config
):
"""Test stylesheet loading with nonexistent file path."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch("builtins.open", side_effect=OSError("File not found")):
# Should handle gracefully
window._apply_stylesheet()
class TestMainWindowCloseEvent:
"""Test window close handling."""
def test_close_event_accepted(self, qtbot, sample_config):
"""Test close event is accepted."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
from PySide6.QtGui import QCloseEvent
event = QCloseEvent()
window.closeEvent(event)
assert event.isAccepted()
class TestMainWindowIntegration:
"""Integration tests for MainWindow with all components."""
def test_full_initialization_flow(self, qtbot, sample_config):
"""Test complete initialization flow."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Verify all components initialized
assert window.web_view is not None
assert window.drag_interceptor is not None
assert window.config == sample_config
# Verify toolbar exists
toolbars = window.findChildren(QToolBar)
assert len(toolbars) > 0
def test_window_with_multiple_allowed_roots(self, qtbot, tmp_path):
"""Test MainWindow with multiple allowed root directories."""
root1 = tmp_path / "root1"
root2 = tmp_path / "root2"
root1.mkdir()
root2.mkdir()
webapp_file = tmp_path / "index.html"
webapp_file.write_text("<html></html>")
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[root1, root2],
allowed_urls=[],
webapp_url=str(webapp_file),
window_width=800,
window_height=600,
enable_logging=False,
)
window = MainWindow(config)
qtbot.addWidget(window)
# Verify validator has both roots
assert window.drag_interceptor._validator is not None
assert len(
window.drag_interceptor._validator.allowed_roots
) == 2
def test_window_with_url_whitelist(self, qtbot, tmp_path):
"""Test MainWindow respects URL whitelist."""
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=["*.example.com", "localhost"],
webapp_url="http://localhost",
window_width=800,
window_height=600,
enable_logging=False,
)
window = MainWindow(config)
qtbot.addWidget(window)
# Verify whitelist is set
assert window.web_view.allowed_urls == ["*.example.com", "localhost"]