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:
327
tests/test_metadata_mapper.py
Normal file
327
tests/test_metadata_mapper.py
Normal 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
|
||||
Reference in New Issue
Block a user