Files
photoanalyzer/tests/unit/test_naming_policy.py

151 lines
5.6 KiB
Python

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