US03-01: Aggregate Album Evidence
This commit is contained in:
@@ -336,11 +336,16 @@ rows:
|
|||||||
Album = leaf folder relative to library, '(root)' at root, bare parent
|
Album = leaf folder relative to library, '(root)' at root, bare parent
|
||||||
outside the library. The single album-identity rule for stats, proposals
|
outside the library. The single album-identity rule for stats, proposals
|
||||||
and folder-as-album upload. webapp.query.album_of is a copy — consolidate
|
and folder-as-album upload. webapp.query.album_of is a copy — consolidate
|
||||||
to one implementation (see wa-album-of).
|
to one implementation (see wa-album-of). Extracted in US03-01 as
|
||||||
|
photo_pipeline.services.albums.album_label (parity test below); the album
|
||||||
|
evidence aggregator uses it. LibraryService._album_of still keeps its own
|
||||||
|
leaf-name grouping — fold it into this rule when the Library view is next
|
||||||
|
touched.
|
||||||
target: photo_pipeline/services/albums.py
|
target: photo_pipeline/services/albums.py
|
||||||
tests:
|
tests:
|
||||||
- test_pa_album::test_album_label_leaf_folder_relative
|
- test_pa_album::test_album_label_leaf_folder_relative
|
||||||
- test_pa_album::test_webapp_album_of_mirrors_album_label
|
- test_pa_album::test_webapp_album_of_mirrors_album_label
|
||||||
|
- test_pa_album::test_albums_service_album_label_matches_donor
|
||||||
status: characterized
|
status: characterized
|
||||||
|
|
||||||
- id: pa-config
|
- id: pa-config
|
||||||
@@ -546,9 +551,12 @@ rows:
|
|||||||
classification: replace
|
classification: replace
|
||||||
rationale: >
|
rationale: >
|
||||||
Verbatim copy of photo_analyzer.album_label (equivalence characterized);
|
Verbatim copy of photo_analyzer.album_label (equivalence characterized);
|
||||||
consolidated into the single albums-service implementation (pa-album-label).
|
consolidated into the single albums-service implementation (pa-album-label),
|
||||||
|
now photo_pipeline.services.albums.album_label (US03-01, parity test below).
|
||||||
target: photo_pipeline/services/albums.py
|
target: photo_pipeline/services/albums.py
|
||||||
tests: [test_pa_album::test_webapp_album_of_mirrors_album_label]
|
tests:
|
||||||
|
- test_pa_album::test_webapp_album_of_mirrors_album_label
|
||||||
|
- test_pa_album::test_albums_service_album_label_matches_donor
|
||||||
status: characterized
|
status: characterized
|
||||||
|
|
||||||
- id: wa-allowlist
|
- id: wa-allowlist
|
||||||
|
|||||||
265
photo_pipeline/services/albums.py
Normal file
265
photo_pipeline/services/albums.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""AlbumService — aggregate per-folder evidence for album proposals (US03-01).
|
||||||
|
|
||||||
|
Read-only. For each leaf folder it summarises the *already stored* analysis and
|
||||||
|
safety evidence of the canonical, active assets living there, so a later stage can
|
||||||
|
name the folder without resending images or exposing raw model responses. It writes
|
||||||
|
nothing and generates no names (that is US03-02/03).
|
||||||
|
|
||||||
|
Folder identity is the full parent path of an asset's ``current_path`` — not just
|
||||||
|
the leaf name — so two folders that share a leaf never merge. Aggregation is
|
||||||
|
deterministic (everything sorted, no clock/random) and each folder's evidence
|
||||||
|
carries a content ``version`` derived only from the inputs that could change it, so
|
||||||
|
any relevant asset change (new analysis, decision change, move, or becoming a
|
||||||
|
duplicate) invalidates exactly that folder's version and nothing else.
|
||||||
|
|
||||||
|
Documented evidence rules (acceptance criterion 2):
|
||||||
|
|
||||||
|
- **Duplicate (non-canonical) assets** — excluded entirely; the canonical carries
|
||||||
|
the evidence.
|
||||||
|
- **Missing / archived assets** — excluded: only ``availability_state == 'active'``
|
||||||
|
assets with a ``current_path`` belong to an active folder.
|
||||||
|
- **NSFW assets** — counted in the folder total and reported as ``nsfw_count``, but
|
||||||
|
contribute NO analysis-derived evidence (tags, description, setting, location,
|
||||||
|
year, people). They are never analysed, and their content must not shape a name
|
||||||
|
beyond mere presence.
|
||||||
|
- **Undecided / deferred / not-yet-analysed assets** — counted in the folder total
|
||||||
|
and reported as ``pending_count``; they contribute no evidence until confirmed
|
||||||
|
SFW and analysed.
|
||||||
|
- **Only confirmed-SFW analysed assets** shape the descriptive evidence.
|
||||||
|
- **Missing fields** (null/empty) simply do not contribute.
|
||||||
|
- **Conflicting fields** (e.g. several ``approx_year`` values) are never silently
|
||||||
|
collapsed: the full sorted distribution is returned, plus a ``dominant_year`` that
|
||||||
|
is set only when one value strictly outnumbers the rest; ties set it to ``None``
|
||||||
|
and raise ``year_conflict``.
|
||||||
|
|
||||||
|
Reuses ``_tags`` from :mod:`photo_pipeline.services.library` (the shared JSON-tag
|
||||||
|
decode). ponytail: neighbouring-folder names/date ranges and user naming
|
||||||
|
conventions (concept §7 inputs) are proposal-generation concerns — add them in
|
||||||
|
US03-03 when a name is actually produced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
|
||||||
|
from photo_pipeline.services.library import _tags
|
||||||
|
|
||||||
|
SFW = "sfw"
|
||||||
|
NSFW = "nsfw"
|
||||||
|
ANALYZED = "analyzed"
|
||||||
|
# Cap the descriptions list so a huge folder can't produce an unbounded payload;
|
||||||
|
# tags/facets are already bounded by their distinct-value count.
|
||||||
|
MAX_DESCRIPTIONS = 50
|
||||||
|
|
||||||
|
|
||||||
|
def album_label(path: str, library_roots: tuple[Path, ...] = ()) -> str:
|
||||||
|
"""The canonical album identity: the leaf folder directly containing ``path``,
|
||||||
|
shown as a path relative to its library root so nested albums stay distinct
|
||||||
|
(``Urlaub/Rom`` vs ``Urlaub/Venedig``); ``(root)`` for a photo directly in a
|
||||||
|
root, and the bare parent name for a photo outside every configured root.
|
||||||
|
|
||||||
|
Ported from ``photo_analyzer.album_label`` (donor_ledger.yaml: pa-album-label);
|
||||||
|
``webapp/query.album_of`` was a verbatim copy (wa-album-of). This is the single
|
||||||
|
implementation for stats, proposals, and folder-as-album upload.
|
||||||
|
"""
|
||||||
|
parent = Path(path).parent
|
||||||
|
for root in library_roots:
|
||||||
|
try:
|
||||||
|
rel = parent.relative_to(root)
|
||||||
|
return "(root)" if str(rel) == "." else str(rel)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return parent.name or str(parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _counts(counter: Counter) -> list[dict]:
|
||||||
|
"""Deterministic ``[{value, count}]``, most frequent first, ties broken by value."""
|
||||||
|
return [
|
||||||
|
{"value": value, "count": count}
|
||||||
|
for value, count in sorted(counter.items(), key=lambda kv: (-kv[1], str(kv[0])))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant(counter: Counter):
|
||||||
|
"""The single strictly-most-frequent value, or ``(None, conflict)``. ``conflict``
|
||||||
|
is true when more than one distinct value is present and no strict winner exists."""
|
||||||
|
if not counter:
|
||||||
|
return None, False
|
||||||
|
ranked = counter.most_common()
|
||||||
|
if len(ranked) == 1:
|
||||||
|
return ranked[0][0], False
|
||||||
|
top_value, top_count = ranked[0]
|
||||||
|
if top_count > ranked[1][1]:
|
||||||
|
return top_value, False
|
||||||
|
return None, True
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumService:
|
||||||
|
def __init__(
|
||||||
|
self, session_factory: sessionmaker, *, library_roots: tuple[Path, ...] = ()
|
||||||
|
) -> None:
|
||||||
|
self._session_factory = session_factory
|
||||||
|
self._library_roots = tuple(Path(root) for root in library_roots)
|
||||||
|
|
||||||
|
def _album_of(self, path: str) -> str:
|
||||||
|
return album_label(path, self._library_roots)
|
||||||
|
|
||||||
|
def _latest_decisions(self, session) -> dict[str, str | None]:
|
||||||
|
"""asset_id -> latest safety decision (``sfw``/``nsfw``/``deferred``/None)."""
|
||||||
|
latest: dict[str, str | None] = {}
|
||||||
|
for review in session.scalars(select(SafetyReview).order_by(SafetyReview.created_at)):
|
||||||
|
latest[review.asset_id] = review.decision
|
||||||
|
return latest
|
||||||
|
|
||||||
|
def folder_count(self) -> int:
|
||||||
|
"""Number of distinct active-canonical folders (for paging callers)."""
|
||||||
|
with self._session_factory() as session:
|
||||||
|
paths = session.scalars(
|
||||||
|
select(Asset.current_path).where(
|
||||||
|
Asset.canonical_asset_id.is_(None),
|
||||||
|
Asset.availability_state == "active",
|
||||||
|
Asset.current_path.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return len({self._album_of(p) for p in paths})
|
||||||
|
|
||||||
|
def aggregate_evidence(self, *, offset: int = 0, limit: int | None = None) -> dict:
|
||||||
|
"""Return a page of per-folder evidence, folders sorted by path.
|
||||||
|
|
||||||
|
``{folders: [...], total: int, offset, limit}``. ``limit=None`` returns all.
|
||||||
|
"""
|
||||||
|
with self._session_factory() as session:
|
||||||
|
decisions = self._latest_decisions(session)
|
||||||
|
rows = list(
|
||||||
|
session.execute(
|
||||||
|
select(Asset.id, Asset.current_path, AnalysisResult)
|
||||||
|
.outerjoin(AnalysisResult, AnalysisResult.asset_id == Asset.id)
|
||||||
|
.where(
|
||||||
|
Asset.canonical_asset_id.is_(None),
|
||||||
|
Asset.availability_state == "active",
|
||||||
|
Asset.current_path.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(Asset.current_path, Asset.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
folders: dict[str, list] = {}
|
||||||
|
for asset_id, path, result in rows:
|
||||||
|
folders.setdefault(self._album_of(path), []).append((asset_id, path, result))
|
||||||
|
|
||||||
|
ordered = sorted(folders)
|
||||||
|
total = len(ordered)
|
||||||
|
page = ordered[offset:] if limit is None else ordered[offset : offset + limit]
|
||||||
|
evidence = [self._folder_evidence(folder, folders[folder], decisions) for folder in page]
|
||||||
|
return {"folders": evidence, "total": total, "offset": offset, "limit": limit}
|
||||||
|
|
||||||
|
def _folder_evidence(self, folder: str, members: list, decisions: dict) -> dict:
|
||||||
|
descriptions: list[str] = []
|
||||||
|
tags: Counter = Counter()
|
||||||
|
settings: Counter = Counter()
|
||||||
|
time_of_day: Counter = Counter()
|
||||||
|
seasons: Counter = Counter()
|
||||||
|
locations: Counter = Counter()
|
||||||
|
people: Counter = Counter()
|
||||||
|
years: Counter = Counter()
|
||||||
|
|
||||||
|
analyzed_count = nsfw_count = pending_count = 0
|
||||||
|
fingerprint: list[tuple] = []
|
||||||
|
|
||||||
|
for asset_id, path, result in members:
|
||||||
|
decision = decisions.get(asset_id)
|
||||||
|
status = result.status if result is not None else None
|
||||||
|
# Fingerprint: every input that could change this folder's evidence.
|
||||||
|
fingerprint.append(
|
||||||
|
(
|
||||||
|
asset_id,
|
||||||
|
path,
|
||||||
|
decision,
|
||||||
|
status,
|
||||||
|
result.description if result is not None else None,
|
||||||
|
result.tags if result is not None else None,
|
||||||
|
result.people_count if result is not None else None,
|
||||||
|
result.setting if result is not None else None,
|
||||||
|
result.time_of_day if result is not None else None,
|
||||||
|
result.season if result is not None else None,
|
||||||
|
_clean(result.location_hint) if result is not None else None,
|
||||||
|
result.approx_year if result is not None else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision == NSFW:
|
||||||
|
nsfw_count += 1
|
||||||
|
continue
|
||||||
|
eligible = decision == SFW and status == ANALYZED and result is not None
|
||||||
|
if not eligible:
|
||||||
|
pending_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
analyzed_count += 1
|
||||||
|
if result.description and result.description.strip():
|
||||||
|
descriptions.append(result.description.strip())
|
||||||
|
for tag in _tags(result.tags):
|
||||||
|
tags[tag] += 1
|
||||||
|
if result.setting:
|
||||||
|
settings[result.setting] += 1
|
||||||
|
if result.time_of_day:
|
||||||
|
time_of_day[result.time_of_day] += 1
|
||||||
|
if result.season:
|
||||||
|
seasons[result.season] += 1
|
||||||
|
location = _clean(result.location_hint)
|
||||||
|
if location:
|
||||||
|
locations[location] += 1
|
||||||
|
if result.people_count is not None:
|
||||||
|
people["3+" if result.people_count >= 3 else str(result.people_count)] += 1
|
||||||
|
if result.approx_year is not None:
|
||||||
|
years[result.approx_year] += 1
|
||||||
|
|
||||||
|
dominant_year, year_conflict = _dominant(years)
|
||||||
|
# ``folder`` is the album label (identity): a library-relative path like
|
||||||
|
# ``Urlaub/Rom``, or ``(root)``. Name is its leaf; parent is the prefix.
|
||||||
|
parent = folder.rsplit("/", 1)[0] if "/" in folder else ""
|
||||||
|
name = folder.rsplit("/", 1)[-1]
|
||||||
|
return {
|
||||||
|
"album": folder,
|
||||||
|
"folder_name": name,
|
||||||
|
"parent_path": parent,
|
||||||
|
"asset_count": len(members),
|
||||||
|
"analyzed_count": analyzed_count,
|
||||||
|
"nsfw_count": nsfw_count,
|
||||||
|
"pending_count": pending_count,
|
||||||
|
"descriptions": sorted(descriptions)[:MAX_DESCRIPTIONS],
|
||||||
|
"tags": _counts(tags),
|
||||||
|
"settings": _counts(settings),
|
||||||
|
"time_of_day": _counts(time_of_day),
|
||||||
|
"seasons": _counts(seasons),
|
||||||
|
"locations": _counts(locations),
|
||||||
|
"people_counts": _counts(people),
|
||||||
|
"years": _counts(years),
|
||||||
|
"dominant_year": dominant_year,
|
||||||
|
"year_conflict": year_conflict,
|
||||||
|
"version": _version(fingerprint),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(location: str | None) -> str | None:
|
||||||
|
"""Drop empty/``null`` location hints (the model emits the string ``"null"``)."""
|
||||||
|
if not location:
|
||||||
|
return None
|
||||||
|
trimmed = location.strip()
|
||||||
|
if not trimmed or trimmed.lower() == "null":
|
||||||
|
return None
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _version(fingerprint: list[tuple]) -> str:
|
||||||
|
"""Deterministic content hash of a folder's evidence inputs. Independent of row
|
||||||
|
order (the inputs are sorted) so only a real change moves it."""
|
||||||
|
payload = json.dumps(sorted(fingerprint, key=lambda t: t[0]), ensure_ascii=False)
|
||||||
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||||
@@ -22,6 +22,19 @@ def test_webapp_album_of_mirrors_album_label():
|
|||||||
assert query.album_of(p, lib) == pa.album_label(Path(p), lib), p
|
assert query.album_of(p, lib) == pa.album_label(Path(p), lib), p
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_service_album_label_matches_donor():
|
||||||
|
# Parity: the extracted single implementation (US03-01) matches the donor rule
|
||||||
|
# (donor_ledger.yaml: pa-album-label, wa-album-of). The service takes a tuple of
|
||||||
|
# library roots; the donor takes one Path.
|
||||||
|
from photo_pipeline.services.albums import album_label
|
||||||
|
|
||||||
|
lib = Path("/lib")
|
||||||
|
for p in ("/lib/Urlaub/Rom/a.jpg", "/lib/a.jpg", "/other/x/a.jpg", "/lib/x/y/a.jpg"):
|
||||||
|
assert album_label(p, (lib,)) == pa.album_label(Path(p), lib), p
|
||||||
|
# No configured roots → bare parent name, like the donor with library=None.
|
||||||
|
assert album_label("/lib/x/a.jpg", ()) == pa.album_label(Path("/lib/x/a.jpg"), None)
|
||||||
|
|
||||||
|
|
||||||
def test_fit_label_left_truncates():
|
def test_fit_label_left_truncates():
|
||||||
assert pa.fit_label("short", 10) == "short"
|
assert pa.fit_label("short", 10) == "short"
|
||||||
assert pa.fit_label("Urlaub/Somewhere/Rom", 8) == "…ere/Rom"
|
assert pa.fit_label("Urlaub/Somewhere/Rom", 8) == "…ere/Rom"
|
||||||
|
|||||||
225
tests/integration/test_album_evidence_repo.py
Normal file
225
tests/integration/test_album_evidence_repo.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Repository tests for AlbumService.aggregate_evidence (US03-01): folder grouping,
|
||||||
|
the documented exclusion/conflict rules, ordering, paging, and — the heart of the
|
||||||
|
story — that each folder's version is stable across reads and invalidated by exactly
|
||||||
|
the relevant asset changes, before and after those changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from photo_pipeline.config import Config
|
||||||
|
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
||||||
|
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
|
||||||
|
from photo_pipeline.services.albums import AlbumService
|
||||||
|
|
||||||
|
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
LIB = (Path("/lib"),) # library root, so album labels are library-relative
|
||||||
|
|
||||||
|
|
||||||
|
def _service(sf):
|
||||||
|
return AlbumService(sf, library_roots=LIB)
|
||||||
|
|
||||||
|
|
||||||
|
def _factory(tmp_path):
|
||||||
|
(tmp_path / "data").mkdir()
|
||||||
|
config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data")})
|
||||||
|
run_migrations(config.database_url)
|
||||||
|
return create_session_factory(create_db_engine(config.database_url))
|
||||||
|
|
||||||
|
|
||||||
|
def _asset(sf, path, *, canonical_of=None, availability="active", asset_id=None):
|
||||||
|
asset_id = asset_id or str(uuid.uuid4())
|
||||||
|
with sf() as session:
|
||||||
|
session.add(
|
||||||
|
Asset(
|
||||||
|
id=asset_id,
|
||||||
|
original_path=path,
|
||||||
|
current_path=path,
|
||||||
|
discovered_at=NOW,
|
||||||
|
hash_version=1,
|
||||||
|
availability_state=availability,
|
||||||
|
canonical_asset_id=canonical_of,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return asset_id
|
||||||
|
|
||||||
|
|
||||||
|
def _decide(sf, asset_id, decision, *, at=NOW):
|
||||||
|
with sf() as session:
|
||||||
|
session.add(
|
||||||
|
SafetyReview(id=str(uuid.uuid4()), asset_id=asset_id, decision=decision, created_at=at)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze(sf, asset_id, **fields):
|
||||||
|
with sf() as session:
|
||||||
|
row = session.get(AnalysisResult, asset_id) or AnalysisResult(asset_id=asset_id)
|
||||||
|
row.status = fields.pop("status", "analyzed")
|
||||||
|
for key, value in fields.items():
|
||||||
|
setattr(row, key, value)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _sfw_analyzed(sf, path, **fields):
|
||||||
|
asset_id = _asset(sf, path)
|
||||||
|
_decide(sf, asset_id, "sfw")
|
||||||
|
_analyze(sf, asset_id, **fields)
|
||||||
|
return asset_id
|
||||||
|
|
||||||
|
|
||||||
|
def _folders(sf):
|
||||||
|
return {f["album"]: f for f in _service(sf).aggregate_evidence()["folders"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_groups_by_full_folder_path_and_summarises_sfw_evidence(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
_sfw_analyzed(
|
||||||
|
sf,
|
||||||
|
"/lib/rome/a.jpg",
|
||||||
|
description="Colosseum",
|
||||||
|
tags='["ruins", "city"]',
|
||||||
|
setting="outdoor",
|
||||||
|
approx_year=2019,
|
||||||
|
people_count=2,
|
||||||
|
location_hint="Rome",
|
||||||
|
)
|
||||||
|
_sfw_analyzed(
|
||||||
|
sf,
|
||||||
|
"/lib/rome/b.jpg",
|
||||||
|
description="Forum",
|
||||||
|
tags='["ruins", "history"]',
|
||||||
|
setting="outdoor",
|
||||||
|
approx_year=2019,
|
||||||
|
people_count=4,
|
||||||
|
location_hint="Rome",
|
||||||
|
)
|
||||||
|
|
||||||
|
rome = _folders(sf)["rome"]
|
||||||
|
assert rome["folder_name"] == "rome" and rome["parent_path"] == ""
|
||||||
|
assert rome["asset_count"] == 2 and rome["analyzed_count"] == 2
|
||||||
|
assert rome["descriptions"] == ["Colosseum", "Forum"] # sorted
|
||||||
|
assert rome["tags"][0] == {"value": "ruins", "count": 2} # most frequent first
|
||||||
|
assert rome["locations"] == [{"value": "Rome", "count": 2}]
|
||||||
|
assert rome["people_counts"] == [{"value": "2", "count": 1}, {"value": "3+", "count": 1}]
|
||||||
|
assert rome["dominant_year"] == 2019 and rome["year_conflict"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclusion_rules_for_dup_nsfw_deferred_and_missing(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
canonical = _sfw_analyzed(sf, "/lib/mix/keep.jpg", description="kept", tags='["a"]')
|
||||||
|
|
||||||
|
# Duplicate variant of the canonical: excluded entirely.
|
||||||
|
_asset(sf, "/lib/mix/dup.jpg", canonical_of=canonical)
|
||||||
|
# NSFW: counted, but no descriptive evidence.
|
||||||
|
nsfw = _asset(sf, "/lib/mix/private.jpg")
|
||||||
|
_decide(sf, nsfw, "nsfw")
|
||||||
|
# Deferred and undecided: counted as pending, no evidence.
|
||||||
|
deferred = _asset(sf, "/lib/mix/later.jpg")
|
||||||
|
_decide(sf, deferred, "deferred")
|
||||||
|
_asset(sf, "/lib/mix/unknown.jpg") # no decision at all
|
||||||
|
# Archived: not in an active folder, excluded entirely.
|
||||||
|
_asset(sf, "/lib/mix/gone.jpg", availability="archived_offline")
|
||||||
|
|
||||||
|
mix = _folders(sf)["mix"]
|
||||||
|
# Only canonical, active assets are counted: keep + nsfw + deferred + unknown.
|
||||||
|
# The duplicate variant and the archived asset are excluded entirely.
|
||||||
|
assert mix["asset_count"] == 4
|
||||||
|
assert mix["nsfw_count"] == 1
|
||||||
|
assert mix["pending_count"] == 2 # deferred + unknown
|
||||||
|
assert mix["analyzed_count"] == 1
|
||||||
|
assert mix["descriptions"] == ["kept"] and mix["tags"] == [{"value": "a", "count": 1}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflicting_year_reports_distribution_and_no_dominant(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
_sfw_analyzed(sf, "/lib/trip/a.jpg", approx_year=2018)
|
||||||
|
_sfw_analyzed(sf, "/lib/trip/b.jpg", approx_year=2019)
|
||||||
|
|
||||||
|
trip = _folders(sf)["trip"]
|
||||||
|
assert trip["years"] == [{"value": 2018, "count": 1}, {"value": 2019, "count": 1}]
|
||||||
|
assert trip["dominant_year"] is None and trip["year_conflict"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_folders_are_ordered_and_pageable(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
for folder in ("/lib/c", "/lib/a", "/lib/b"):
|
||||||
|
_sfw_analyzed(sf, f"{folder}/x.jpg", description="x")
|
||||||
|
|
||||||
|
service = _service(sf)
|
||||||
|
assert service.folder_count() == 3
|
||||||
|
full = service.aggregate_evidence()
|
||||||
|
assert [f["album"] for f in full["folders"]] == ["a", "b", "c"]
|
||||||
|
|
||||||
|
page = service.aggregate_evidence(offset=1, limit=1)
|
||||||
|
assert page["total"] == 3 and page["offset"] == 1
|
||||||
|
assert [f["album"] for f in page["folders"]] == ["b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_is_stable_across_repeated_reads(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
_sfw_analyzed(sf, "/lib/rome/a.jpg", description="d", approx_year=2019)
|
||||||
|
first = _folders(sf)["rome"]["version"]
|
||||||
|
second = _folders(sf)["rome"]["version"]
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_invalidates_on_new_analysis(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
_sfw_analyzed(sf, "/lib/rome/a.jpg", description="d")
|
||||||
|
before = _folders(sf)["rome"]["version"]
|
||||||
|
|
||||||
|
added = _asset(sf, "/lib/rome/b.jpg")
|
||||||
|
_decide(sf, added, "sfw")
|
||||||
|
_analyze(sf, added, description="new")
|
||||||
|
after = _folders(sf)["rome"]
|
||||||
|
assert after["version"] != before and after["analyzed_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_invalidates_when_a_decision_changes_to_nsfw(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
asset = _sfw_analyzed(sf, "/lib/rome/a.jpg", description="d")
|
||||||
|
before = _folders(sf)["rome"]["version"]
|
||||||
|
|
||||||
|
_decide(sf, asset, "nsfw", at=NOW + timedelta(hours=1)) # newer decision wins
|
||||||
|
after = _folders(sf)["rome"]
|
||||||
|
assert after["version"] != before
|
||||||
|
assert after["nsfw_count"] == 1 and after["analyzed_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_an_asset_invalidates_both_folders_and_not_a_third(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
mover = _sfw_analyzed(sf, "/lib/src/a.jpg", description="d")
|
||||||
|
_sfw_analyzed(sf, "/lib/dst/keep.jpg", description="k")
|
||||||
|
_sfw_analyzed(sf, "/lib/other/z.jpg", description="z")
|
||||||
|
before = _folders(sf)
|
||||||
|
other_before = before["other"]["version"]
|
||||||
|
|
||||||
|
with sf() as session:
|
||||||
|
asset = session.get(Asset, mover)
|
||||||
|
asset.current_path = "/lib/dst/a.jpg"
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
after = _folders(sf)
|
||||||
|
assert "src" not in after # source folder is now empty
|
||||||
|
assert after["dst"]["asset_count"] == 2
|
||||||
|
assert after["dst"]["version"] != before["dst"]["version"]
|
||||||
|
assert after["other"]["version"] == other_before # untouched folder is stable
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_invalidates_when_an_asset_becomes_a_duplicate(tmp_path):
|
||||||
|
sf = _factory(tmp_path)
|
||||||
|
canonical = _sfw_analyzed(sf, "/lib/rome/a.jpg", description="a")
|
||||||
|
variant = _sfw_analyzed(sf, "/lib/rome/b.jpg", description="b")
|
||||||
|
before = _folders(sf)["rome"]["version"]
|
||||||
|
|
||||||
|
with sf() as session:
|
||||||
|
session.get(Asset, variant).canonical_asset_id = canonical
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
after = _folders(sf)["rome"]
|
||||||
|
assert after["version"] != before
|
||||||
|
assert after["asset_count"] == 1 # the variant no longer contributes
|
||||||
@@ -68,6 +68,10 @@
|
|||||||
"US02-07": [
|
"US02-07": [
|
||||||
"tests/e2e/test_phase_b_pipeline.py",
|
"tests/e2e/test_phase_b_pipeline.py",
|
||||||
"tests/e2e/test_analysis_browser.py"
|
"tests/e2e/test_analysis_browser.py"
|
||||||
|
],
|
||||||
|
"US03-01": [
|
||||||
|
"tests/unit/test_album_evidence.py",
|
||||||
|
"tests/integration/test_album_evidence_repo.py"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
tests/unit/test_album_evidence.py
Normal file
117
tests/unit/test_album_evidence.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""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)
|
||||||
Reference in New Issue
Block a user