Files
media-batch-tools/unit_tests/test_photo_metadata_logic.py
T

149 lines
6.1 KiB
Python

from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.photo_grouping import detect_photo_groups, infer_group_subseconds
from tools.photo_planning import (
UserChoices,
build_write_plan,
filename_timestamp_stem,
infer_missing_timestamps,
merge_record_groups,
)
from tools.photo_records import (
MediaRecord,
infer_video_timezone,
)
from tools.photo_settings import default_artist
class PhotoMetadataLogicTests(unittest.TestCase):
def choices(self, **values):
base = dict(
working_dir=Path("."),
time_offset=None,
fixed_timezone=None,
fill_timezone_gaps=False,
artist_action="leave",
artist_value=None,
rename_mode="adjust_replace",
organize_files=False,
group_min_size=2,
process_pto_files=False,
infer_missing_timestamps=True,
)
base.update(values)
return UserChoices(**base)
def test_adjusted_filename_preserves_each_timestamp_interval(self) -> None:
stem = "20260516_130011-20260516_130102_fused"
result = filename_timestamp_stem(stem, datetime(2026, 6, 7, 14, 0, 0), "adjust_replace")
self.assertEqual(result, "20260607_140000-20260607_140051_fused")
def test_uses_one_existing_artist_as_the_disabled_default(self) -> None:
records = [
MediaRecord(Path("a.jpg"), {"EXIF:Artist": "Anton"}, "image"),
MediaRecord(Path("b.jpg"), {"XMP:Author": "Anton"}, "image"),
]
self.assertEqual(default_artist(records), "Anton")
def test_infers_missing_photo_time_from_filename_before_modify_date(self) -> None:
record = MediaRecord(
Path("20260607_140011.jpg"),
{"EXIF:ModifyDate": "2020:01:01 00:00:00"},
"image",
)
infer_missing_timestamps([record], True)
self.assertEqual(record.original_time, datetime(2026, 6, 7, 14, 0, 11))
self.assertEqual(record.inferred_timestamp_source, "filename")
def test_infers_missing_photo_time_from_modify_date(self) -> None:
record = MediaRecord(
Path("DSC0001.jpg"),
{"EXIF:ModifyDate": "2026:06:07 14:00:11"},
"image",
)
infer_missing_timestamps([record], True)
self.assertEqual(record.original_time, datetime(2026, 6, 7, 14, 0, 11))
self.assertEqual(record.inferred_timestamp_source, "ModifyDate")
def test_infers_video_timezone_from_filename_and_utc_end_time(self) -> None:
record = MediaRecord(
Path("20260704_161323.mp4"),
{},
"video",
original_time=datetime(2026, 7, 4, 14, 13, 36, tzinfo=timezone.utc),
duration_seconds=13,
)
self.assertEqual(infer_video_timezone(record), timezone(timedelta(hours=2)))
def test_existing_subseconds_prevent_burst_inference(self) -> None:
records = [
MediaRecord(Path("a.jpg"), {}, "image", adjusted_time=datetime(2026, 6, 7, 14, 0, 0), subsec=100),
MediaRecord(Path("b.jpg"), {}, "image", adjusted_time=datetime(2026, 6, 7, 14, 0, 0)),
]
infer_group_subseconds([records])
self.assertIsNone(records[1].inferred_subsec)
def test_burst_detection_stays_per_camera(self) -> None:
timestamp = datetime(2026, 6, 7, 14, 0, 0)
records = [
MediaRecord(Path("a.jpg"), {}, "image", adjusted_time=timestamp, camera_key="A"),
MediaRecord(Path("b.jpg"), {}, "image", adjusted_time=timestamp, camera_key="A"),
MediaRecord(Path("c.jpg"), {}, "image", adjusted_time=timestamp, camera_key="B"),
]
self.assertEqual([[record.path.name for record in group] for group in detect_photo_groups(records)], [["a.jpg", "b.jpg"]])
def test_single_file_pto_group_is_preserved(self) -> None:
record = MediaRecord(Path("a.jpg"), {}, "image")
self.assertEqual(merge_record_groups([record], [[record]]), [[record]])
def test_shift_keeps_later_modify_date_but_clamps_an_earlier_one(self) -> None:
original = datetime(2026, 6, 7, 14, 0, 0)
record = MediaRecord(
Path("photo.jpg"),
{
"EXIF:DateTimeOriginal": "2026:06:07 14:00:00",
"EXIF:CreateDate": "2026:06:07 14:00:00",
"EXIF:ModifyDate": "2026:06:07 13:00:00",
},
"image",
original_time=original,
adjusted_time=original + timedelta(hours=2),
)
writes = build_write_plan(record, self.choices(time_offset=timedelta(hours=2)))
changed = {write.label: write.new_value for write in writes if write.will_write}
self.assertEqual(changed["DateTimeOriginal"], "2026:06:07 16:00:00")
self.assertEqual(changed["CreateDate"], "2026:06:07 16:00:00")
self.assertEqual(changed["ModifyDate"], "2026:06:07 16:00:00")
self.assertTrue(any("ModifyDate" in warning for warning in record.warnings))
def test_timezone_only_follows_capture_related_dates(self) -> None:
captured = datetime(2026, 6, 7, 14, 0, 0)
record = MediaRecord(
Path("photo.jpg"),
{
"EXIF:DateTimeOriginal": "2026:06:07 14:00:00",
"EXIF:CreateDate": "2026:06:07 14:00:00",
"EXIF:ModifyDate": "2026:07:07 14:00:00",
"EXIF:OffsetTimeOriginal": "+00:00",
"EXIF:OffsetTimeDigitized": "+00:00",
"EXIF:OffsetTime": "+00:00",
},
"image",
original_time=captured,
adjusted_time=captured,
resolved_timezone=timezone(timedelta(hours=2)),
)
writes = build_write_plan(record, self.choices(fixed_timezone=record.resolved_timezone))
changed = {write.arg for write in writes if write.will_write}
self.assertIn("-EXIF:OffsetTimeOriginal=+02:00", changed)
self.assertIn("-EXIF:OffsetTimeDigitized=+02:00", changed)
self.assertNotIn("-EXIF:OffsetTime=+02:00", changed)
if __name__ == "__main__":
unittest.main()