149 lines
6.1 KiB
Python
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()
|