| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- """Unit tests for asot_player (stdlib unittest only)."""
- from __future__ import annotations
- import os
- import sys
- import tempfile
- import unittest
- from unittest.mock import Mock
- sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)) + "/..")
- import asot_player.main as main_mod
- from asot_player.feed import Episode
- from asot_player.id3 import Meta, parse_id3
- from asot_player.engine import FakeEngine
- from asot_player.main import Player, resolve_start
- from asot_player.state import State
- from asot_player.ui import UI, visible_len
- FIXTURE = os.environ.get("ASOT_FIXTURE", "/tmp/ep1298_head.bin")
- # --------------------------------------------------------------------------
- # id3 helpers (writer layout: junk 0x00 0x01 + BOM + UTF-16BE text; every
- # declared frame size is exactly one byte short of its content)
- # --------------------------------------------------------------------------
- def _v23_size(n: int) -> bytes:
- out = bytearray(4)
- for i in range(4):
- out[i] = (n >> (21 - 7 * i)) & 0x7F
- return bytes(out)
- def _text_content(text: str) -> bytes:
- """Writer's canonical text-frame payload: junk 0x00 0x01, an FF FE BOM byte
- pair, then the text in UTF-16LE byte order (the real writer's layout)."""
- return b"\x00\x01\xff\xfe" + text.encode("utf-16-le")
- def _frame(fid: bytes, content: bytes) -> bytes:
- """One frame honouring the writer invariant: declared size = len - 1."""
- return fid + _v23_size(len(content) - 1) + b"\x00" + content
- def build_tag(frames: list[bytes]) -> bytes:
- """A 10-byte-header ID3v2.3 tag (as emitted by the real writer: ID3 + ver
- + flags + padding byte + 4-byte size field, first frame at offset 10) with
- exactly these frames (junk gaps may be inserted as bytes between them)."""
- payload = b"".join(frames)
- return b"ID3\x03\x00\x00" + _v23_size(len(payload)) + payload
- class Id3SyntheticTests(unittest.TestCase):
- def test_synthetic_basic(self):
- frames = [
- _frame(b"TIT2", _text_content("Main Title")),
- _frame(b"TPE2", _text_content("Some Artist")),
- _frame(b"TALB", _text_content("Some Album")),
- _frame(b"TYER", b"\x00\x00\x00" + b"1999"),
- _frame(b"TXXX", _text_content("COMMENT\x00micro")),
- ]
- m = parse_id3(build_tag(frames))
- self.assertEqual(m.titles, ("Main Title",))
- self.assertEqual(m.title, "Main Title")
- self.assertEqual(m.artist, "Some Artist")
- self.assertEqual(m.album, "Some Album")
- self.assertEqual(m.year, "1999")
- self.assertEqual(m.comment, "micro")
- def test_tyer_ascii(self):
- # TYER: NUL-padded plain ASCII; content is fsz+1 bytes.
- frames = [
- b"TIT2" + _v23_size(0) + b"\x00" + _text_content("x"),
- _frame(b"TYER", b"\x00\x00\x00\x00" + b"2026"),
- ]
- m = parse_id3(build_tag(frames))
- self.assertEqual(m.year, "2026")
- def test_txxx_with_repeated_bom(self):
- # Writer repeats the BOM inside TXXX values (see real episode data);
- # key and value are both junk+BOM+UTF-16LE, like every real frame.
- content = (b"\x00\x01\xff\xfe"
- + "DESC".encode("utf-16-le") + b"\x00"
- + b"\xff\xfe"
- + "microsite".encode("utf-16-le"))
- m = parse_id3(build_tag([_frame(b"TXXX", content)]))
- self.assertIn("microsite", m.comment)
- def test_comm_fallback_with_repeated_bom(self):
- # No TXXX: comment must come from COMM; its text follows a second BOM.
- content = (b"\x00\x01eng\xff\xfe\x00\x00\xff\xfe"
- + "MiroPPB.com".encode("utf-16-le"))
- m = parse_id3(build_tag([_frame(b"COMM", content)]))
- self.assertEqual(m.comment, "MiroPPB.com")
- def test_ghost_empty_frame(self):
- # junk+BOM only (declared size 3) -> decodes to nothing, not a title.
- frames = [
- _frame(b"TIT2", b"\x00\x01\xff\xfe"),
- _frame(b"TIT2", _text_content("Real")),
- ]
- m = parse_id3(build_tag(frames))
- self.assertEqual(m.titles, ("Real",), f"titles={m.titles!r}")
- def test_odd_utf16_body_does_not_crash(self):
- frames = [_frame(b"TIT2", b"\x00\x01\xff\xfe\x41")] # odd-length body
- m = parse_id3(build_tag(frames))
- self.assertEqual(m.title, "")
- def test_no_header_or_bad_major(self):
- self.assertEqual(parse_id3(b""), Meta())
- self.assertEqual(parse_id3(b"NOTAG").title, "")
- m = parse_id3(b"ID3\x02\x00\x00\x00\x00" + b"\x00" * 4)
- self.assertEqual(m.title, "")
- def test_frames_scanned_among_junk_gaps(self):
- frames = [
- b"\x00" * 5,
- _frame(b"TPE2", _text_content("Gap Artist")),
- bytes([0xaa] * 37),
- _frame(b"TIT2", _text_content("Later Title")),
- ]
- m = parse_id3(build_tag(frames))
- self.assertIn("Later Title", m.titles)
- self.assertEqual(m.artist, "Gap Artist")
- def test_display_fallbacks(self):
- m = Meta(titles=("A Song",), artist="The Band")
- self.assertEqual(m.display, "The Band - A Song")
- m = Meta(titles=("One", "Two"), artist="The Band", album="Records", year="2026")
- self.assertEqual(m.display, "The Band - Records (2026)")
- self.assertEqual(Meta(artist="Solo").display, "Solo")
- self.assertEqual(Meta().display, "")
- self.assertEqual(Meta(titles=("X", "X", ""), artist="A").display, "A - X")
- # no artist, several tracks -> album/year fallback
- self.assertEqual(Meta(titles=("A", "B"), album="Alb").display, "Alb")
- def test_real_head_fixture(self):
- """Parse the 1 MiB real fixture of ASOT_Ep_1298 (saved from miroppb)."""
- if not os.path.isfile(FIXTURE):
- self.skipTest("fixture not available in this environment")
- with open(FIXTURE, "rb") as f:
- m = parse_id3(f.read())
- self.assertGreaterEqual(len(m.titles), 35)
- self.assertEqual(m.artist, "Armin van Buuren")
- self.assertEqual(m.album, "A State of Trance")
- self.assertEqual(m.year, "2026")
- self.assertIn("MiroPPB.com", m.comment or "")
- self.assertTrue("Episode 1298" in " ".join(m.titles))
- # --------------------------------------------------------------------------
- # resolve_start (main.py)
- # --------------------------------------------------------------------------
- def fake_eps():
- return [
- Episode(number=1301, url="https://miroppb.com/b/rss/ASOT_Ep_1301.mp3"),
- Episode(number=1300, url="https://miroppb.com/b/rss/ASOT_Ep_1300.mp3"),
- Episode(number=1299, url="https://miroppb.com/b/rss/ASOT_Ep_1299.mp3"),
- Episode(number=1298, url="https://miroppb.com/b/rss/ASOT_Ep_1298.mp3"),
- ]
- class ResolveStartTests(unittest.TestCase):
- def setUp(self):
- self.eps = fake_eps()
- def test_explicit_episode_found(self):
- ep, resume, pos = resolve_start(self.eps, None, 1299)
- self.assertEqual(ep.number, 1299)
- self.assertFalse(resume)
- self.assertEqual(pos, 0.0)
- def test_explicit_episode_missing_constructed(self):
- ep, resume, pos = resolve_start(self.eps, None, 1999)
- self.assertEqual(ep.number, 1999)
- self.assertFalse(resume)
- self.assertIn("1999", ep.url)
- self.assertTrue(ep.url.endswith(".mp3"), f"bad URL: {ep.url!r}")
- def test_state_resumes_matching_episode(self):
- st = State()
- st.ep = 1298
- st.url = self.eps[-1].url
- st.position = 28.0
- st.length = 30.0
- ep, resume, pos = resolve_start(self.eps, st, None)
- self.assertEqual(ep.number, 1298)
- self.assertTrue(resume)
- # clamped to length - 5 so we don't seek into the last seconds
- self.assertEqual(pos, 25.0)
- def test_state_short_length_starts_clean(self):
- # length <= 5 s (fake-engine size): marked as resuming but position
- # is reset to 0 so playback starts clean.
- st = State()
- st.ep = 1298
- st.url = self.eps[-1].url
- st.position = 2.0
- st.length = 3.0
- ep, resume, pos = resolve_start(self.eps, st, None)
- self.assertEqual(ep.number, 1298)
- self.assertTrue(resume)
- self.assertEqual(pos, 0.0)
- def test_state_not_in_feed_falls_back(self):
- st = State()
- st.ep = 500 # not present (feed was rebuilt without it)
- ep, resume, _ = resolve_start(self.eps, st, None)
- self.assertEqual(ep.number, self.eps[0].number)
- self.assertFalse(resume)
- def test_position_zero_no_resume(self):
- st = State()
- st.ep = 1298
- st.url = self.eps[-1].url
- st.position = 0.0
- _, resume, _ = resolve_start(self.eps, st, None)
- self.assertFalse(resume)
- # --------------------------------------------------------------------------
- # Player.advance (direction + edge behaviour)
- # --------------------------------------------------------------------------
- class PlayerAdvanceTests(unittest.TestCase):
- def setUp(self):
- # patch must span the whole test body, not just setUp: _load() runs
- # inside test methods, so the module attribute has to stay mocked.
- self._orig_fetch_meta = main_mod.fetch_meta
- main_mod.fetch_meta = Mock(return_value=Meta())
- self.episodes = [
- Episode(number=1301, url="https://x/1301.mp3"),
- Episode(number=1300, url="https://x/1300.mp3"),
- Episode(number=1299, url="https://x/1299.mp3"),
- Episode(number=1298, url="https://x/1298.mp3"),
- ]
- self.p = Player(eps=self.episodes, engine=FakeEngine("u", 3.0),
- ui=UI(False, 56, 40), state=State())
- def tearDown(self):
- main_mod.fetch_meta = self._orig_fetch_meta
- del self._orig_fetch_meta
- def load(self, number):
- for e in self.p.eps:
- if e.number == number:
- self.p._load(e, "test")
- break
- return self.p
- def test_next_and_prev_by_number(self):
- self.load(1299)
- self.assertTrue(self.p.advance(+1))
- self.assertEqual(self.p.ep.number, 1300)
- self.assertTrue(self.p.advance(-1))
- self.assertEqual(self.p.ep.number, 1299)
- self.assertTrue(self.p.advance(-1))
- self.assertEqual(self.p.ep.number, 1298)
- self.assertTrue(self.p.advance(+1))
- self.assertEqual(self.p.ep.number, 1299)
- def test_edge_end_of_feed(self):
- self.load(1301)
- self.assertFalse(self.p.advance(+1))
- self.assertEqual(self.p.ep.number, 1301)
- self.load(1298)
- self.assertFalse(self.p.advance(-1))
- self.assertEqual(self.p.ep.number, 1298)
- def test_msg_override_on_advance(self):
- self.load(1299)
- self.p.advance(+1)
- self.assertIn("ASOT 1300", self.p.msg_override)
- self.assertTrue("next" in self.p.msg_override.lower())
- # --------------------------------------------------------------------------
- # FakeEngine behaviour
- # --------------------------------------------------------------------------
- class FakeEngineTests(unittest.TestCase):
- @staticmethod
- def make(speed=1.0, length=3.0):
- return FakeEngine("u", length=length, speed=speed)
- def test_position_advances_per_status(self):
- e = self.make(speed=2.5, length=10.0)
- e.play("u")
- self.assertEqual(e.status().position, 2.5)
- self.assertEqual(e.status().position, 5.0)
- def test_eof_reached_and_clamped(self):
- e = self.make(length=3.0)
- e.play("u")
- e.status() # 1
- e.status() # 2
- s = e.status()
- self.assertEqual(s.state, "eof")
- self.assertEqual(s.position, 3.0)
- def test_buffering_then_playing(self):
- e = self.make(length=10.0)
- e.play("u")
- self.assertIn(e.status().state, ("buffering",))
- self.assertEqual(e.status().state, "playing")
- def test_seeks_clamp(self):
- # speed 0 -> status() does not advance the position, keeps asserts exact
- e = self.make(length=5.0, speed=0.0)
- e.play("u")
- e.seek(999.0)
- self.assertEqual(e.status().position, 5.0)
- e.seek(-10.0)
- self.assertEqual(e.status().position, 0.0)
- e.seek(2.0)
- self.assertEqual(e.status().position, 2.0)
- self.assertEqual(e.seek_count, 3)
- def test_volume_clamps(self):
- e = self.make()
- e.play("u")
- e.set_volume(1.4)
- self.assertEqual(e.status().volume, 1.0)
- e.set_volume(-0.2)
- self.assertEqual(e.status().volume, 0.0)
- e.set_volume(0.75)
- self.assertAlmostEqual(e.status().volume, 0.75)
- def test_seek_to_on_play(self):
- # speed 0 -> no implicit advance between play() and status()
- e = self.make(length=10.0, speed=0.0)
- e.play("u", seek_to=4.0)
- self.assertEqual(e.status().position, 4.0)
- e.play("u", seek_to=99.0) # clamped to length
- self.assertEqual(e.status().position, 10.0)
- def test_stop_resets_url_tracking(self):
- e = self.make()
- e.play("u")
- e.stop()
- self.assertEqual(e.state, "stop")
- self.assertIsNone(e.played_url)
- # --------------------------------------------------------------------------
- # State: round-trip / corrupt file / env dir
- # --------------------------------------------------------------------------
- class StateTests(unittest.TestCase):
- def setUp(self):
- self._dir = tempfile.TemporaryDirectory()
- self._saved = os.environ.pop("ASOT_STATE_DIR", None)
- os.environ["ASOT_STATE_DIR"] = self._dir.name
- def tearDown(self):
- del os.environ["ASOT_STATE_DIR"]
- if self._saved is not None:
- os.environ["ASOT_STATE_DIR"] = self._saved
- self._dir.cleanup()
- def test_roundtrip(self):
- st = State(ep=1298, url="https://x/1.mp3", position=21.5, length=100.0,
- paused=False, muted=True, volume=0.42)
- st.save()
- loaded = State.load()
- self.assertIsNotNone(loaded)
- self.assertEqual(loaded.ep, 1298)
- self.assertEqual(loaded.url, "https://x/1.mp3")
- self.assertAlmostEqual(loaded.position, 21.5)
- self.assertTrue(loaded.muted)
- self.assertAlmostEqual(loaded.volume, 0.42)
- self.assertTrue(loaded.updated_at)
- def test_load_corrupt_file_returns_none(self):
- State().save()
- State.state_file().write_text("not json at all {{{{", encoding="utf-8")
- self.assertIsNone(State.load())
- def test_load_empty_file_returns_none(self):
- State().save()
- State.state_file().write_text("", encoding="utf-8")
- self.assertIsNone(State.load())
- def test_load_missing_returns_none(self):
- self.assertIsNone(State.load())
- def test_from_raw_defaults_and_clamps(self):
- st = State.from_raw({"ep": 42, "volume": 3.0, "position": 5,
- "length": "1.0"})
- self.assertEqual(st.ep, 42)
- self.assertEqual(st.volume, 1.0)
- self.assertEqual(st.position, 5.0)
- self.assertEqual(st.length, 1.0)
- def test_clear(self):
- State().save()
- self.assertTrue(os.path.exists(State.state_file()))
- self.assertTrue(State.clear())
- self.assertFalse(os.path.exists(State.state_file()))
- self.assertFalse(State.clear())
- # --------------------------------------------------------------------------
- # UI: every line is exactly `width` visible characters in both colour modes
- # --------------------------------------------------------------------------
- def rendered_lines(ui, song, date, status, kind, position, length, volume):
- out = ui.render(1298, song, date, status, kind, position, length, volume, False)
- assert out.startswith("\x1b[H")
- return out[len("\x1b[H"):].split("\n") # last element is "" (trailing newline)
- class UITests(unittest.TestCase):
- @staticmethod
- def widths_and_colors():
- for w in (52, 53, 78, 120):
- for color in (True, False):
- yield UI(color, w, 40), w
- def test_line_lengths_exact(self):
- for ui, w in self.widths_and_colors():
- lines = [ui.episode_line(1298, "PLAYING", "ok"),
- ui.episode_line(1298, "Finished — end of feed at ASOT 1301", "info"),
- ui.song_line("", ""),
- ui.song_line("Armin van Buuren - A State of Trance (2026) Extra Long",
- "07 Feb 2026"),
- ui.bar_line(None, None, 0.5, False),
- ui.bar_line(0.0, 0.0, 0.5, False),
- ui.bar_line(1234.5, 7200.0, 0.8, True),
- ui.hint_line()]
- for ln in lines:
- self.assertEqual(visible_len(ln), w, f"visible len != width: {ln!r}")
- def test_rendered_block_lengths_exact(self):
- for ui, w in self.widths_and_colors():
- body = rendered_lines(ui, "Armin - A Long Song Title That Keeps Going",
- "07 Feb 2026", "Buffering", "buffer",
- 333.5, 7200.0, 0.85)
- for ln in body[:-1]:
- self.assertEqual(visible_len(ln), w, f"visible len != width: {ln!r}")
- def test_plain_mode_no_escapes(self):
- ui = UI(False, 80, 40)
- out = ui.plain_render(9, "s", "d", "ok", "ok", 1.0, 10.0, 0.5, False)
- self.assertNotIn("\x1b", out)
- lines = [l for l in out.splitlines() if l]
- self.assertEqual(len(lines), 4)
- self.assertTrue(all(len(l) == 80 for l in lines))
- if __name__ == "__main__":
- unittest.main(verbosity=2)
|