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