mirror of
https://github.com/beetbox/beets.git
synced 2026-03-28 16:25:19 +01:00
lastgenre: Tests for genre ignorelist feature
- Test file format (valid and error cases) - Test regex pattern matching (_is_ignored) - Test _resolve_genres: ignored genres filtered - Test _resolve_genres: c14n ancestry walk blocked for ignored tags - Test _resolve_genres: without whitelist, oldest ancestor is kept - Test _resolve_genres: whithout whitelist, ignored oldest ancestor is removed
This commit is contained in:
parent
1943b14565
commit
cd0c0a2349
1 changed files with 277 additions and 0 deletions
|
|
@ -14,13 +14,19 @@
|
|||
|
||||
"""Tests for the 'lastgenre' plugin."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from beets.test import _common
|
||||
from beets.test.helper import IOMixin, PluginTestCase
|
||||
from beets.ui import UserError
|
||||
from beetsplug import lastgenre
|
||||
from beetsplug.lastgenre.utils import is_ignored
|
||||
|
||||
|
||||
class LastGenrePluginTest(IOMixin, PluginTestCase):
|
||||
|
|
@ -202,6 +208,80 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
|
|||
res = lastgenre.sort_by_depth(tags, self.plugin.c14n_branches)
|
||||
assert res == ["ambient", "electronic"]
|
||||
|
||||
# Ignorelist tests in resolve_genres and _is_ignored
|
||||
|
||||
def test_ignorelist_filters_genres_in_resolve(self):
|
||||
"""Ignored genres are stripped by _resolve_genres (no c14n).
|
||||
|
||||
Artist-specific and global patterns are both applied.
|
||||
"""
|
||||
self._setup_config(whitelist=False, canonical=False)
|
||||
self.plugin.ignorelist = defaultdict(
|
||||
list,
|
||||
{
|
||||
"the artist": [re.compile(r"^metal$", re.IGNORECASE)],
|
||||
"*": [re.compile(r"^rock$", re.IGNORECASE)],
|
||||
},
|
||||
)
|
||||
result = self.plugin._resolve_genres(
|
||||
["metal", "rock", "jazz"], artist="the artist"
|
||||
)
|
||||
assert "metal" not in result, (
|
||||
"artist-specific ignored genre must be removed"
|
||||
)
|
||||
assert "rock" not in result, "globally ignored genre must be removed"
|
||||
assert "jazz" in result, "non-ignored genre must survive"
|
||||
|
||||
def test_ignorelist_stops_c14n_ancestry_walk(self):
|
||||
"""An ignored tag's c14n parents don't bleed into the result.
|
||||
|
||||
Without ignorelist, 'delta blues' canonicalizes to 'blues'.
|
||||
With 'delta blues' ignored the tag is skipped entirely in the
|
||||
c14n loop, so 'blues' must not appear either.
|
||||
"""
|
||||
self._setup_config(whitelist=False, canonical=True, count=99)
|
||||
self.plugin.ignorelist = defaultdict(
|
||||
list,
|
||||
{
|
||||
"the artist": [re.compile(r"^delta blues$", re.IGNORECASE)],
|
||||
},
|
||||
)
|
||||
result = self.plugin._resolve_genres(
|
||||
["delta blues"], artist="the artist"
|
||||
)
|
||||
assert result == [], (
|
||||
"ignored tag must not contribute c14n parents to the result"
|
||||
)
|
||||
|
||||
def test_ignorelist_c14n_no_whitelist_keeps_oldest_ancestor(self):
|
||||
"""With c14n on and whitelist off, ignorelist must not change the
|
||||
parent-selection rule: only the oldest ancestor is returned.
|
||||
"""
|
||||
self._setup_config(whitelist=False, canonical=True, count=99)
|
||||
# ignorelist targets an unrelated genre — must not affect parent walking
|
||||
self.plugin.ignorelist = defaultdict(
|
||||
list,
|
||||
{"*": [re.compile(r"^jazz$", re.IGNORECASE)]},
|
||||
)
|
||||
result = self.plugin._resolve_genres(["delta blues"])
|
||||
assert result == ["blues"], (
|
||||
"oldest ancestor only must be returned, not the full parent chain"
|
||||
)
|
||||
|
||||
def test_ignorelist_c14n_no_whitelist_drops_ignored_ancestor(self):
|
||||
"""With c14n on and whitelist off, if the oldest ancestor itself is
|
||||
ignored it must be dropped and the tag contributes nothing.
|
||||
"""
|
||||
self._setup_config(whitelist=False, canonical=True, count=99)
|
||||
self.plugin.ignorelist = defaultdict(
|
||||
list,
|
||||
{"*": [re.compile(r"^blues$", re.IGNORECASE)]},
|
||||
)
|
||||
result = self.plugin._resolve_genres(["delta blues"])
|
||||
assert result == [], (
|
||||
"ignored oldest ancestor must not appear in the result"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(config):
|
||||
|
|
@ -614,3 +694,200 @@ def test_get_genre(
|
|||
|
||||
# Run
|
||||
assert plugin._get_genre(item) == expected_result
|
||||
|
||||
|
||||
# Ignorelist pattern matching tests for _is_ignored, independent of _resolve_genres
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ignorelist_dict, artist, genre, expected_forbidden",
|
||||
[
|
||||
# Global ignorelist - simple word
|
||||
({"*": ["spoken word"]}, "Any Artist", "spoken word", True),
|
||||
({"*": ["spoken word"]}, "Any Artist", "jazz", False),
|
||||
# Global ignorelist - regex pattern
|
||||
({"*": [".*electronic.*"]}, "Any Artist", "ambient electronic", True),
|
||||
({"*": [".*electronic.*"]}, "Any Artist", "jazz", False),
|
||||
# Artist-specific ignorelist
|
||||
({"metallica": ["metal"]}, "Metallica", "metal", True),
|
||||
({"metallica": ["metal"]}, "Iron Maiden", "metal", False),
|
||||
# Case insensitive matching
|
||||
({"metallica": ["metal"]}, "METALLICA", "METAL", True),
|
||||
# Full-match behavior: plain "metal" must not match "heavy metal"
|
||||
({"metallica": ["metal"]}, "Metallica", "heavy metal", False),
|
||||
# Regex behavior: explicit pattern ".*metal.*" may match "heavy metal"
|
||||
({"metallica": [".*metal.*"]}, "Metallica", "heavy metal", True),
|
||||
# Artist-specific ignorelist - exact match
|
||||
({"metallica": ["^Heavy Metal$"]}, "Metallica", "classic metal", False),
|
||||
# Combined global and artist-specific
|
||||
(
|
||||
{"*": ["spoken word"], "metallica": ["metal"]},
|
||||
"Metallica",
|
||||
"spoken word",
|
||||
True,
|
||||
),
|
||||
(
|
||||
{"*": ["spoken word"], "metallica": ["metal"]},
|
||||
"Metallica",
|
||||
"metal",
|
||||
True,
|
||||
),
|
||||
# Complex regex pattern with multiple features (raw string)
|
||||
(
|
||||
{
|
||||
"fracture": [
|
||||
r"^(heavy|black|power|death)?\s?(metal|rock)$|\w+-metal\d*$"
|
||||
]
|
||||
},
|
||||
"Fracture",
|
||||
"power metal",
|
||||
True,
|
||||
),
|
||||
# Complex regex pattern with multiple features (regular string)
|
||||
(
|
||||
{"amon tobin": ["d(rum)?[ n/]*b(ass)?"]},
|
||||
"Amon Tobin",
|
||||
"dnb",
|
||||
True,
|
||||
),
|
||||
# Empty ignorelist
|
||||
({}, "Any Artist", "any genre", False),
|
||||
],
|
||||
)
|
||||
def test_ignorelist_patterns(
|
||||
config, ignorelist_dict, artist, genre, expected_forbidden
|
||||
):
|
||||
"""Test ignorelist pattern matching logic directly."""
|
||||
|
||||
# Disable file-based ignorelist to avoid depending on global config state.
|
||||
config["lastgenre"]["ignorelist"] = False
|
||||
|
||||
# Initialize plugin
|
||||
plugin = lastgenre.LastGenrePlugin()
|
||||
|
||||
# Set up compiled ignorelist directly (skipping file parsing)
|
||||
compiled_ignorelist = defaultdict(list)
|
||||
for artist_name, patterns in ignorelist_dict.items():
|
||||
compiled_ignorelist[artist_name.lower()] = [
|
||||
re.compile(pattern, re.IGNORECASE) for pattern in patterns
|
||||
]
|
||||
|
||||
plugin.ignorelist = compiled_ignorelist
|
||||
|
||||
result = is_ignored(plugin._log, plugin.ignorelist, genre, artist)
|
||||
assert result == expected_forbidden
|
||||
|
||||
|
||||
def test_ignorelist_literal_fallback_uses_fullmatch(config):
|
||||
"""An invalid-regex pattern falls back to a literal string and must use
|
||||
full-match semantics: the pattern must equal the entire genre string,
|
||||
not just appear as a substring.
|
||||
"""
|
||||
# Disable file-based ignorelist to avoid depending on global config state.
|
||||
config["lastgenre"]["ignorelist"] = False
|
||||
plugin = lastgenre.LastGenrePlugin()
|
||||
# "[not valid regex" is not valid regex, so _compile_ignorelist_patterns
|
||||
# escapes and compiles it as a literal.
|
||||
plugin.ignorelist = lastgenre.LastGenrePlugin._compile_ignorelist_patterns(
|
||||
{"*": ["[not valid regex"]}
|
||||
)
|
||||
# Exact match must be caught.
|
||||
assert (
|
||||
is_ignored(plugin._log, plugin.ignorelist, "[not valid regex", "")
|
||||
is True
|
||||
)
|
||||
# Substring must NOT be caught (would have passed with old .search()).
|
||||
assert (
|
||||
is_ignored(
|
||||
plugin._log,
|
||||
plugin.ignorelist,
|
||||
"contains [not valid regex inside",
|
||||
"",
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_content, expected_ignorelist",
|
||||
[
|
||||
# Basic artist with pattern
|
||||
("metallica:\n metal", {"metallica": ["metal"]}),
|
||||
# Global ignorelist
|
||||
("*:\n spoken word", {"*": ["spoken word"]}),
|
||||
# Multiple patterns per artist
|
||||
(
|
||||
"metallica:\n metal\n .*rock.*",
|
||||
{"metallica": ["metal", ".*rock.*"]},
|
||||
),
|
||||
# Comments and empty lines skipped
|
||||
(
|
||||
"# comment\n*:\n spoken word\n\nmetallica:\n metal",
|
||||
{"*": ["spoken word"], "metallica": ["metal"]},
|
||||
),
|
||||
# Case insensitive artist names — key lowercased, pattern kept as-is
|
||||
# (patterns compiled with re.IGNORECASE so case doesn't matter for matching)
|
||||
("METALLICA:\n METAL", {"metallica": ["METAL"]}),
|
||||
# Invalid regex pattern that gets escaped
|
||||
("artist:\n [invalid(regex", {"artist": ["\\[invalid\\(regex"]}),
|
||||
# Empty file
|
||||
("", {}),
|
||||
],
|
||||
)
|
||||
def test_ignorelist_file_format(config, file_content, expected_ignorelist):
|
||||
"""Test ignorelist file format parsing."""
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".txt", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(file_content)
|
||||
ignorelist_file = f.name
|
||||
|
||||
try:
|
||||
config["lastgenre"]["ignorelist"] = ignorelist_file
|
||||
plugin = lastgenre.LastGenrePlugin()
|
||||
|
||||
# Convert compiled regex patterns back to strings for comparison
|
||||
string_ignorelist = {
|
||||
artist: [p.pattern for p in patterns]
|
||||
for artist, patterns in plugin.ignorelist.items()
|
||||
}
|
||||
|
||||
assert string_ignorelist == expected_ignorelist
|
||||
|
||||
finally:
|
||||
os.unlink(ignorelist_file)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_content, expected_error_message",
|
||||
[
|
||||
# Missing colon
|
||||
("metallica\n metal", "Malformed ignorelist section header"),
|
||||
# Pattern before section
|
||||
(" metal\nmetallica:\n heavy metal", "before any section header"),
|
||||
# Unindented pattern
|
||||
("metallica:\nmetal", "Malformed ignorelist section header"),
|
||||
],
|
||||
)
|
||||
def test_ignorelist_file_format_errors(
|
||||
config, invalid_content, expected_error_message
|
||||
):
|
||||
"""Test ignorelist file format error handling."""
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".txt", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(invalid_content)
|
||||
ignorelist_file = f.name
|
||||
|
||||
try:
|
||||
config["lastgenre"]["ignorelist"] = ignorelist_file
|
||||
|
||||
with pytest.raises(UserError) as exc_info:
|
||||
lastgenre.LastGenrePlugin()
|
||||
|
||||
assert expected_error_message in str(exc_info.value)
|
||||
|
||||
finally:
|
||||
os.unlink(ignorelist_file)
|
||||
|
|
|
|||
Loading…
Reference in a new issue