151 lines
5.6 KiB
Python
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
|