diff --git a/src/webdrop_bridge/core/drag_interceptor.py b/src/webdrop_bridge/core/drag_interceptor.py index f00eae2..6f37ce5 100644 --- a/src/webdrop_bridge/core/drag_interceptor.py +++ b/src/webdrop_bridge/core/drag_interceptor.py @@ -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) diff --git a/src/webdrop_bridge/ui/bridge_script_intercept.js b/src/webdrop_bridge/ui/bridge_script_intercept.js index f7a2b04..9c8914a 100644 --- a/src/webdrop_bridge/ui/bridge_script_intercept.js +++ b/src/webdrop_bridge/ui/bridge_script_intercept.js @@ -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; } } diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index accafcf..46c01f5 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -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: diff --git a/tests/unit/test_drag_interceptor.py b/tests/unit/test_drag_interceptor.py index 74b262e..c19333f 100644 --- a/tests/unit/test_drag_interceptor.py +++ b/tests/unit/test_drag_interceptor.py @@ -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] diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py index 65f35ab..01eaa5a 100644 --- a/tests/unit/test_main_window.py +++ b/tests/unit/test_main_window.py @@ -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("") - - 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"]