soulsync/tests/metadata/test_musicbrainz_client_query.py
BoulderBadgeDad 163de6c146 MusicBrainz manual search: field-scope the artist in non-strict mode (#754)
The user-facing Search-for-Match / Fix popup runs non-strict MB searches.
That path built a bare "track artist" query with no field scoping, so the
artist was just a free fuzzy term — covers and karaoke whose TITLES contained
the artist name outranked the canonical recording. Reproduced live: searching
"Say You Will" / Foreigner returned cover artists with Foreigner absent, and
"Sweet Child O Mine" / Guns N Roses returned only covers (Presnyakov, PMJ…),
never the Guns N' Roses original.

Keep the track/album side loose (no phrase quotes → diacritic + bracketed-
suffix recall, the reason non-strict exists) but field-scope the artist as
artist:(...) so it constrains. The artist value is Lucene-escaped via
_escape_lucene() — without it, names like "Sunn O)))" or "Anthony Green
(Saosin)" would close the artist:( group early (returning unrelated artists)
or break the query (zero results). Same fix applied to search_release.

Verified against the live MB API: both reporter queries now return the real
artist top-to-bottom; diacritic recall is preserved (artist:(Bjork) folds to
Björk); and paren/?/!-laden artist names produce valid, balanced queries.

Tests pin the constructed query string (no network): non-strict scopes and
escapes the artist while keeping the track loose/unquoted; strict path
unchanged; plus _escape_lucene unit coverage.
2026-05-31 10:05:24 -07:00

122 lines
4.8 KiB
Python

"""Query-construction tests for core/musicbrainz_client.py.
Regression guard for #754: the user-facing "Search for Match" / Fix popup
runs non-strict (strict=False) MusicBrainz searches. The old non-strict path
built a bare "track artist" blob with NO field scoping, so the artist was just
a free fuzzy term — covers/karaoke whose TITLES contained the artist name
outranked the real recording (e.g. "Sweet Child O Mine" / "Guns N Roses"
returned only covers, never the Guns N' Roses original).
The fix keeps the track/album side loose (diacritic + bracket recall) but
field-scopes the artist (artist:(...)) so it actually constrains. These tests
pin the query STRING the client sends — no network — by capturing the params
passed to session.get.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from core.musicbrainz_client import MusicBrainzClient, _escape_lucene
@pytest.fixture
def client():
c = MusicBrainzClient("SoulSync", "2")
# Replace the HTTP session with a mock returning an empty result set, so
# we can inspect the query string without touching the network.
resp = MagicMock()
resp.raise_for_status = MagicMock()
resp.json = MagicMock(return_value={"recordings": [], "releases": []})
c.session = MagicMock()
c.session.get = MagicMock(return_value=resp)
return c
def _query_of(client) -> str:
"""The 'query' param of the last session.get call."""
_, kwargs = client.session.get.call_args
return kwargs["params"]["query"]
# ── recording: non-strict must field-scope the artist ──────────────────────
def test_recording_nonstrict_scopes_artist(client):
client.search_recording("Sweet Child O Mine", artist_name="Guns N Roses", strict=False)
q = _query_of(client)
assert "artist:(Guns N Roses)" in q # artist is a CONSTRAINT, not a loose term
assert q.startswith("Sweet Child O Mine") # track side stays loose (no phrase quotes)
assert '"' not in q # no phrase-quoting that kills bracket/diacritic recall
def test_recording_nonstrict_without_artist_is_bare_track(client):
client.search_recording("Hyperballad", strict=False)
assert _query_of(client) == "Hyperballad" # no artist → no AND clause
def test_recording_nonstrict_whitespace_artist_is_bare_track(client):
# A whitespace-only artist must not produce a malformed artist:( ) group.
client.search_recording("Hyperballad", artist_name=" ", strict=False)
assert _query_of(client) == "Hyperballad"
def test_recording_nonstrict_strips_artist_padding(client):
client.search_recording("Money", artist_name=" Pink Floyd ", strict=False)
assert _query_of(client) == "Money AND artist:(Pink Floyd)"
def test_recording_strict_unchanged(client):
client.search_recording("Say You Will", artist_name="Foreigner", strict=True)
q = _query_of(client)
assert q == 'recording:"Say You Will" AND artist:"Foreigner"' # strict path untouched
def test_recording_nonstrict_escapes_lucene_specials_in_artist(client):
# Artist names with parens/?/! must NOT break the artist:(...) group.
# Without escaping, "Sunn O)))" closes the group early and returns
# unrelated artists; "Anthony Green (Saosin)" returns nothing.
client.search_recording("Hunting Season", artist_name="Sunn O)))", strict=False)
q = _query_of(client)
assert "artist:(Sunn O\\)\\)\\))" in q # every ) escaped, group stays balanced
# The clause is well-formed: opening "artist:(" is closed by exactly one
# unescaped ")", so paren depth returns to zero.
depth = 0
after = q.split("artist:(", 1)[1]
i = 0
while i < len(after):
ch = after[i]
if ch == "\\":
i += 2
continue
if ch == "(":
depth += 1
elif ch == ")":
if depth == 0:
break # this is the closing paren of artist:(
depth -= 1
i += 1
assert depth == 0
def test_escape_lucene_helper():
assert _escape_lucene("Sunn O)))") == "Sunn O\\)\\)\\)"
assert _escape_lucene("Therapy?") == "Therapy\\?"
assert _escape_lucene("AC/DC") == "AC\\/DC"
assert _escape_lucene("Foreigner") == "Foreigner" # plain text untouched
# ── release: same fix, same guarantees ─────────────────────────────────────
def test_release_nonstrict_scopes_artist(client):
client.search_release("Nevermind", artist_name="Nirvana", strict=False)
q = _query_of(client)
assert "artist:(Nirvana)" in q
assert q.startswith("Nevermind")
assert '"' not in q
def test_release_strict_unchanged(client):
client.search_release("Nevermind", artist_name="Nirvana", strict=True)
assert _query_of(client) == 'release:"Nevermind" AND artist:"Nirvana"'