pyPhotoAlbum/tests/test_asset_heal_dialog.py
Duncan Tourolle b18a780a33
All checks were successful
Python CI / test (push) Successful in 1m28s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m35s
Tests / test (3.14) (push) Successful in 1m15s
increase test coverage
2025-11-28 19:54:41 +01:00

597 lines
21 KiB
Python

"""
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()