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