项目文件夹

文件
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 行
10 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""
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()