118 lines
4.0 KiB
Python
118 lines
4.0 KiB
Python
"""Unit + property tests for the album-evidence aggregation helpers (US03-01):
|
|
conflict/dominant rules, deterministic ordering, location cleaning, and the content
|
|
version's stability and sensitivity.
|
|
"""
|
|
|
|
import random
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
from photo_pipeline.services.albums import (
|
|
_clean,
|
|
_counts,
|
|
_dominant,
|
|
_version,
|
|
album_label,
|
|
)
|
|
|
|
|
|
def test_album_label_is_library_relative_and_keeps_nested_albums_distinct():
|
|
lib = (Path("/lib"),)
|
|
assert album_label("/lib/Urlaub/Rom/a.jpg", lib) == "Urlaub/Rom"
|
|
assert album_label("/lib/Urlaub/Venedig/a.jpg", lib) == "Urlaub/Venedig"
|
|
assert album_label("/lib/a.jpg", lib) == "(root)" # directly in the root
|
|
assert album_label("/elsewhere/x/a.jpg", lib) == "x" # outside → bare parent
|
|
assert album_label("/lib/x/a.jpg", ()) == "x" # no configured root → bare parent
|
|
# Donor parity lives in tests/characterization/test_pa_album.py.
|
|
|
|
|
|
def test_clean_drops_empty_and_literal_null_locations():
|
|
assert _clean(None) is None
|
|
assert _clean("") is None
|
|
assert _clean(" ") is None
|
|
assert _clean("null") is None
|
|
assert _clean("NULL") is None
|
|
assert _clean(" Rome ") == "Rome"
|
|
|
|
|
|
def test_counts_order_by_frequency_then_value():
|
|
counts = _counts(Counter({"beach": 1, "sunset": 3, "sea": 1}))
|
|
assert counts == [
|
|
{"value": "sunset", "count": 3},
|
|
{"value": "beach", "count": 1}, # tie broken alphabetically
|
|
{"value": "sea", "count": 1},
|
|
]
|
|
|
|
|
|
def test_dominant_requires_a_strict_winner():
|
|
assert _dominant(Counter()) == (None, False)
|
|
assert _dominant(Counter({2019: 3})) == (2019, False)
|
|
assert _dominant(Counter({2019: 3, 2020: 1})) == (2019, False)
|
|
# A tie is a conflict: no dominant value, flagged.
|
|
assert _dominant(Counter({2019: 2, 2020: 2})) == (None, True)
|
|
|
|
|
|
def test_version_is_stable_and_row_order_independent():
|
|
a = [("id1", "/f/a.jpg", "sfw", "analyzed", "d", None, 1, None, None, None, None, 2019)]
|
|
b = list(a)
|
|
two = a + [("id2", "/f/b.jpg", "sfw", "analyzed", "e", None, 2, None, None, None, None, 2019)]
|
|
assert _version(a) == _version(b)
|
|
# Reversed input order must not change the version (inputs are sorted).
|
|
assert _version(two) == _version(list(reversed(two)))
|
|
|
|
|
|
def test_version_changes_when_any_input_field_changes():
|
|
base = (
|
|
"id1",
|
|
"/f/a.jpg",
|
|
"sfw",
|
|
"analyzed",
|
|
"desc",
|
|
None,
|
|
1,
|
|
"outdoor",
|
|
None,
|
|
None,
|
|
None,
|
|
2019,
|
|
)
|
|
baseline = _version([base])
|
|
# Flip each field in turn; every change must move the hash.
|
|
for index in range(len(base)):
|
|
changed = list(base)
|
|
current = changed[index]
|
|
changed[index] = 99 if isinstance(current, int) else "CHANGED"
|
|
assert _version([tuple(changed)]) != baseline, f"field {index} did not affect the version"
|
|
|
|
|
|
def test_property_dominant_matches_a_brute_force_definition():
|
|
rng = random.Random(1234)
|
|
for _ in range(500):
|
|
counter = Counter()
|
|
for _ in range(rng.randint(0, 6)):
|
|
counter[rng.randint(2000, 2005)] += 1
|
|
value, conflict = _dominant(counter)
|
|
if not counter:
|
|
assert value is None and conflict is False
|
|
continue
|
|
top = max(counter.values())
|
|
winners = [k for k, v in counter.items() if v == top]
|
|
if len(winners) == 1:
|
|
assert value == winners[0] and conflict is False
|
|
else:
|
|
assert value is None and conflict is True
|
|
|
|
|
|
def test_property_counts_is_a_permutation_sorted_by_frequency():
|
|
rng = random.Random(99)
|
|
for _ in range(300):
|
|
counter = Counter()
|
|
for _ in range(rng.randint(0, 20)):
|
|
counter[rng.choice("abcde")] += 1
|
|
counts = _counts(counter)
|
|
# Same multiset of (value, count) pairs as the counter.
|
|
assert {(c["value"], c["count"]) for c in counts} == set(counter.items())
|
|
# Non-increasing frequency.
|
|
freqs = [c["count"] for c in counts]
|
|
assert freqs == sorted(freqs, reverse=True)
|