226 lines
8.1 KiB
Python
226 lines
8.1 KiB
Python
"""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
|