Sort_YM/tests/test_digest.py
2026-08-16 20:23:19 +03:00

405 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from sort_ym import digest, report
CATALOG: dict = {} # classify_track с пустым catalog использует genre_raw как есть
_UNSET = object()
def _track(id_, title, artists, genre_raw, artist_ids=None, album_id=_UNSET, album_title="", year=None):
return {
"id": id_,
"album_id": id_ * 10 if album_id is _UNSET else album_id,
"album_title": album_title,
"year": year,
"title": title,
"artists": artists,
"artist_ids": artist_ids if artist_ids is not None else [],
"genre_raw": genre_raw,
"lyrics_available": False,
}
def _rows(tracks_cache, small_group_min=1):
return report.build_rows(tracks_cache, {}, CATALOG, {}, small_group_min)
def test_artist_stats_counts_by_name_even_without_ids():
tracks_cache = {
"1:10": _track(1, "T1", ["Artist A"], "punk", artist_ids=[]),
"2:10": _track(2, "T2", ["Artist A"], "punk", artist_ids=[7]),
}
stats = digest.artist_stats(_rows(tracks_cache), tracks_cache, {})
assert len(stats) == 1
assert stats[0]["name"] == "Artist A"
assert stats[0]["tracks"] == 2
def test_artist_stats_joins_tracks_without_album_id():
# Ключ кэша не совпадает с "id:album_id" (реалистично для треков без альбома) -
# склейка идёт по значениям, а не по ключу.
tracks_cache = {"some-uuid": _track(1, "T1", ["Artist A"], "punk", artist_ids=[7], album_id=None)}
stats = digest.artist_stats(_rows(tracks_cache), tracks_cache, {})
assert stats == [{"name": "Artist A", "tracks": 1, "top_fine": "punk", "tags": []}]
def test_artist_stats_counts_collaboration_for_each_artist():
tracks_cache = {"1:10": _track(1, "T1", ["Artist A", "Artist B"], "punk", artist_ids=[7, 8])}
stats = digest.artist_stats(_rows(tracks_cache), tracks_cache, {})
names = {s["name"]: s["tracks"] for s in stats}
assert names == {"Artist A": 1, "Artist B": 1}
def test_long_tail_collapses_past_top_artists():
tracks_cache = {}
tid = 0
for artist_idx in range(50):
count = 50 - artist_idx # Artist0 - 50 треков, ... Artist49 - 1 трек
for _ in range(count):
tid += 1
tracks_cache[f"{tid}:{tid * 10}"] = _track(tid, f"T{tid}", [f"Artist{artist_idx}"], "punk")
rows = _rows(tracks_cache)
stats = digest.artist_stats(rows, tracks_cache, {})
rest = stats[10:]
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=0)
assert "Artist0" in text
assert "Artist9" in text
assert "Artist10" not in text
assert f"Ещё {len(rest)} исполнителей" in text
assert str(sum(a["tracks"] for a in rest)) in text
def test_genre_stats_uses_coarse_of_fine_not_row_bucket():
# punk (15 треков, >= порога) остаётся отдельным под-жанром в row["bucket"], indie (2, < порога)
# схлопывается в indie-alt - при наивном подсчёте по row["bucket"] это были бы ДВЕ разные
# строки, хотя coarse_of("punk") тоже "indie-alt" - это одна и та же крупная корзина.
tracks_cache = {}
for i in range(15):
tracks_cache[f"{i}:{i * 10}"] = _track(i, f"P{i}", ["Punk Artist"], "punk")
for i in range(15, 17):
tracks_cache[f"{i}:{i * 10}"] = _track(i, f"I{i}", ["Indie Artist"], "indie")
rows = _rows(tracks_cache, small_group_min=12)
assert any(r["bucket"] == "punk" for r in rows), "sanity: punk остался отдельным под-жанром"
assert any(r["bucket"] == "indie-alt" for r in rows), "sanity: indie схлопнулся"
stats = digest.genre_stats(rows)
assert {g["bucket"]: g["tracks"] for g in stats} == {"indie-alt": 17}
def test_fine_stats_unmapped_bucket_renders_as_raw_slug():
tracks_cache = {"1:10": _track(1, "T1", ["Artist"], "totally-unknown-genre")}
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10)
assert "totally-unknown-genre" in text
def test_decade_key_and_label():
assert digest.decade_key(2011) == "2010"
assert digest.decade_key(None) == digest.DECADE_UNKNOWN
assert digest.decade_key(0) == digest.DECADE_UNKNOWN
assert digest.decade_key(1) == digest.DECADE_UNKNOWN
assert digest.decade_key(2150) == digest.DECADE_UNKNOWN
assert digest.decade_label("2010") == "2010-е"
assert digest.decade_label(digest.DECADE_UNKNOWN) == "Неизвестно"
def test_decade_stats_handles_missing_year_key_without_crashing():
old_format_track = _track(1, "T1", ["Artist"], "punk")
del old_format_track["year"]
tracks_cache = {"1:10": old_format_track}
stats = digest.decade_stats(_rows(tracks_cache), tracks_cache)
assert stats == [{"key": digest.DECADE_UNKNOWN, "tracks": 1, "top_genres": [{"bucket": "indie-alt", "tracks": 1}]}]
def test_decade_stats_sorted_desc_with_unknown_last():
tracks_cache = {
"1:10": _track(1, "T1", ["A"], "punk", year=2015),
"2:20": _track(2, "T2", ["B"], "punk", year=1995),
"3:30": _track(3, "T3", ["C"], "punk", year=None),
}
stats = digest.decade_stats(_rows(tracks_cache), tracks_cache)
assert [s["key"] for s in stats] == ["2010", "1990", digest.DECADE_UNKNOWN]
def test_decade_top_genres_truncated_to_three_largest():
tracks_cache = {}
tid = 0
for genre_raw, n in {"punk": 10, "metal": 8, "rap": 6, "jazz": 4, "pop": 2}.items():
for _ in range(n):
tid += 1
tracks_cache[f"{tid}:{tid * 10}"] = _track(tid, f"T{tid}", ["Artist"], genre_raw, year=2015)
stats = digest.decade_stats(_rows(tracks_cache), tracks_cache)
assert len(stats) == 1
top = stats[0]["top_genres"]
assert len(top) == 3
assert sum(g["tracks"] for g in top) < stats[0]["tracks"]
def test_album_stats_skips_none_album_id_but_tracks_counted_elsewhere():
tracks_cache = {
"abc": _track(1, "T1", ["Artist"], "punk", album_id=None),
"2:20": _track(2, "T2", ["Artist"], "punk", album_id=20, album_title="Album X"),
}
albums = digest.album_stats(tracks_cache)
assert len(albums) == 1
assert albums[0]["album_id"] == 20
genre_total = sum(g["tracks"] for g in digest.genre_stats(_rows(tracks_cache)))
assert genre_total == 2
def test_album_stats_title_tie_break_is_deterministic():
# Оба названия встречаются по разу - при равенстве частот побеждает одно и то же
# значение независимо от порядка треков на входе.
a = {
"1:10": _track(1, "T1", ["Artist"], "punk", album_id=10, album_title="Zeta"),
"2:10": _track(2, "T2", ["Artist"], "punk", album_id=10, album_title="Alpha"),
}
b = {
"2:10": a["2:10"],
"1:10": a["1:10"],
}
assert digest.album_stats(a)[0]["title"] == "Alpha"
assert digest.album_stats(b)[0]["title"] == "Alpha"
def test_album_stats_untitled_fallback():
tracks_cache = {"1:10": _track(1, "T1", ["Artist"], "punk", album_id=42, album_title="")}
albums = digest.album_stats(tracks_cache)
assert albums[0]["title"] == "без названия (#42)"
def test_artist_tags_empty_when_only_misaligned_tracks():
# 2 имени, 1 id - длины не совпадают, трек целиком пропускается для сопоставления тегов.
tracks_cache = {"1:10": _track(1, "T1", ["Artist A", "Artist B"], "punk", artist_ids=[7])}
ids_by_name = digest._artist_ids_by_name(tracks_cache)
assert digest.artist_tags("Artist A", ids_by_name, {"7": {"name": "Artist B", "genres": ["rock"]}}) == []
def test_artist_tags_no_misattribution_from_misaligned_track():
tracks_cache = {
"1:10": _track(1, "T1", ["Artist A", "Artist B"], "punk", artist_ids=[9]), # рассинхрон
"2:20": _track(2, "T2", ["Artist B"], "punk", artist_ids=[9]), # выровнен
}
ids_by_name = digest._artist_ids_by_name(tracks_cache)
artist_genres = {"9": {"name": "Artist B", "genres": ["rock"]}}
assert digest.artist_tags("Artist A", ids_by_name, artist_genres) == []
assert digest.artist_tags("Artist B", ids_by_name, artist_genres) == ["rock"]
def test_artist_tags_unions_across_multiple_ids_for_same_name():
tracks_cache = {
"1:10": _track(1, "T1", ["Artist X"], "punk", artist_ids=[1]),
"2:20": _track(2, "T2", ["Artist X"], "punk", artist_ids=[2]),
}
ids_by_name = digest._artist_ids_by_name(tracks_cache)
artist_genres = {
"1": {"name": "Artist X", "genres": ["punk", "rock"]},
"2": {"name": "Artist X", "genres": ["rock", "ska"]},
}
assert digest.artist_tags("Artist X", ids_by_name, artist_genres) == ["punk", "rock", "ska"]
def test_artist_tags_truncated_to_limit():
tracks_cache = {"1:10": _track(1, "T1", ["Artist X"], "punk", artist_ids=[1])}
ids_by_name = digest._artist_ids_by_name(tracks_cache)
artist_genres = {"1": {"name": "Artist X", "genres": ["a", "b", "c", "d"]}}
assert digest.artist_tags("Artist X", ids_by_name, artist_genres, limit=3) == ["a", "b", "c"]
def test_default_wording_matches_likes_output():
# Фиксирует путь без --source: render_digest(...) без явного wording не должен измениться
# при добавлении Wording/playlist_wording для --source.
tracks_cache = {"1:10": _track(1, "T1", ["Artist"], "punk")}
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10)
assert text.startswith("# Дайджест музыкальной библиотеки")
assert "Лайкнутых треков: 1" in text
def test_playlist_wording_does_not_mention_likes():
tracks_cache = {"1:10": _track(1, "T1", ["Artist"], "punk")}
rows = _rows(tracks_cache)
wording = digest.playlist_wording("Мой плейлист", "friend", "https://music.yandex.ru/x")
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10, wording=wording)
assert "Мой плейлист" in text
assert "friend" in text
assert "лайкнут" not in text.lower()
def test_digest_line_count_stays_bounded_on_large_library():
tracks_cache = {}
tid = 0
genre_pool = [
"punk", "indie", "metal", "rap", "electronics", "jazz",
"classicalmusic", "pop", "folk", "soundtrack", "allrock",
]
for artist_idx in range(1500):
for _ in range(1 + artist_idx % 5):
tid += 1
tracks_cache[f"t{tid}"] = _track(
tid,
f"Track{tid}",
[f"Artist{artist_idx}"],
genre_pool[tid % len(genre_pool)],
album_id=tid % 1000,
album_title=f"Album{tid % 1000}",
year=1960 + (tid % 60),
)
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=40, top_albums=15)
assert len(text.splitlines()) < 250
ANALYSIS = {
"100": {"mood": ["melancholy", "longing"], "themes": ["city", "memory"], "stance": "sincere", "pov": "confessional"},
"200": {"mood": ["melancholy"], "themes": ["city"], "stance": "ironic", "pov": "narrative"},
"300": {"error": "TimeoutError: модель зависла"},
}
def test_mood_stats_counts_every_mood_of_a_track():
assert digest.mood_stats(ANALYSIS) == [
{"mood": "melancholy", "tracks": 2},
{"mood": "longing", "tracks": 1},
]
def test_theme_stats_limits_and_orders():
assert digest.theme_stats(ANALYSIS, limit=1) == [{"theme": "city", "tracks": 2}]
def test_analyzed_entries_excludes_error_markers():
assert len(digest.analyzed_entries(ANALYSIS)) == 2
def test_lyrics_block_is_compact():
lines = digest.lyrics_block(ANALYSIS)
assert lines[0] == "## Лирика"
assert len(lines) <= 7, "агрегат в digest.md должен оставаться компактным"
assert any("melancholy 2" in line for line in lines)
assert any("city 2" in line for line in lines)
def test_lyrics_block_empty_without_analysis():
assert digest.lyrics_block({}) == []
assert digest.lyrics_block({"300": {"error": "TimeoutError: ..."}}) == []
def test_render_digest_without_analysis_has_no_lyrics_section():
# Обратная совместимость: без analysis дайджест прежний, как в test_default_wording_matches_likes_output.
tracks_cache = {"100:1000": _track(100, "Тоска", ["Артист"], "rock")}
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10)
assert "## Лирика" not in text
def test_render_digest_with_analysis_includes_lyrics_section():
tracks_cache = {"100:1000": _track(100, "Тоска", ["Артист"], "rock")}
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10, analysis=ANALYSIS)
assert "## Лирика" in text
LYRICS_TRACKS = {
"100:10": {"id": 100, "album_id": 10, "title": "Тоска", "artists": ["Артист"]},
"200:20": {"id": 200, "album_id": 20, "title": "Город", "artists": ["Другой"]},
}
FULL_ANALYSIS = {
"100": {
"mood": ["melancholy", "longing"],
"themes": ["city", "memory"],
"emotional_arc": "descent",
"pov": "confessional",
"register": "conversational",
"concreteness": "abstract",
"stance": "sincere",
"confidence": 0.9,
"summary": "Герой бродит по городу.",
"resonance": "Узнаваемое чувство одиночества.",
"key_line": "А я иду, шагаю по Москве",
"key_line_verified": True,
},
"200": {
"mood": ["melancholy"],
"themes": ["city"],
"emotional_arc": "static",
"pov": "narrative",
"register": "poetic",
"concreteness": "concrete",
"stance": "ironic",
"confidence": 0.8,
"summary": "Наблюдение за городом.",
"resonance": "Городская меланхолия.",
"key_line": "Пересказ вместо цитаты",
"key_line_verified": False,
},
"300": {"error": "TimeoutError: модель зависла"},
}
def test_render_lyrics_digest_lists_tracks_with_narrative():
text = digest.render_lyrics_digest(LYRICS_TRACKS, FULL_ANALYSIS)
assert "Артист — Тоска" in text
assert "Герой бродит по городу." in text
assert "Узнаваемое чувство одиночества." in text
assert "city, memory" in text
def test_render_lyrics_digest_omits_unverified_key_line():
text = digest.render_lyrics_digest(LYRICS_TRACKS, FULL_ANALYSIS)
assert "А я иду, шагаю по Москве" in text
assert "Пересказ вместо цитаты" not in text, "непроверенная цитата - вероятный пересказ модели"
def test_render_lyrics_digest_reports_failed_tracks_without_rendering_them():
text = digest.render_lyrics_digest(LYRICS_TRACKS, FULL_ANALYSIS)
assert "TimeoutError" not in text
assert "Не разобрано: 1" in text
def test_render_lyrics_digest_groups_by_primary_mood():
text = digest.render_lyrics_digest(LYRICS_TRACKS, FULL_ANALYSIS)
assert "## melancholy (2)" in text
def test_render_lyrics_digest_handles_track_missing_from_cache():
text = digest.render_lyrics_digest({}, {"100": FULL_ANALYSIS["100"]})
assert "трек 100" in text