soulsync/tests/sync/test_artist_name_extraction.py
Broque Thomas feb6778af4 Address Cin review: extract helpers, indexed pool fetch, tidy nits
Three changes folded into one perf+cleanup pass:

1. Indexed fast path for the per-artist pool fetch. The previous
   `search_tracks(artist=name)` call hit
   `unidecode_lower(artists.name) LIKE ?`, a function-in-WHERE that
   can't use `idx_artists_name`. New `MusicDatabase.get_artist_tracks_indexed`
   does a two-step lookup: exact-name match (indexed) plus a
   case-insensitive fallback, then `tracks WHERE artist_id IN (...)`
   via `idx_tracks_artist_id`. Drops per-artist fetch from seconds to
   milliseconds for the common case. The sync helper falls back to
   the old LIKE-based `search_tracks` only when the indexed lookup
   finds nothing, preserving diacritic recall and `tracks.track_artist`
   feature-artist matches with zero regression.

2. Public text-normalization helper. Lifted the body of
   `MusicDatabase._normalize_for_comparison` into
   `core/text/normalize.py:normalize_for_comparison` so callers outside
   the database layer (matching engine, sync pool, future import-side
   comparisons) don't reach across the module boundary into a
   leading-underscore "private" method. The DB method now delegates,
   so existing internal call sites stay untouched. Sync's lazy pool
   now imports the public helper.

3. Artist-name walker extracted. `_artist_name` at module level in
   `services/sync_service.py` replaces two near-identical inline
   str-or-dict-or-fallback walkers (one in `sync_playlist`, one in
   `_find_track_in_media_server`). Returns `''` for None instead of
   the literal string `'None'`.

Plus three small tidies from the same review:

- `_POOL_FETCH_LIMIT = 10000` constant in place of the literal at the
  pool-fetch call site.
- Trimmed the verbose docstring + comment block on the pool helper.
- Set-intersection predicate for the trigger-shape reset in
  `core/automation/api.py` instead of a two-line `or` chain.

Also removed the duplicate `_get_active_media_client()` call at
sync_service.py:212/214 — pre-existing wart that was sitting in the
same block I was editing.

Tests: 21 new tests across `tests/database/`, `tests/sync/`, and
`tests/text/`, plus updates to the existing pool tests to cover the
new fast/fallback split. Full suite stays green (3953 passing).
2026-05-24 23:33:55 -07:00

43 lines
1.3 KiB
Python

"""Tests for `_artist_name` in services/sync_service.py — the helper
that pulls a string name out of Spotify's bare-string / dict / fallback
artist representations."""
from services.sync_service import _artist_name
def test_bare_string_returned_as_is():
assert _artist_name('Drake') == 'Drake'
def test_dict_with_name_field():
assert _artist_name({'name': 'Drake', 'id': '3TVXt'}) == 'Drake'
def test_dict_without_name_field_falls_back_to_str_repr():
"""Missing name field shouldn't crash — caller should still get a
string back, even if it's the awkward dict repr."""
out = _artist_name({'id': '3TVXt'})
assert isinstance(out, str)
assert out != ''
def test_dict_with_non_string_name_falls_back():
"""Defensive — if some endpoint ever returns {name: None} or a list,
the helper must not propagate the bad type."""
out = _artist_name({'name': None})
assert isinstance(out, str)
def test_none_returns_empty_string():
assert _artist_name(None) == ''
def test_unexpected_type_returns_string_repr():
"""A weird type (int, custom object) must coerce to a string instead
of raising — sync iterates a lot of inputs and one bad row shouldn't
crash the whole loop."""
assert _artist_name(12345) == '12345'
def test_empty_string_stays_empty():
assert _artist_name('') == ''