Files
wehub-resource-sync 1d6fd29cda
tests / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:27 +08:00

283 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
tests/test_photo_analyzer.py
pytest 测试套件 · photo_analyzer.py
"""
import math
import pytest
from datetime import datetime
from pathlib import Path
from tools.photo_analyzer import (
get_datetime,
_dms_to_decimal,
get_gps,
get_make_model,
detect_meetups,
_gps_distance_km,
)
# PIL may not be installed in test env; skip GPS tests that need GPSTAGS
try:
from PIL.ExifTags import GPSTAGS
PIL_AVAILABLE = True
except Exception:
PIL_AVAILABLE = False
# ─────────────────────────────────────────────
# 纯函数
# ─────────────────────────────────────────────
class TestDmsToDecimal:
def test_positive_north_east(self) -> None:
result = _dms_to_decimal((40, 30, 15), "N")
assert 40.5 < result < 41
def test_positive_south_west(self) -> None:
result = _dms_to_decimal((40, 30, 15), "S")
assert result < 0
def test_west_negative(self) -> None:
result = _dms_to_decimal((10, 0, 0), "W")
assert result < 0
def test_invalid_input_returns_zero(self) -> None:
result = _dms_to_decimal(("bad", None, 0), "N")
assert result == 0.0
def test_rounds_to_6_decimals(self) -> None:
result = _dms_to_decimal((40, 30, 15.123456), "N")
decimal_part = str(result).split(".")[-1]
assert len(decimal_part) <= 6
class TestGpsDistanceKm:
def test_same_point_zero(self) -> None:
a = {"lat": 31.23, "lon": 121.47}
assert _gps_distance_km(a, a) == 0.0
def test_known_distance(self) -> None:
# Shanghai to Hangzhou ~160km
sh = {"lat": 31.2304, "lon": 121.4737}
hz = {"lat": 30.2741, "lon": 120.1551}
dist = _gps_distance_km(sh, hz)
assert 150 < dist < 170
def test_symmetric(self) -> None:
a = {"lat": 31.0, "lon": 121.0}
b = {"lat": 32.0, "lon": 122.0}
assert abs(_gps_distance_km(a, b) - _gps_distance_km(b, a)) < 0.001
class TestGetDatetime:
def test_date_time_original(self) -> None:
# 注意:EXIF 日期格式是 YYYY:MM:DD HH:MM:SS(用冒号分隔)
exif = {"DateTimeOriginal": "2024:03:15 14:30:00"}
result = get_datetime(exif)
assert result == datetime(2024, 3, 15, 14, 30, 0)
def test_date_time_fallback(self) -> None:
# DateTime 格式不同,strptime 会失败,函数应该 fallback
exif = {"DateTime": "2024-05-01 08:00:00"}
result = get_datetime(exif)
assert result is None # wrong format, falls through
def test_no_time_in_exif(self) -> None:
assert get_datetime({}) is None
assert get_datetime({"Make": "Canon"}) is None
def test_invalid_format_falls_through(self) -> None:
exif = {"DateTimeOriginal": "not a date"}
assert get_datetime(exif) is None
class TestGetGps:
@pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL not installed")
def test_full_gps_data(self) -> None:
gps_raw = {
1: "N",
2: (31, 12, 36),
3: "E",
4: (121, 30, 0),
}
exif = {"GPSInfo": gps_raw}
result = get_gps(exif)
assert result is not None
assert 30 < result["lat"] < 32
assert 121 < result["lon"] < 122
@pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL not installed")
def test_no_gps_info(self) -> None:
assert get_gps({}) is None
@pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL not installed")
def test_only_latitude(self) -> None:
exif = {"GPSInfo": {1: "N", 2: (31, 12, 36)}}
assert get_gps(exif) is None # needs both lat and lon
class TestGetMakeModel:
def test_both_present(self) -> None:
exif = {"Make": "Apple", "Model": "iPhone 15"}
assert get_make_model(exif) == "Apple iPhone 15"
def test_model_only(self) -> None:
exif = {"Model": "Canon EOS R5"}
assert get_make_model(exif) == "Canon EOS R5"
def test_make_only(self) -> None:
exif = {"Make": "Samsung"}
assert get_make_model(exif) == "Samsung"
def test_empty_exif(self) -> None:
assert get_make_model({}) == ""
def test_whitespace_stripped(self) -> None:
exif = {"Make": " Sony ", "Model": " α7 "}
assert " " not in get_make_model(exif)
# ─────────────────────────────────────────────
# detect_meetups · 核心逻辑
# ─────────────────────────────────────────────
def photo(datetime_str: str, lat: float = None, lon: float = None) -> dict:
dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M")
p = {"datetime": dt, "gps": None}
if lat is not None and lon is not None:
p["gps"] = {"lat": lat, "lon": lon}
return p
class TestDetectMeetups:
def test_empty_list(self) -> None:
assert detect_meetups([]) == []
def test_single_photo(self) -> None:
assert detect_meetups([photo("2024-01-01 10:00")]) == []
def test_two_photos_same_time_and_place(self) -> None:
photos = [
photo("2024-01-01 10:00", 31.23, 121.47),
photo("2024-01-01 10:30", 31.23, 121.47),
]
meetups = detect_meetups(photos)
assert len(meetups) == 1
assert meetups[0]["photo_count"] == 2
def test_different_times_no_meetup(self) -> None:
photos = [
photo("2024-01-01 10:00", 31.23, 121.47),
photo("2024-01-01 16:00", 31.23, 121.47),
]
meetups = detect_meetups(photos, time_gap_hours=4.0)
assert meetups == []
def test_far_apart_location_no_meetup(self) -> None:
photos = [
photo("2024-01-01 10:00", 31.23, 121.47),
photo("2024-01-01 10:30", 29.55, 121.72), # ~190km away
]
meetups = detect_meetups(photos, location_radius_km=2.0)
assert meetups == []
def test_no_gps_uses_time_only(self) -> None:
photos = [
photo("2024-01-01 10:00"),
photo("2024-01-01 10:30"),
]
meetups = detect_meetups(photos, time_gap_hours=4.0)
assert len(meetups) == 1
def test_custom_time_gap(self) -> None:
photos = [
photo("2024-01-01 10:00"),
photo("2024-01-01 14:00"),
]
assert len(detect_meetups(photos, time_gap_hours=4.0)) == 1
assert len(detect_meetups(photos, time_gap_hours=2.0)) == 0
def test_multiple_meetups(self) -> None:
photos = [
photo("2024-01-01 10:00"),
photo("2024-01-01 10:30"),
photo("2024-01-03 14:00"),
photo("2024-01-03 15:00"),
]
meetups = detect_meetups(photos)
assert len(meetups) == 2
def test_photos_without_datetime_ignored(self) -> None:
photos = [
photo("2024-01-01 10:00"),
{"datetime": None},
photo("2024-01-01 10:30"),
]
meetups = detect_meetups(photos)
assert len(meetups) == 1
def test_meetup_has_required_fields(self) -> None:
photos = [
photo("2024-01-01 10:00", 31.23, 121.47),
photo("2024-01-01 11:00", 31.23, 121.47),
]
m = detect_meetups(photos)[0]
assert "date" in m
assert "start" in m
assert "end" in m
assert "duration_min" in m
assert "photo_count" in m
assert m["photo_count"] == 2
def test_meetup_sorted_by_date(self) -> None:
photos = [
photo("2024-02-01 10:00"),
photo("2024-02-01 11:00"),
photo("2024-01-01 10:00"),
photo("2024-01-01 11:00"),
]
meetups = detect_meetups(photos)
assert meetups[0]["date"].startswith("2024-01-01")
# ─────────────────────────────────────────────
# scan_photos · 目录扫描
# ─────────────────────────────────────────────
class TestScanPhotos:
def test_nonexistent_dir(self) -> None:
from tools.photo_analyzer import scan_photos
result = scan_photos("/no/such/dir")
assert result == []
# ─────────────────────────────────────────────
# generate_report · 报告生成
# ─────────────────────────────────────────────
class TestGenerateReport:
def test_no_pillow_returns_warning(self) -> None:
from unittest.mock import patch
from tools.photo_analyzer import generate_report, PIL_AVAILABLE
with patch("tools.photo_analyzer.PIL_AVAILABLE", False):
result = generate_report("/any/path", "小美")
assert "Pillow" in result or "⚠️" in result
def test_empty_directory_returns_no_photos_message(self, tmp_path: Path) -> None:
from unittest.mock import patch
from tools.photo_analyzer import generate_report
with patch("tools.photo_analyzer.PIL_AVAILABLE", True):
with patch("tools.photo_analyzer.scan_photos", return_value=[]):
result = generate_report(str(tmp_path), "小美")
assert "照片元数据分析报告" in result
assert "未找到任何照片" in result
def test_output_path_writes_file(self, tmp_path: Path) -> None:
from unittest.mock import patch
from tools.photo_analyzer import generate_report
out = tmp_path / "report.md"
with patch("tools.photo_analyzer.PIL_AVAILABLE", True):
with patch("tools.photo_analyzer.scan_photos", return_value=[]):
generate_report(str(tmp_path), "小美", str(out))
assert out.exists()