soulsync/tests/radio/test_selection.py
BoulderBadgeDad cbc001e283 Player revamp Phase 0a: extract radio selection into testable core/radio/
First step of the stream/player/radio revamp (see revamp_plan.md). The radio
algorithm lived inline inside database.music_database.get_radio_tracks as raw
SQL tangled with selection logic — untestable without a live DB (which also
throws in the dev sandbox). Lifted the pure DECISIONS into core/radio/selection.py:

  - parse_tags / merge_tags  — JSON-or-CSV tag fields → ordered deduped list
  - same_artist_cap          — tier-1 30%-floored-at-5 cap
  - build_like_conditions    — OR-of-LIKEs SQL fragment + params per tier
  - RadioCollector           — dedup + cap + exclude-set + NOT-IN placeholder/value tracking

The DB method keeps the cursor work and now delegates every decision to these
helpers. Faithful extraction, not a rewrite — behavior unchanged.

This is the kettui foundation move: radio is now unit-testable, so Phase 2
(smart ranking — play-count / recency / feature seeding) becomes 'evolve a
tested function' instead of 'rewrite SQL and pray'.

Tests (tests/radio/):
  - test_selection.py (22): unit coverage of every extracted helper
  - test_get_radio_tracks_db.py (7): drive the REAL get_radio_tracks against
    in-memory sqlite — tier fallback, dedup, exclude, file_path filter.
    Behavior-pinned: these 7 pass against BOTH old inline and new extracted
    code (refactor-equivalence proof). 52 adjacent DB+radio tests green.
2026-05-30 08:34:27 -07:00

139 lines
5.2 KiB
Python

"""Tests for the extracted radio-selection logic (Phase 0a of the player revamp).
These pin the behavior that used to be inline + untestable inside
``database.music_database.get_radio_tracks``. They lock current behavior so
Phase 2 (smart ranking) can evolve it against a green baseline.
"""
from __future__ import annotations
from core.radio.selection import (
RadioCollector,
build_like_conditions,
merge_tags,
parse_tags,
same_artist_cap,
)
class TestParseTags:
def test_json_array(self):
assert parse_tags('["rock", "indie"]') == ["rock", "indie"]
def test_comma_separated_legacy(self):
assert parse_tags("rock, indie, folk") == ["rock", "indie", "folk"]
def test_comma_separated_strips_whitespace_and_blanks(self):
assert parse_tags("rock, , indie ,") == ["rock", "indie"]
def test_empty_and_none(self):
assert parse_tags("") == []
assert parse_tags(None) == []
def test_non_list_json_scalar_wrapped(self):
# A bare JSON scalar (e.g. a quoted string) becomes a single-item list.
assert parse_tags('"rock"') == ["rock"]
def test_garbage_falls_back_to_split(self):
assert parse_tags("not json at all") == ["not json at all"]
class TestSameArtistCap:
def test_thirty_percent(self):
assert same_artist_cap(50) == 15 # 50 * 3 // 10
assert same_artist_cap(20) == 6
def test_floored_at_five(self):
assert same_artist_cap(10) == 5 # 3, floored to 5
assert same_artist_cap(1) == 5
class TestMergeTags:
def test_dedupes_preserving_order(self):
assert merge_tags(["rock", "indie"], ["indie", "folk"]) == ["rock", "indie", "folk"]
def test_empty_groups(self):
assert merge_tags([], []) == []
class TestBuildLikeConditions:
def test_single_tag_two_columns(self):
sql, params = build_like_conditions(["rock"], ("al.genres", "ar.genres"))
assert sql == "al.genres LIKE ? OR ar.genres LIKE ?"
assert params == ["%rock%", "%rock%"]
def test_grouping_matches_original_order(self):
# Original emitted all album-col LIKEs, then all artist-col LIKEs;
# params were [%t%...] * 2. Reproduce that ordering exactly.
sql, params = build_like_conditions(["rock", "indie"], ("al.genres", "ar.genres"))
assert sql == (
"al.genres LIKE ? OR al.genres LIKE ? OR "
"ar.genres LIKE ? OR ar.genres LIKE ?"
)
assert params == ["%rock%", "%indie%", "%rock%", "%indie%"]
def test_no_tags_returns_empty(self):
assert build_like_conditions([], ("al.genres",)) == ("", [])
def test_no_columns_returns_empty(self):
assert build_like_conditions(["rock"], ()) == ("", [])
class TestRadioCollector:
def _rows(self, *ids):
return [{"id": i, "title": f"t{i}"} for i in ids]
def test_collects_and_dedupes(self):
c = RadioCollector(limit=10)
c.collect(self._rows(1, 2, 2, 3)) # dup 2 ignored
assert [t["id"] for t in c.tracks] == [1, 2, 3]
def test_excludes_seed_and_caller_ids(self):
c = RadioCollector(limit=10, exclude_ids=["1", "2"])
c.collect(self._rows(1, 2, 3, 4))
assert [t["id"] for t in c.tracks] == [3, 4]
def test_exclude_ids_coerced_to_str(self):
# Caller may pass ints; seen-set stores strings.
c = RadioCollector(limit=10, exclude_ids=[1])
c.collect(self._rows(1, 2))
assert [t["id"] for t in c.tracks] == [2]
def test_cap_bounds_a_single_tier(self):
c = RadioCollector(limit=10)
c.collect(self._rows(1, 2, 3, 4, 5), cap=2) # only 2 from this tier
assert [t["id"] for t in c.tracks] == [1, 2]
assert not c.filled
assert c.remaining() == 8
def test_filled_at_limit(self):
c = RadioCollector(limit=3)
ret = c.collect(self._rows(1, 2, 3, 4))
assert ret is True
assert c.filled
assert len(c.tracks) == 3
assert c.remaining() == 0
def test_capped_collect_returns_true_at_cap_target(self):
# Faithful to the original _collect: it returns True once the
# cap-bounded target is hit, even below the overall limit. The DB
# method IGNORES tier 1's capped return and checks .filled instead, so
# this never causes early exit — but the contract must match exactly.
c = RadioCollector(limit=5)
assert c.collect(self._rows(1, 2), cap=2) is True # hit cap target (2)
assert not c.filled # but not at limit (5)
def test_uncapped_collect_returns_true_only_at_limit(self):
c = RadioCollector(limit=5)
assert c.collect(self._rows(1, 2)) is False # below limit
assert c.collect(self._rows(3, 4, 5)) is True # now at limit
def test_exclude_placeholders_and_values_track_seen_set(self):
c = RadioCollector(limit=10, exclude_ids=["a", "b"])
assert c.exclude_placeholders() == "?,?"
assert set(c.exclude_values()) == {"a", "b"}
# After collecting, already-collected IDs join the NOT-IN set so the
# next tier's SQL won't re-pull them.
c.collect(self._rows("c"))
assert c.exclude_placeholders() == "?,?,?"
assert set(c.exclude_values()) == {"a", "b", "c"}