Initial commit: script de gestion des métadonnées EXIF pour photos argentiques

Script Python qui lit un fichier JSON généré par Exif Notes et écrit
les métadonnées EXIF dans les fichiers images (TIFF, JPEG) ou crée
des fichiers XMP sidecar pour les formats non supportés.

Fonctionnalités :
- Validation des fichiers JSON Exif Notes
- Mapping complet JSON → tags EXIF standard
- Écriture EXIF via exiftool avec fallback XMP
- Support des formats de vitesse (1/125, 2", B/Bulb)
- Support des dates ISO avec ou sans secondes
- CLI avec options --dry-run, --verbose, --force-xmp

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 16:22:12 +01:00
commit 25bd8bc961
14 changed files with 2715 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests pour json-to-metadata."""

257
tests/test_exif_writer.py Normal file
View File

@@ -0,0 +1,257 @@
"""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'

263
tests/test_json_parser.py Normal file
View File

@@ -0,0 +1,263 @@
"""Tests pour le module json_parser."""
import json
from pathlib import Path
import pytest
from json_to_metadata.json_parser import (
ValidationError,
load_json,
validate_frame,
validate_roll,
)
class TestLoadJson:
"""Tests pour la fonction load_json."""
def test_load_valid_json(self, tmp_path):
"""Charge un fichier JSON valide."""
data = {'id': 1, 'frames': []}
json_file = tmp_path / 'test.json'
json_file.write_text(json.dumps(data), encoding='utf-8')
result = load_json(json_file)
assert result == data
def test_load_json_file_not_found(self):
"""Lève une exception si le fichier n'existe pas."""
with pytest.raises(FileNotFoundError):
load_json(Path('/nonexistent/file.json'))
def test_load_invalid_json(self, tmp_path):
"""Lève une exception pour un JSON malformé."""
json_file = tmp_path / 'invalid.json'
json_file.write_text('{ invalid json }', encoding='utf-8')
with pytest.raises(json.JSONDecodeError):
load_json(json_file)
def test_load_json_with_unicode(self, tmp_path):
"""Charge un JSON avec des caractères Unicode."""
data = {'note': 'Café à Paris 🇫🇷'}
json_file = tmp_path / 'unicode.json'
json_file.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8')
result = load_json(json_file)
assert result['note'] == 'Café à Paris 🇫🇷'
class TestValidateRoll:
"""Tests pour la fonction validate_roll."""
def test_valid_roll_minimal(self):
"""Valide un roll avec les champs minimaux."""
roll = {
'id': 1,
'frames': [{'id': 1}]
}
assert validate_roll(roll) is True
def test_valid_roll_complete(self):
"""Valide un roll complet avec tous les champs optionnels."""
roll = {
'id': 'roll-001',
'frames': [
{'id': 1, 'date': '2024-03-15T14:30:00'}
],
'camera': {'make': 'Nikon', 'model': 'FM2'},
'filmStock': {'make': 'Kodak', 'model': 'Portra 400'},
'iso': 400
}
assert validate_roll(roll) is True
def test_missing_id(self):
"""Lève une exception si l'id est manquant."""
roll = {'frames': [{'id': 1}]}
with pytest.raises(ValidationError, match="'id'"):
validate_roll(roll)
def test_missing_frames(self):
"""Lève une exception si frames est manquant."""
roll = {'id': 1}
with pytest.raises(ValidationError, match="'frames'"):
validate_roll(roll)
def test_empty_frames(self):
"""Lève une exception si frames est vide."""
roll = {'id': 1, 'frames': []}
with pytest.raises(ValidationError, match="aucun frame"):
validate_roll(roll)
def test_frames_not_list(self):
"""Lève une exception si frames n'est pas une liste."""
roll = {'id': 1, 'frames': 'invalid'}
with pytest.raises(ValidationError, match="liste"):
validate_roll(roll)
def test_invalid_camera_type(self):
"""Lève une exception si camera n'est pas un dict."""
roll = {
'id': 1,
'frames': [{'id': 1}],
'camera': 'Nikon FM2'
}
with pytest.raises(ValidationError, match="dictionnaire"):
validate_roll(roll)
def test_invalid_iso_type(self):
"""Lève une exception si iso n'est pas numérique."""
roll = {
'id': 1,
'frames': [{'id': 1}],
'iso': 'four hundred'
}
with pytest.raises(ValidationError, match="numérique"):
validate_roll(roll)
def test_frame_validation_error_propagation(self):
"""Les erreurs de validation des frames sont propagées."""
roll = {
'id': 1,
'frames': [
{'id': 1},
{'note': 'missing id'} # Frame invalide
]
}
with pytest.raises(ValidationError, match="frame 2"):
validate_roll(roll)
class TestValidateFrame:
"""Tests pour la fonction validate_frame."""
def test_valid_frame_minimal(self):
"""Valide un frame avec les champs minimaux."""
frame = {'id': 1}
assert validate_frame(frame) is True
def test_valid_frame_complete(self):
"""Valide un frame complet."""
frame = {
'id': 1,
'date': '2024-03-15T14:30:00',
'aperture': 'f/2.8',
'shutter': '1/125',
'focalLength': 50,
'location': {'latitude': 48.8584, 'longitude': 2.2945},
'note': 'Tour Eiffel'
}
assert validate_frame(frame) is True
def test_missing_id(self):
"""Lève une exception si l'id est manquant."""
frame = {'date': '2024-03-15'}
with pytest.raises(ValidationError, match="'id'"):
validate_frame(frame)
def test_invalid_frame_type(self):
"""Lève une exception si le frame n'est pas un dict."""
with pytest.raises(ValidationError, match="dictionnaire"):
validate_frame("not a frame")
def test_valid_aperture_formats(self):
"""Accepte différents formats d'ouverture."""
frames = [
{'id': 1, 'aperture': 2.8},
{'id': 2, 'aperture': 'f/2.8'},
{'id': 3, 'aperture': 'f2.8'},
{'id': 4, 'aperture': '5.6'},
]
for frame in frames:
assert validate_frame(frame) is True
def test_invalid_aperture(self):
"""Lève une exception pour une ouverture invalide."""
frame = {'id': 1, 'aperture': 'wide open'}
with pytest.raises(ValidationError, match="ouverture"):
validate_frame(frame)
def test_valid_shutter_formats(self):
"""Accepte différents formats de vitesse."""
frames = [
{'id': 1, 'shutter': '1/125'},
{'id': 2, 'shutter': '1/125s'},
{'id': 3, 'shutter': '2'},
{'id': 4, 'shutter': '2s'},
{'id': 5, 'shutter': 0.5},
{'id': 6, 'shutter': '2"'}, # 2 secondes avec guillemet
{'id': 7, 'shutter': "4'"}, # 4 secondes avec apostrophe
{'id': 8, 'shutter': 'B'}, # Mode Bulb
{'id': 9, 'shutter': 'bulb'}, # Mode Bulb (minuscule)
]
for frame in frames:
assert validate_frame(frame) is True
def test_invalid_shutter(self):
"""Lève une exception pour une vitesse invalide."""
frame = {'id': 1, 'shutter': 'fast'}
with pytest.raises(ValidationError, match="vitesse"):
validate_frame(frame)
def test_valid_gps_coordinates(self):
"""Accepte des coordonnées GPS valides."""
frame = {
'id': 1,
'location': {
'latitude': 48.8584,
'longitude': 2.2945
}
}
assert validate_frame(frame) is True
def test_invalid_latitude_range(self):
"""Lève une exception pour une latitude hors limites."""
frame = {
'id': 1,
'location': {'latitude': 91.0, 'longitude': 0}
}
with pytest.raises(ValidationError, match="latitude"):
validate_frame(frame)
def test_invalid_longitude_range(self):
"""Lève une exception pour une longitude hors limites."""
frame = {
'id': 1,
'location': {'latitude': 0, 'longitude': 181.0}
}
with pytest.raises(ValidationError, match="longitude"):
validate_frame(frame)
def test_null_optional_fields(self):
"""Accepte des champs optionnels à None."""
frame = {
'id': 1,
'date': None,
'aperture': None,
'shutter': None,
'location': None
}
assert validate_frame(frame) is True

View File

@@ -0,0 +1,327 @@
"""Tests pour le module metadata_mapper."""
from zoneinfo import ZoneInfo
import pytest
from json_to_metadata.metadata_mapper import (
format_gps_for_exif,
map_frame_to_exif,
parse_aperture,
parse_date,
parse_shutter_speed,
)
class TestParseShutterSpeed:
"""Tests pour la fonction parse_shutter_speed."""
def test_fraction_format(self):
"""Parse une fraction simple."""
assert parse_shutter_speed('1/125') == pytest.approx(0.008, rel=0.01)
assert parse_shutter_speed('1/1000') == pytest.approx(0.001, rel=0.01)
assert parse_shutter_speed('1/60') == pytest.approx(0.0167, rel=0.01)
def test_fraction_with_suffix(self):
"""Parse une fraction avec suffixe 's'."""
assert parse_shutter_speed('1/125s') == pytest.approx(0.008, rel=0.01)
def test_whole_seconds(self):
"""Parse des temps en secondes entières."""
assert parse_shutter_speed('2') == 2.0
assert parse_shutter_speed('2s') == 2.0
assert parse_shutter_speed('30') == 30.0
def test_seconds_with_quote(self):
"""Parse des temps avec guillemet (notation photographique)."""
assert parse_shutter_speed('2"') == 2.0
assert parse_shutter_speed("4'") == 4.0
assert parse_shutter_speed('30"') == 30.0
def test_bulb_mode(self):
"""Retourne None pour le mode Bulb."""
assert parse_shutter_speed('B') is None
assert parse_shutter_speed('b') is None
assert parse_shutter_speed('Bulb') is None
assert parse_shutter_speed('bulb') is None
def test_numeric_input(self):
"""Accepte des valeurs numériques directes."""
assert parse_shutter_speed(0.5) == 0.5
assert parse_shutter_speed(2) == 2.0
def test_decimal_string(self):
"""Parse des chaînes décimales."""
assert parse_shutter_speed('0.5') == 0.5
def test_invalid_format(self):
"""Lève une exception pour un format invalide."""
with pytest.raises(ValueError):
parse_shutter_speed('fast')
def test_division_by_zero(self):
"""Lève une exception pour une division par zéro."""
with pytest.raises(ValueError):
parse_shutter_speed('1/0')
class TestParseAperture:
"""Tests pour la fonction parse_aperture."""
def test_f_stop_format(self):
"""Parse le format f/X.X."""
assert parse_aperture('f/2.8') == 2.8
assert parse_aperture('f/5.6') == 5.6
assert parse_aperture('f/16') == 16.0
def test_f_format_no_slash(self):
"""Parse le format fX.X."""
assert parse_aperture('f2.8') == 2.8
assert parse_aperture('F5.6') == 5.6
def test_numeric_string(self):
"""Parse une chaîne numérique simple."""
assert parse_aperture('2.8') == 2.8
assert parse_aperture('11') == 11.0
def test_numeric_input(self):
"""Accepte des valeurs numériques directes."""
assert parse_aperture(2.8) == 2.8
assert parse_aperture(8) == 8.0
def test_invalid_format(self):
"""Lève une exception pour un format invalide."""
with pytest.raises(ValueError):
parse_aperture('wide')
class TestParseDate:
"""Tests pour la fonction parse_date."""
def test_iso_format_with_timezone(self):
"""Parse le format ISO avec timezone."""
result = parse_date('2024-03-15T14:30:00+02:00')
assert result.year == 2024
assert result.month == 3
assert result.day == 15
assert result.hour == 14
assert result.minute == 30
assert result.tzinfo is not None
def test_iso_format_without_timezone(self):
"""Parse le format ISO sans timezone (utilise Europe/Paris)."""
result = parse_date('2024-03-15T14:30:00')
assert result.year == 2024
assert result.month == 3
assert result.day == 15
assert result.tzinfo == ZoneInfo('Europe/Paris')
def test_iso_format_with_z(self):
"""Parse le format ISO avec Z pour UTC."""
result = parse_date('2024-03-15T14:30:00Z')
assert result.tzinfo is not None
def test_iso_format_without_seconds(self):
"""Parse le format ISO sans secondes."""
result = parse_date('2025-11-16T16:47')
assert result.year == 2025
assert result.month == 11
assert result.day == 16
assert result.hour == 16
assert result.minute == 47
assert result.second == 0
def test_date_only(self):
"""Parse une date sans heure."""
result = parse_date('2024-03-15')
assert result.year == 2024
assert result.month == 3
assert result.day == 15
def test_simple_format(self):
"""Parse le format simple avec espace."""
result = parse_date('2024-03-15 14:30:00')
assert result.hour == 14
assert result.minute == 30
def test_invalid_format(self):
"""Lève une exception pour un format invalide."""
with pytest.raises(ValueError):
parse_date('15/03/2024')
class TestFormatGpsForExif:
"""Tests pour la fonction format_gps_for_exif."""
def test_north_east_coordinates(self):
"""Formate des coordonnées Nord-Est."""
result = format_gps_for_exif(48.8584, 2.2945)
assert result['GPSLatitude'] == 48.8584
assert result['GPSLatitudeRef'] == 'N'
assert result['GPSLongitude'] == 2.2945
assert result['GPSLongitudeRef'] == 'E'
def test_south_west_coordinates(self):
"""Formate des coordonnées Sud-Ouest."""
result = format_gps_for_exif(-22.9068, -43.1729)
assert result['GPSLatitude'] == 22.9068
assert result['GPSLatitudeRef'] == 'S'
assert result['GPSLongitude'] == 43.1729
assert result['GPSLongitudeRef'] == 'W'
def test_zero_coordinates(self):
"""Formate des coordonnées à zéro (équateur/méridien)."""
result = format_gps_for_exif(0, 0)
assert result['GPSLatitude'] == 0
assert result['GPSLatitudeRef'] == 'N'
assert result['GPSLongitude'] == 0
assert result['GPSLongitudeRef'] == 'E'
class TestMapFrameToExif:
"""Tests pour la fonction map_frame_to_exif."""
def test_basic_mapping(self):
"""Mappe les champs de base."""
frame = {'id': 1}
roll = {
'id': 'roll-1',
'frames': [frame],
'camera': {'make': 'Nikon', 'model': 'FM2'},
'iso': 400
}
result = map_frame_to_exif(frame, roll)
assert result['Make'] == 'Nikon'
assert result['Model'] == 'FM2'
assert result['ISO'] == 400
def test_exposure_mapping(self):
"""Mappe les paramètres d'exposition."""
frame = {
'id': 1,
'aperture': 'f/2.8',
'shutter': '1/125',
'focalLength': 50
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['FNumber'] == 2.8
assert result['ExposureTime'] == pytest.approx(0.008, rel=0.01)
assert result['FocalLength'] == 50.0
def test_date_mapping(self):
"""Mappe la date correctement."""
frame = {
'id': 1,
'date': '2024-03-15T14:30:00'
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['DateTimeOriginal'] == '2024:03:15 14:30:00'
assert result['CreateDate'] == '2024:03:15 14:30:00'
def test_gps_mapping(self):
"""Mappe les coordonnées GPS."""
frame = {
'id': 1,
'location': {
'latitude': 48.8584,
'longitude': 2.2945
}
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['GPSLatitude'] == 48.8584
assert result['GPSLatitudeRef'] == 'N'
assert result['GPSLongitude'] == 2.2945
assert result['GPSLongitudeRef'] == 'E'
def test_note_mapping(self):
"""Mappe la note en description."""
frame = {
'id': 1,
'note': 'Belle photo du coucher de soleil'
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['ImageDescription'] == 'Belle photo du coucher de soleil'
assert result['UserComment'] == 'Belle photo du coucher de soleil'
def test_flash_mapping(self):
"""Mappe l'utilisation du flash."""
frame_with_flash = {'id': 1, 'flashUsed': True}
frame_without_flash = {'id': 2, 'flashUsed': False}
roll = {'id': 1, 'frames': []}
result_with = map_frame_to_exif(frame_with_flash, roll)
result_without = map_frame_to_exif(frame_without_flash, roll)
assert result_with['Flash'] == 1
assert result_without['Flash'] == 0
def test_lens_mapping(self):
"""Mappe les informations de l'objectif."""
frame = {'id': 1}
roll = {
'id': 1,
'frames': [frame],
'lens': {'make': 'Nikon', 'model': 'Nikkor 50mm f/1.4'}
}
result = map_frame_to_exif(frame, roll)
assert result['LensMake'] == 'Nikon'
assert result['LensModel'] == 'Nikkor 50mm f/1.4'
def test_film_stock_mapping(self):
"""Mappe les informations du film."""
frame = {'id': 1}
roll = {
'id': 1,
'frames': [frame],
'filmStock': {'make': 'Kodak', 'model': 'Portra 400'}
}
result = map_frame_to_exif(frame, roll)
assert 'Kodak Portra 400' in result['ImageDescription']
def test_empty_frame(self):
"""Gère un frame vide."""
frame = {'id': 1}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['ImageNumber'] == 1
def test_invalid_shutter_logged(self):
"""Les vitesses invalides sont ignorées avec un warning."""
frame = {
'id': 1,
'shutter': 'invalid'
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert 'ExposureTime' not in result

218
tests/test_xmp_writer.py Normal file
View File

@@ -0,0 +1,218 @@
"""Tests pour le module xmp_writer."""
from json_to_metadata.xmp_writer import (
_convert_exif_to_xmp,
_format_rational,
_format_xmp_date,
generate_xmp_content,
write_xmp_sidecar,
)
class TestGenerateXmpContent:
"""Tests pour la fonction generate_xmp_content."""
def test_basic_structure(self):
"""Génère une structure XMP valide."""
content = generate_xmp_content({})
assert '<?xml version="1.0"' in content
assert 'x:xmpmeta' in content
assert 'rdf:RDF' in content
assert 'rdf:Description' in content
def test_includes_namespaces(self):
"""Inclut les namespaces requis."""
content = generate_xmp_content({})
assert 'xmlns:dc=' in content
assert 'xmlns:exif=' in content
assert 'xmlns:tiff=' in content
assert 'xmlns:xmp=' in content
def test_maps_camera_info(self):
"""Mappe les informations de l'appareil."""
tags = {'Make': 'Nikon', 'Model': 'FM2'}
content = generate_xmp_content(tags)
assert '<tiff:Make>Nikon</tiff:Make>' in content
assert '<tiff:Model>FM2</tiff:Model>' in content
def test_maps_exposure(self):
"""Mappe les paramètres d'exposition."""
tags = {'ISO': 400, 'FNumber': 2.8}
content = generate_xmp_content(tags)
assert 'ISOSpeedRatings' in content
assert '400' in content
assert 'FNumber' in content
def test_escapes_special_characters(self):
"""Échappe les caractères spéciaux XML."""
tags = {'ImageDescription': 'Test <avec> des & caractères "spéciaux"'}
content = generate_xmp_content(tags)
assert '&lt;avec&gt;' in content
assert '&amp;' in content
def test_includes_modify_date(self):
"""Inclut la date de modification."""
content = generate_xmp_content({})
assert 'xmp:ModifyDate' in content
class TestWriteXmpSidecar:
"""Tests pour la fonction write_xmp_sidecar."""
def test_creates_xmp_file(self, tmp_path):
"""Crée un fichier XMP à côté de l'image."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
tags = {'Make': 'Nikon'}
xmp_path = write_xmp_sidecar(image, tags)
assert xmp_path.exists()
assert xmp_path.name == 'photo.xmp'
assert xmp_path.parent == tmp_path
def test_xmp_content_valid(self, tmp_path):
"""Le contenu du fichier XMP est valide."""
image = tmp_path / 'photo.jpg'
image.write_bytes(b'fake image')
tags = {'Make': 'Nikon', 'Model': 'FM2'}
xmp_path = write_xmp_sidecar(image, tags)
content = xmp_path.read_text(encoding='utf-8')
assert '<?xml version="1.0"' in content
assert '<tiff:Make>Nikon</tiff:Make>' in content
def test_dry_run_mode(self, tmp_path):
"""Le mode dry-run ne crée pas de fichier."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
xmp_path = write_xmp_sidecar(image, {'Make': 'Nikon'}, dry_run=True)
assert not xmp_path.exists()
assert xmp_path.name == 'photo.xmp'
def test_overwrites_existing_xmp(self, tmp_path):
"""Écrase un fichier XMP existant."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
xmp_path = tmp_path / 'photo.xmp'
xmp_path.write_text('old content')
write_xmp_sidecar(image, {'Make': 'Canon'})
content = xmp_path.read_text()
assert 'Canon' in content
assert 'old content' not in content
def test_handles_different_extensions(self, tmp_path):
"""Fonctionne avec différentes extensions d'images."""
for ext in ['.tif', '.avif', '.heic', '.webp', '.png']:
image = tmp_path / f'photo{ext}'
image.write_bytes(b'fake image')
xmp_path = write_xmp_sidecar(image, {'Make': 'Test'})
assert xmp_path.suffix == '.xmp'
assert xmp_path.stem == 'photo'
xmp_path.unlink() # Nettoyer
class TestConvertExifToXmp:
"""Tests pour la fonction _convert_exif_to_xmp."""
def test_make_mapping(self):
"""Mappe Make vers tiff:Make."""
result = _convert_exif_to_xmp('Make', 'Nikon')
assert '<tiff:Make>Nikon</tiff:Make>' in result
def test_iso_mapping(self):
"""Mappe ISO avec structure rdf:Seq."""
result = _convert_exif_to_xmp('ISO', 400)
assert 'ISOSpeedRatings' in result
assert 'rdf:Seq' in result
assert '400' in result
def test_date_mapping(self):
"""Mappe DateTimeOriginal vers xmp:CreateDate."""
result = _convert_exif_to_xmp('DateTimeOriginal', '2024:03:15 14:30:00')
assert 'xmp:CreateDate' in result
assert '2024-03-15T14:30:00' in result
def test_gps_mapping(self):
"""Mappe les coordonnées GPS."""
lat = _convert_exif_to_xmp('GPSLatitude', 48.8584)
lat_ref = _convert_exif_to_xmp('GPSLatitudeRef', 'N')
assert 'exif:GPSLatitude' in lat
assert '48.8584' in lat
assert 'exif:GPSLatitudeRef' in lat_ref
assert 'N' in lat_ref
def test_description_uses_alt(self):
"""ImageDescription utilise rdf:Alt pour le multilingue."""
result = _convert_exif_to_xmp('ImageDescription', 'Ma photo')
assert 'dc:description' in result
assert 'rdf:Alt' in result
assert 'x-default' in result
def test_unknown_tag_returns_none(self):
"""Les tags inconnus retournent None."""
result = _convert_exif_to_xmp('UnknownTag', 'value')
assert result is None
class TestFormatRational:
"""Tests pour la fonction _format_rational."""
def test_integer_value(self):
"""Formate un entier comme chaîne."""
assert _format_rational(50) == '50'
assert _format_rational(100.0) == '100'
def test_fraction_value(self):
"""Formate une fraction correctement."""
result = _format_rational(0.008) # 1/125
assert '1/125' in result
def test_decimal_passthrough(self):
"""Retourne les décimaux non-fractions tels quels."""
result = _format_rational(2.8)
assert '2.8' in result
class TestFormatXmpDate:
"""Tests pour la fonction _format_xmp_date."""
def test_exif_to_xmp_format(self):
"""Convertit le format EXIF en ISO."""
result = _format_xmp_date('2024:03:15 14:30:00')
assert result == '2024-03-15T14:30:00'
def test_iso_passthrough(self):
"""Laisse passer les dates déjà au format ISO."""
result = _format_xmp_date('2024-03-15T14:30:00')
assert result == '2024-03-15T14:30:00'
def test_date_only(self):
"""Gère les dates sans heure."""
result = _format_xmp_date('2024-03-15')
assert '2024-03-15' in result