"""Tests pour le module exif_writer.""" import subprocess from pathlib import Path from unittest.mock import MagicMock, patch import pytest from json_to_metadata.exif_writer import ( ExiftoolError, ExiftoolNotFoundError, _build_exiftool_args, _format_tag_value, check_exiftool_available, supports_embedded_exif, supports_partial_exif, write_exif_to_file, write_exif_with_fallback, ) class TestCheckExiftoolAvailable: """Tests pour la fonction check_exiftool_available.""" @patch('shutil.which') @patch('subprocess.run') def test_exiftool_available(self, mock_run, mock_which): """Retourne True si exiftool est disponible.""" mock_which.return_value = '/usr/bin/exiftool' mock_run.return_value = MagicMock(returncode=0, stdout='12.50') assert check_exiftool_available() is True @patch('shutil.which') def test_exiftool_not_found(self, mock_which): """Lève une exception si exiftool n'est pas trouvé.""" mock_which.return_value = None with pytest.raises(ExiftoolNotFoundError): check_exiftool_available() @patch('shutil.which') @patch('subprocess.run') def test_exiftool_timeout(self, mock_run, mock_which): """Lève une exception en cas de timeout.""" mock_which.return_value = '/usr/bin/exiftool' mock_run.side_effect = subprocess.TimeoutExpired('exiftool', 10) with pytest.raises(ExiftoolError, match='timeout'): check_exiftool_available() class TestSupportsEmbeddedExif: """Tests pour la fonction supports_embedded_exif.""" def test_tiff_supported(self): """Les fichiers TIFF supportent l'EXIF intégré.""" assert supports_embedded_exif(Path('photo.tif')) is True assert supports_embedded_exif(Path('photo.tiff')) is True assert supports_embedded_exif(Path('photo.TIF')) is True def test_jpeg_supported(self): """Les fichiers JPEG supportent l'EXIF intégré.""" assert supports_embedded_exif(Path('photo.jpg')) is True assert supports_embedded_exif(Path('photo.jpeg')) is True assert supports_embedded_exif(Path('photo.JPG')) is True def test_avif_not_fully_supported(self): """Les fichiers AVIF n'ont pas un support complet.""" assert supports_embedded_exif(Path('photo.avif')) is False def test_png_not_supported(self): """Les fichiers PNG ne supportent pas l'EXIF intégré.""" assert supports_embedded_exif(Path('photo.png')) is False class TestSupportsPartialExif: """Tests pour la fonction supports_partial_exif.""" def test_avif_partial_support(self): """Les fichiers AVIF ont un support partiel.""" assert supports_partial_exif(Path('photo.avif')) is True def test_heic_partial_support(self): """Les fichiers HEIC ont un support partiel.""" assert supports_partial_exif(Path('photo.heic')) is True def test_webp_partial_support(self): """Les fichiers WebP ont un support partiel.""" assert supports_partial_exif(Path('photo.webp')) is True def test_jpeg_full_support(self): """Les fichiers JPEG n'ont pas un support 'partiel'.""" assert supports_partial_exif(Path('photo.jpg')) is False class TestBuildExiftoolArgs: """Tests pour la fonction _build_exiftool_args.""" def test_simple_tags(self): """Construit des arguments pour des tags simples.""" tags = { 'Make': 'Nikon', 'Model': 'FM2', 'ISO': 400 } args = _build_exiftool_args(tags) assert '-Make=Nikon' in args assert '-Model=FM2' in args assert '-ISO=400' in args def test_skip_none_values(self): """Ignore les valeurs None.""" tags = {'Make': 'Nikon', 'Model': None} args = _build_exiftool_args(tags) assert '-Make=Nikon' in args assert len([a for a in args if 'Model' in a]) == 0 def test_float_formatting(self): """Formate correctement les nombres flottants.""" tags = {'FNumber': 2.8, 'ExposureTime': 0.008} args = _build_exiftool_args(tags) assert '-FNumber=2.8' in args assert any('ExposureTime' in a for a in args) class TestFormatTagValue: """Tests pour la fonction _format_tag_value.""" def test_boolean_true(self): """Formate True en '1'.""" assert _format_tag_value('Flash', True) == '1' def test_boolean_false(self): """Formate False en '0'.""" assert _format_tag_value('Flash', False) == '0' def test_integer_float(self): """Formate les floats entiers sans décimales.""" assert _format_tag_value('ISO', 400.0) == '400' def test_decimal_float(self): """Formate les floats décimaux avec précision.""" result = _format_tag_value('FNumber', 2.8) assert '2.8' in result def test_list_formatting(self): """Formate les listes en chaîne séparée par virgules.""" assert _format_tag_value('Keywords', ['a', 'b', 'c']) == 'a, b, c' class TestWriteExifToFile: """Tests pour la fonction write_exif_to_file.""" def test_file_not_found(self): """Lève une exception si le fichier n'existe pas.""" with pytest.raises(FileNotFoundError): write_exif_to_file(Path('/nonexistent/photo.jpg'), {'Make': 'Test'}) def test_empty_tags(self, tmp_path): """Retourne True pour des tags vides.""" image = tmp_path / 'photo.jpg' image.write_bytes(b'fake image content') result = write_exif_to_file(image, {}) assert result is True @patch('subprocess.run') def test_dry_run_mode(self, mock_run, tmp_path): """Le mode dry-run n'exécute pas exiftool.""" image = tmp_path / 'photo.jpg' image.write_bytes(b'fake image content') result = write_exif_to_file(image, {'Make': 'Nikon'}, dry_run=True) assert result is True mock_run.assert_not_called() @patch('subprocess.run') def test_successful_write(self, mock_run, tmp_path): """Écrit les métadonnées avec succès.""" image = tmp_path / 'photo.jpg' image.write_bytes(b'fake image content') mock_run.return_value = MagicMock( returncode=0, stdout='1 image files updated', stderr='' ) result = write_exif_to_file(image, {'Make': 'Nikon'}) assert result is True mock_run.assert_called_once() @patch('subprocess.run') def test_exiftool_error(self, mock_run, tmp_path): """Lève une exception en cas d'erreur exiftool.""" image = tmp_path / 'photo.jpg' image.write_bytes(b'fake image content') mock_run.return_value = MagicMock( returncode=1, stdout='', stderr='Error: Invalid file format' ) with pytest.raises(ExiftoolError, match='Invalid file format'): write_exif_to_file(image, {'Make': 'Nikon'}) class TestWriteExifWithFallback: """Tests pour la fonction write_exif_with_fallback.""" @patch('json_to_metadata.exif_writer.write_exif_to_file') def test_jpeg_uses_exif(self, mock_write, tmp_path): """Les JPEG utilisent l'écriture EXIF directe.""" image = tmp_path / 'photo.jpg' image.write_bytes(b'fake image content') mock_write.return_value = True mock_xmp = MagicMock() success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp) assert success is True assert method == 'exif' mock_write.assert_called_once() @patch('json_to_metadata.exif_writer.write_exif_to_file') def test_avif_fallback_to_xmp(self, mock_write, tmp_path): """Les AVIF utilisent XMP en fallback si EXIF échoue.""" image = tmp_path / 'photo.avif' image.write_bytes(b'fake image content') mock_write.side_effect = ExiftoolError('Not supported') mock_xmp = MagicMock(return_value=image.with_suffix('.xmp')) success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp) assert success is True assert method == 'xmp' def test_png_uses_xmp_directly(self, tmp_path): """Les PNG utilisent directement XMP.""" image = tmp_path / 'photo.png' image.write_bytes(b'fake image content') xmp_path = image.with_suffix('.xmp') mock_xmp = MagicMock(return_value=xmp_path) success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp) assert success is True assert method == 'xmp'