""" Tests for asset_heal_dialog module """ import pytest import os import shutil from pathlib import Path from unittest.mock import Mock, MagicMock, patch, call from PyQt6.QtWidgets import QMessageBox, QFileDialog from PyQt6.QtCore import Qt class TestAssetHealDialog: """Tests for AssetHealDialog class""" @pytest.fixture def mock_project(self, tmp_path): """Create a mock project with folder_path""" project = Mock() project.folder_path = str(tmp_path / "project") os.makedirs(project.folder_path, exist_ok=True) # Create assets folder assets_path = os.path.join(project.folder_path, "assets") os.makedirs(assets_path, exist_ok=True) project.asset_manager = Mock() project.pages = [] return project @pytest.fixture def mock_page_with_image(self): """Create a mock page with an image element""" from pyPhotoAlbum.models import ImageData page = Mock() element = Mock(spec=ImageData) element.image_path = "assets/image.jpg" page.layout = Mock() page.layout.elements = [element] return page def test_init(self, qtbot, mock_project): """Test AssetHealDialog initialization""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) assert dialog.project is mock_project assert dialog.search_paths == [] assert dialog.missing_assets == set() assert dialog.windowTitle() == "Heal Missing Assets" def test_init_ui(self, qtbot, mock_project): """Test UI initialization""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) # Check that UI elements exist assert dialog.missing_list is not None assert dialog.search_list is not None def test_scan_missing_assets_no_missing(self, qtbot, mock_project, tmp_path): """Test scanning when no assets are missing""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData # Create a valid image in assets folder img_path = os.path.join(mock_project.folder_path, "assets", "image.jpg") Path(img_path).touch() element = Mock(spec=ImageData) element.image_path = "assets/image.jpg" page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) assert len(dialog.missing_assets) == 0 assert dialog.missing_list.count() == 1 # "No missing assets found!" message item = dialog.missing_list.item(0) assert "No missing assets" in item.text() def test_scan_missing_assets_absolute_path(self, qtbot, mock_project): """Test scanning detects absolute paths as needing healing""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData element = Mock(spec=ImageData) element.image_path = "/absolute/path/to/image.jpg" page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) assert "/absolute/path/to/image.jpg" in dialog.missing_assets def test_scan_missing_assets_not_in_assets_folder(self, qtbot, mock_project): """Test scanning detects paths not in assets/ folder""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData element = Mock(spec=ImageData) element.image_path = "images/photo.jpg" # Not in assets/ page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) assert "images/photo.jpg" in dialog.missing_assets def test_scan_missing_assets_file_missing(self, qtbot, mock_project): """Test scanning detects missing files in assets/ folder""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData element = Mock(spec=ImageData) element.image_path = "assets/missing.jpg" # File doesn't exist page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) assert "assets/missing.jpg" in dialog.missing_assets def test_scan_missing_assets_non_image_elements_ignored(self, qtbot, mock_project): """Test that non-ImageData elements are ignored""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog # TextBox element (not ImageData) element = Mock() element.image_path = None page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) assert len(dialog.missing_assets) == 0 def test_scan_missing_assets_empty_image_path(self, qtbot, mock_project): """Test that elements with empty image_path are ignored""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData element = Mock(spec=ImageData) element.image_path = "" page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) assert len(dialog.missing_assets) == 0 def test_add_search_path(self, qtbot, mock_project, tmp_path): """Test adding a search path""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) search_path = str(tmp_path / "search") os.makedirs(search_path, exist_ok=True) with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path): dialog._add_search_path() assert search_path in dialog.search_paths assert dialog.search_list.count() == 1 def test_add_search_path_duplicate(self, qtbot, mock_project, tmp_path): """Test adding duplicate search path is ignored""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) search_path = str(tmp_path / "search") os.makedirs(search_path, exist_ok=True) with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path): dialog._add_search_path() dialog._add_search_path() assert dialog.search_paths.count(search_path) == 1 assert dialog.search_list.count() == 1 def test_add_search_path_cancelled(self, qtbot, mock_project): """Test cancelling search path dialog""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) with patch.object(QFileDialog, 'getExistingDirectory', return_value=""): dialog._add_search_path() assert len(dialog.search_paths) == 0 def test_remove_search_path(self, qtbot, mock_project, tmp_path): """Test removing a search path""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) search_path = str(tmp_path / "search") dialog.search_paths.append(search_path) dialog.search_list.addItem(search_path) dialog.search_list.setCurrentRow(0) dialog._remove_search_path() assert len(dialog.search_paths) == 0 assert dialog.search_list.count() == 0 def test_remove_search_path_none_selected(self, qtbot, mock_project): """Test removing when no path is selected""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) dialog.search_paths.append("/some/path") dialog.search_list.addItem("/some/path") dialog.search_list.setCurrentRow(-1) # No selection dialog._remove_search_path() # Should not remove anything assert len(dialog.search_paths) == 1 @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_no_missing_assets(self, mock_set_context, qtbot, mock_project): """Test healing when there are no missing assets""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog mock_project.pages = [] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) with patch.object(QMessageBox, 'information') as mock_info: dialog._attempt_healing() mock_info.assert_called_once() args = mock_info.call_args[0] assert "Assets found: 0" in args[2] @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_resolve_relative_path(self, mock_set_context, qtbot, mock_project, tmp_path): """Test healing by resolving relative path""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData # Create the actual image file outside project external_img = tmp_path / "external" / "image.jpg" external_img.parent.mkdir(exist_ok=True) external_img.touch() # Element with relative path that resolves to external image element = Mock(spec=ImageData) rel_path = os.path.relpath(str(external_img), mock_project.folder_path) element.image_path = rel_path page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) # Mock the import_asset method mock_project.asset_manager.import_asset.return_value = "assets/image.jpg" with patch.object(QMessageBox, 'information') as mock_info: dialog._attempt_healing() # Should have imported the asset mock_project.asset_manager.import_asset.assert_called() @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_absolute_path_exists(self, mock_set_context, qtbot, mock_project, tmp_path): """Test healing absolute path that exists""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData # Create image at absolute path abs_img = tmp_path / "image.jpg" abs_img.touch() element = Mock(spec=ImageData) element.image_path = str(abs_img) page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) mock_project.asset_manager.import_asset.return_value = "assets/image.jpg" with patch.object(QMessageBox, 'information') as mock_info: dialog._attempt_healing() # Should import the asset mock_project.asset_manager.import_asset.assert_called_with(str(abs_img)) # Should update element path assert element.image_path == "assets/image.jpg" @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_search_path_by_filename(self, mock_set_context, qtbot, mock_project, tmp_path): """Test healing by finding file in search path by filename""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData # Create search path with image search_dir = tmp_path / "search" search_dir.mkdir() found_img = search_dir / "photo.jpg" found_img.touch() element = Mock(spec=ImageData) element.image_path = "/missing/path/photo.jpg" page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) dialog.missing_assets.add("/missing/path/photo.jpg") dialog.search_paths.append(str(search_dir)) mock_project.asset_manager.import_asset.return_value = "assets/photo.jpg" with patch.object(QMessageBox, 'information'): dialog._attempt_healing() mock_project.asset_manager.import_asset.assert_called_with(str(found_img)) @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_search_path_by_relative_structure(self, mock_set_context, qtbot, mock_project, tmp_path): """Test healing by finding file in search path with same relative structure""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData # Create search path with subdirectory structure search_dir = tmp_path / "search" subdir = search_dir / "photos" subdir.mkdir(parents=True) found_img = subdir / "image.jpg" found_img.touch() element = Mock(spec=ImageData) element.image_path = "photos/image.jpg" page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) dialog.missing_assets.add("photos/image.jpg") dialog.search_paths.append(str(search_dir)) mock_project.asset_manager.import_asset.return_value = "assets/image.jpg" with patch.object(QMessageBox, 'information'): dialog._attempt_healing() mock_project.asset_manager.import_asset.assert_called_with(str(found_img)) @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_restore_to_assets_folder(self, mock_set_context, qtbot, mock_project, tmp_path): """Test healing by restoring file to assets folder""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData # Create source image source_img = tmp_path / "source" / "image.jpg" source_img.parent.mkdir() source_img.touch() element = Mock(spec=ImageData) element.image_path = "assets/image.jpg" # Already correct path, just missing file page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] with patch.object(AssetHealDialog, '_scan_missing_assets'): dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) dialog.missing_assets.add("assets/image.jpg") dialog.search_paths.append(str(source_img.parent)) with patch('shutil.copy2') as mock_copy: with patch.object(QMessageBox, 'information'): dialog._attempt_healing() # Should copy file, not import mock_copy.assert_called_once() assert not mock_project.asset_manager.import_asset.called @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_not_found(self, mock_set_context, qtbot, mock_project): """Test healing when asset cannot be found""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData element = Mock(spec=ImageData) element.image_path = "/missing/image.jpg" page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) with patch.object(QMessageBox, 'information') as mock_info: dialog._attempt_healing() args = mock_info.call_args[0] assert "Still missing: 1" in args[2] assert "/missing/image.jpg" in args[2] @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_import_error(self, mock_set_context, qtbot, mock_project, tmp_path): """Test healing when import_asset raises an error""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData # Create image img = tmp_path / "image.jpg" img.touch() element = Mock(spec=ImageData) element.image_path = str(img) page = Mock() page.layout = Mock() page.layout.elements = [element] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) # Mock import to raise error mock_project.asset_manager.import_asset.side_effect = Exception("Import failed") with patch.object(QMessageBox, 'information') as mock_info: dialog._attempt_healing() # Should be in still_missing list args = mock_info.call_args[0] assert "Still missing: 1" in args[2] @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_many_missing(self, mock_set_context, qtbot, mock_project): """Test healing with more than 10 missing assets (tests truncation)""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData pages = [] for i in range(15): element = Mock(spec=ImageData) element.image_path = f"/missing/image{i}.jpg" page = Mock() page.layout = Mock() page.layout.elements = [element] pages.append(page) mock_project.pages = pages dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) with patch.object(QMessageBox, 'information') as mock_info: dialog._attempt_healing() args = mock_info.call_args[0] # Should show "... and X more" assert "and 5 more" in args[2] @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_multiple_elements_same_path(self, mock_set_context, qtbot, mock_project, tmp_path): """Test healing when multiple elements reference the same missing path""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.models import ImageData # Create image img = tmp_path / "image.jpg" img.touch() # Two elements with same path element1 = Mock(spec=ImageData) element1.image_path = str(img) element2 = Mock(spec=ImageData) element2.image_path = str(img) page = Mock() page.layout = Mock() page.layout.elements = [element1, element2] mock_project.pages = [page] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) mock_project.asset_manager.import_asset.return_value = "assets/image.jpg" with patch.object(QMessageBox, 'information'): dialog._attempt_healing() # Both elements should be updated assert element1.image_path == "assets/image.jpg" assert element2.image_path == "assets/image.jpg" @patch('pyPhotoAlbum.models.set_asset_resolution_context') def test_attempt_healing_rescans_after(self, mock_set_context, qtbot, mock_project): """Test that _scan_missing_assets is called after healing""" from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog mock_project.pages = [] dialog = AssetHealDialog(mock_project) qtbot.addWidget(dialog) with patch.object(dialog, '_scan_missing_assets') as mock_scan: with patch.object(QMessageBox, 'information'): dialog._attempt_healing() # Should rescan after healing mock_scan.assert_called_once()