Files
unshackle/tests/core/test_music_display.py
sp4rk.y 78a6a97fcf feat(music): native music core - shared helpers, album folder template, display cleanup (#125)
* Add native Music core workflow

* feat(music): add shared music core helpers, album folder template, and UX cleanup

Consolidate duplicated music-service logic into core and extend the native music workflow.

- add core/music/extract.py: shared stateless helpers (first_text, first_number, year/duration/name formatting, classify_release_kind, dedupe_track_options, build_music_from_songs) so services stop carrying their own copies
- add core/music/display.py: shared rich rendering (render_track_panel, render_album_header, render_artwork_preview) with TrackRow / MusicHeaderInfo data holders
- export the new helpers from core/music/__init__.py
- add dedicated `albums` folder template kind (output_template.folder.albums) resolving albums -> songs -> "{artist} - {album} ({year})"; whitelist music template variables (album_artist, track_total, disc_total, release_type, genre, explicit, isrc, upc, label)
- fix song filename crash: config.get_output_template(...) did not exist; use config.output_template.get("songs") with a sane default
- strip emojis from music output (renderer, dl.py music branch) to match unshackle UX; remove dead MusicSongPlan import and music_icon logic
- document the albums folder key in unshackle-example.yaml
- add tests for extract, display, and the folder template

---------

Co-authored-by: MrMovies-Dev <MrMovies-Dev@users.noreply.github.com>
2026-06-15 13:34:34 -06:00

194 lines
6.3 KiB
Python

"""Unit tests for the shared music rendering helpers in unshackle.core.music.display.
Rendering is visual, so these assert structure/type/no-crash rather than exact
ANSI. No network: the artwork soft-fail path is exercised with a session whose
get raises, and (when Pillow is absent) via the missing-dependency guard.
"""
from __future__ import annotations
from typing import Any, Optional
from rich.console import Console, RenderableType
from rich.panel import Panel
from unshackle.core.music.display import (MusicHeaderInfo, TrackRow, format_track_detail_quality, render_album_header,
render_artwork_preview, render_track_panel)
from unshackle.core.titles.music import Song
class DummyService:
"""Stand-in service class; Song only requires a type, never an instance."""
def make_song(*, track: int = 1, name: str = "Song", album: str = "Album") -> Song:
return Song(
id_=f"{album}-{track}",
service=DummyService,
name=name,
artist="Artist",
album=album,
track=track,
disc=1,
year=2020,
)
def render_to_text(renderable: RenderableType) -> str:
"""Render a rich renderable to plain text so we can assert on its content."""
console = Console(width=120, record=True, force_terminal=False)
console.print(renderable)
return console.export_text()
def test_trackrow_constructs() -> None:
song = make_song(name="Track A")
row = TrackRow(
song=song,
quality_label="FLAC 16-bit/44.1kHz",
layout="Stereo",
duration_str="3:21",
hires=True,
cd=True,
note="",
)
assert row.song is song
assert row.quality_label == "FLAC 16-bit/44.1kHz"
assert row.layout == "Stereo"
assert row.hires is True
assert row.cd is True
assert row.note == ""
def test_trackrow_defaults() -> None:
row = TrackRow(song=make_song(), quality_label="MP3 320", layout="Stereo", duration_str="2:00")
assert row.hires is False
assert row.cd is False
assert row.note == ""
def test_musicheaderinfo_constructs() -> None:
info = MusicHeaderInfo(
artist="Some Artist",
album="Some Album",
year="2021",
track_count=12,
quality_label="FLAC 24-bit/96kHz",
duration_str="42:10",
)
assert info.artist == "Some Artist"
assert info.album == "Some Album"
assert info.year == "2021"
assert info.track_count == 12
assert info.quality_label == "FLAC 24-bit/96kHz"
assert info.duration_str == "42:10"
assert info.artist_label == "Artist"
def test_format_track_detail_quality_brackets_codec() -> None:
assert format_track_detail_quality("FLAC 16-bit/44.1kHz") == "[FLAC] | 16-bit/44.1kHz"
assert format_track_detail_quality("ogg 320kbps") == "[OGG] | 320kbps"
def test_format_track_detail_quality_passthrough() -> None:
assert format_track_detail_quality("Lossless") == "Lossless"
assert format_track_detail_quality("") == ""
def test_render_track_panel_returns_panel() -> None:
rows = [
TrackRow(song=make_song(track=1, name="First"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00", cd=True),
TrackRow(song=make_song(track=2, name="Second"), quality_label="OGG 320", layout="Stereo", duration_str="4:00", hires=True),
]
panel = render_track_panel(rows, total=len(rows))
assert isinstance(panel, Panel)
text = render_to_text(panel)
assert "First" in text
assert "Second" in text
assert "2 Tracks" in text
assert "CD" in text
assert "Hi-Res" in text
def test_render_track_panel_singular_label() -> None:
rows = [TrackRow(song=make_song(name="Only"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00")]
text = render_to_text(render_track_panel(rows, total=1))
# The count node reads "1 Track" (singular); the panel title is always
# "Available Tracks", so we only check the count node phrasing here.
assert "1 Track" in text
assert "1 Tracks" not in text
def test_render_track_panel_note_marks_unavailable() -> None:
rows = [
TrackRow(
song=make_song(name="Gone"),
quality_label="FLAC",
layout="Stereo",
duration_str="3:00",
cd=True,
hires=True,
note="(unavailable in region)",
)
]
text = render_to_text(render_track_panel(rows, total=1))
assert "unavailable in region" in text
# When a note is present, the badges are suppressed.
assert "CD" not in text
assert "Hi-Res" not in text
def test_render_album_header_without_artwork() -> None:
info = MusicHeaderInfo(
artist="The Artist",
album="Greatest Hits",
year="1999",
track_count=10,
quality_label="FLAC 16-bit/44.1kHz",
duration_str="55:00",
)
header = render_album_header(info)
assert header is not None
text = render_to_text(header)
assert "The Artist" in text
assert "Greatest Hits" in text
assert "1999" in text
assert "10" in text
def test_render_album_header_with_artwork() -> None:
info = MusicHeaderInfo(
artist="Owner Name",
album="A Playlist",
year="2024",
track_count=3,
quality_label="OGG 320",
artist_label="Owner",
)
artwork = render_album_header(MusicHeaderInfo("x", "y", "1", 1, "q")) # any renderable
header = render_album_header(info, artwork=artwork)
assert header is not None
text = render_to_text(header)
assert "Owner Name" in text
assert "A Playlist" in text
assert "Owner" in text
class RaisingSession:
"""Session stub whose get() always raises, to drive the soft-fail path."""
def get(self, *args: Any, **kwargs: Any) -> Any:
raise RuntimeError("network disabled in tests")
def test_render_artwork_preview_empty_url_returns_none() -> None:
assert render_artwork_preview(RaisingSession(), "") is None
def test_render_artwork_preview_soft_fails_without_network() -> None:
# Either Pillow is absent (guard returns None) or the session.get raises
# (caught and returns None). Both paths must yield None, never raise.
result: Optional[RenderableType] = render_artwork_preview(RaisingSession(), "https://example.invalid/cover.jpg")
assert result is None