"""Golden + property tests for the album naming policy (US03-02): template rendering and fallbacks, evidence projection, sanitisation/normalisation, reserved names, collisions, and the invariant that a sanitised name can never escape a root. """ import random import unicodedata from pathlib import Path from photo_pipeline.services.naming import ( FORBIDDEN, NamingPolicy, build_fields, date_field, render_name, sanitize, validate, ) SEP = NamingPolicy().separator def _codes(result): return {issue["code"] for issue in result.issues} # ── rendering + fallbacks ──────────────────────────────────────────────────── def test_full_template_renders_date_place_event(): fields = {"date": "2019-07", "place": "Rome", "event": "Summer Holiday"} assert render_name(fields) == SEP.join(["2019-07", "Rome", "Summer Holiday"]) def test_template_fallbacks_drop_missing_fields_in_priority_order(): assert render_name({"place": "Rome", "event": "Summer Holiday"}) == SEP.join( ["Rome", "Summer Holiday"] ) assert render_name({"date": "2016", "event": "Team Event"}) == SEP.join(["2016", "Team Event"]) assert render_name({"date": "2019", "place": "Rome"}) == SEP.join(["2019", "Rome"]) assert render_name({"event": "Family Portraits"}) == "Family Portraits" assert render_name({"place": "Rome"}) == "Rome" assert render_name({}) == "" def test_date_field_combines_year_and_month_iso(): assert date_field(2019, 7) == "2019-07" assert date_field(2019, None) == "2019" assert date_field(None, 7) is None def test_build_fields_projects_evidence_and_renders(): evidence = { "dominant_year": 2019, "locations": [{"value": "Rome", "count": 3}, {"value": "Napoli", "count": 1}], "folder_name": "trip", } fields = build_fields(evidence) assert fields["date"] == "2019" and fields["place"] == "Rome" and fields["event"] == "trip" assert render_name(fields) == SEP.join(["2019", "Rome", "trip"]) # ── golden validation cases ────────────────────────────────────────────────── def test_valid_name_passes_unchanged(): result = validate("2019-07 — Rome — Summer Holiday") assert result.valid and result.name == "2019-07 — Rome — Summer Holiday" assert result.issues == [] def test_normalization_collapses_whitespace_and_strips_forbidden(): result = validate(" 2019 Rome/Trip ") assert result.name == "2019 Rome Trip" # slash → space, collapsed, trimmed assert "removed_forbidden_characters" in _codes(result) assert "normalized_whitespace" in _codes(result) assert result.valid def test_unicode_is_nfc_normalized_keeping_umlauts(): decomposed = unicodedata.normalize("NFD", "Café") # e + combining acute result = validate(decomposed) assert result.name == "Café" and "normalized_unicode" in _codes(result) def test_reserved_name_is_rejected_with_a_safe_suggestion(): result = validate("con") assert not result.valid and "reserved_name" in _codes(result) assert result.suggestions == ["con (album)"] def test_collision_is_flagged_and_disambiguated(): result = validate("2019 Rome", existing={"2019 rome"}) # case-insensitive assert not result.valid and "collision" in _codes(result) assert result.suggestions == ["2019 Rome (2)"] def test_disambiguation_skips_multiple_taken_variants(): result = validate("Rome", existing={"rome", "rome (2)", "rome (3)"}) assert result.suggestions == ["Rome (4)"] def test_empty_after_sanitization_falls_back(): result = validate("///:::") assert not result.valid and "empty" in _codes(result) assert result.name == "" and result.suggestions == ["(untitled)"] def test_overlong_name_is_truncated_to_the_limit(): policy = NamingPolicy(max_length=20) result = validate("A" * 50, policy=policy) assert len(result.name) <= 20 and "truncated" in _codes(result) # ── property: sanitisation keeps names safe and inside the root ────────────── _ALPHABET = 'abcÄ Rome/..\\:*?"<>|.\t\n\x00 é2019—()' def test_property_sanitized_names_are_filesystem_safe_and_cannot_escape_root(tmp_path): rng = random.Random(20260815) root = tmp_path.resolve() policy = NamingPolicy() for _ in range(2000): raw = "".join(rng.choice(_ALPHABET) for _ in range(rng.randint(0, 40))) name, _issues = sanitize(raw, policy) assert not (set(name) & FORBIDDEN), f"forbidden char survived: {name!r}" assert name == name.strip() and not name.endswith("."), name assert len(name) <= policy.max_length if not name: continue assert ".." not in Path(name).parts and "/" not in name and "\\" not in name # Joining to a root can never escape it. resolved = (root / name).resolve() assert resolved == root or root in resolved.parents, resolved def test_property_valid_names_never_collide_with_existing(tmp_path): rng = random.Random(7) policy = NamingPolicy() for _ in range(500): existing = {f"album {i}" for i in range(rng.randint(0, 5))} raw = rng.choice(["album 0", "Album 1", "Rome", "2019 Trip", "album 3"]) result = validate(raw, existing=existing, policy=policy) if result.valid: assert result.name.casefold() not in {e.casefold() for e in existing} else: # An invalid result always offers at least one suggestion to move forward. assert result.suggestions