test_asot.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. """Unit tests for asot_player (stdlib unittest only)."""
  2. from __future__ import annotations
  3. import os
  4. import re
  5. import sys
  6. import re
  7. import tempfile
  8. import unittest
  9. from unittest.mock import Mock
  10. sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)) + "/..")
  11. import asot_player.main as main_mod
  12. from asot_player.feed import Episode
  13. from asot_player.id3 import Meta, parse_id3
  14. from asot_player.engine import FakeEngine
  15. from asot_player.main import Player, resolve_start
  16. from asot_player.state import State
  17. from asot_player.ui import UI, visible_len
  18. FIXTURE = os.environ.get("ASOT_FIXTURE", "/tmp/ep1298_head.bin")
  19. # --------------------------------------------------------------------------
  20. # id3 helpers (writer layout: junk 0x00 0x01 + BOM + UTF-16BE text; every
  21. # declared frame size is exactly one byte short of its content)
  22. # --------------------------------------------------------------------------
  23. def _v23_size(n: int) -> bytes:
  24. out = bytearray(4)
  25. for i in range(4):
  26. out[i] = (n >> (21 - 7 * i)) & 0x7F
  27. return bytes(out)
  28. def _text_content(text: str) -> bytes:
  29. """Writer's canonical text-frame payload: junk 0x00 0x01, an FF FE BOM byte
  30. pair, then the text in UTF-16LE byte order (the real writer's layout)."""
  31. return b"\x00\x01\xff\xfe" + text.encode("utf-16-le")
  32. def _frame(fid: bytes, content: bytes) -> bytes:
  33. """One frame honouring the writer invariant: declared size = len - 1."""
  34. return fid + _v23_size(len(content) - 1) + b"\x00" + content
  35. def build_tag(frames: list[bytes]) -> bytes:
  36. """A 10-byte-header ID3v2.3 tag (as emitted by the real writer: ID3 + ver
  37. + flags + padding byte + 4-byte size field, first frame at offset 10) with
  38. exactly these frames (junk gaps may be inserted as bytes between them)."""
  39. payload = b"".join(frames)
  40. return b"ID3\x03\x00\x00" + _v23_size(len(payload)) + payload
  41. class Id3SyntheticTests(unittest.TestCase):
  42. def test_synthetic_basic(self):
  43. frames = [
  44. _frame(b"TIT2", _text_content("Main Title")),
  45. _frame(b"TPE2", _text_content("Some Artist")),
  46. _frame(b"TALB", _text_content("Some Album")),
  47. _frame(b"TYER", b"\x00\x00\x00" + b"1999"),
  48. _frame(b"TXXX", _text_content("COMMENT\x00micro")),
  49. ]
  50. m = parse_id3(build_tag(frames))
  51. self.assertEqual(m.titles, ("Main Title",))
  52. self.assertEqual(m.title, "Main Title")
  53. self.assertEqual(m.artist, "Some Artist")
  54. self.assertEqual(m.album, "Some Album")
  55. self.assertEqual(m.year, "1999")
  56. self.assertEqual(m.comment, "micro")
  57. def test_tyer_ascii(self):
  58. # TYER: NUL-padded plain ASCII; content is fsz+1 bytes.
  59. frames = [
  60. b"TIT2" + _v23_size(0) + b"\x00" + _text_content("x"),
  61. _frame(b"TYER", b"\x00\x00\x00\x00" + b"2026"),
  62. ]
  63. m = parse_id3(build_tag(frames))
  64. self.assertEqual(m.year, "2026")
  65. def test_txxx_with_repeated_bom(self):
  66. # Writer repeats the BOM inside TXXX values (see real episode data);
  67. # key and value are both junk+BOM+UTF-16LE, like every real frame.
  68. content = (b"\x00\x01\xff\xfe"
  69. + "DESC".encode("utf-16-le") + b"\x00"
  70. + b"\xff\xfe"
  71. + "microsite".encode("utf-16-le"))
  72. m = parse_id3(build_tag([_frame(b"TXXX", content)]))
  73. self.assertIn("microsite", m.comment)
  74. def test_comm_fallback_with_repeated_bom(self):
  75. # No TXXX: comment must come from COMM; its text follows a second BOM.
  76. content = (b"\x00\x01eng\xff\xfe\x00\x00\xff\xfe"
  77. + "MiroPPB.com".encode("utf-16-le"))
  78. m = parse_id3(build_tag([_frame(b"COMM", content)]))
  79. self.assertEqual(m.comment, "MiroPPB.com")
  80. def test_ghost_empty_frame(self):
  81. # junk+BOM only (declared size 3) -> decodes to nothing, not a title.
  82. frames = [
  83. _frame(b"TIT2", b"\x00\x01\xff\xfe"),
  84. _frame(b"TIT2", _text_content("Real")),
  85. ]
  86. m = parse_id3(build_tag(frames))
  87. self.assertEqual(m.titles, ("Real",), f"titles={m.titles!r}")
  88. def test_odd_utf16_body_does_not_crash(self):
  89. frames = [_frame(b"TIT2", b"\x00\x01\xff\xfe\x41")] # odd-length body
  90. m = parse_id3(build_tag(frames))
  91. self.assertEqual(m.title, "")
  92. def test_no_header_or_bad_major(self):
  93. self.assertEqual(parse_id3(b""), Meta())
  94. self.assertEqual(parse_id3(b"NOTAG").title, "")
  95. m = parse_id3(b"ID3\x02\x00\x00\x00\x00" + b"\x00" * 4)
  96. self.assertEqual(m.title, "")
  97. def test_frames_scanned_among_junk_gaps(self):
  98. frames = [
  99. b"\x00" * 5,
  100. _frame(b"TPE2", _text_content("Gap Artist")),
  101. bytes([0xaa] * 37),
  102. _frame(b"TIT2", _text_content("Later Title")),
  103. ]
  104. m = parse_id3(build_tag(frames))
  105. self.assertIn("Later Title", m.titles)
  106. self.assertEqual(m.artist, "Gap Artist")
  107. def test_display_fallbacks(self):
  108. m = Meta(titles=("A Song",), artist="The Band")
  109. self.assertEqual(m.display, "The Band - A Song")
  110. m = Meta(titles=("One", "Two"), artist="The Band", album="Records", year="2026")
  111. self.assertEqual(m.display, "The Band - Records (2026)")
  112. self.assertEqual(Meta(artist="Solo").display, "Solo")
  113. self.assertEqual(Meta().display, "")
  114. self.assertEqual(Meta(titles=("X", "X", ""), artist="A").display, "A - X")
  115. # no artist, several tracks -> album/year fallback
  116. self.assertEqual(Meta(titles=("A", "B"), album="Alb").display, "Alb")
  117. def test_real_head_fixture(self):
  118. """Parse the 1 MiB real fixture of ASOT_Ep_1298 (saved from miroppb)."""
  119. if not os.path.isfile(FIXTURE):
  120. self.skipTest("fixture not available in this environment")
  121. with open(FIXTURE, "rb") as f:
  122. m = parse_id3(f.read())
  123. self.assertGreaterEqual(len(m.titles), 35)
  124. self.assertEqual(m.artist, "Armin van Buuren")
  125. self.assertEqual(m.album, "A State of Trance")
  126. self.assertEqual(m.year, "2026")
  127. self.assertIn("MiroPPB.com", m.comment or "")
  128. self.assertTrue("Episode 1298" in " ".join(m.titles))
  129. # --------------------------------------------------------------------------
  130. # resolve_start (main.py)
  131. # --------------------------------------------------------------------------
  132. def fake_eps():
  133. return [
  134. Episode(number=1301, url="https://miroppb.com/b/rss/ASOT_Ep_1301.mp3"),
  135. Episode(number=1300, url="https://miroppb.com/b/rss/ASOT_Ep_1300.mp3"),
  136. Episode(number=1299, url="https://miroppb.com/b/rss/ASOT_Ep_1299.mp3"),
  137. Episode(number=1298, url="https://miroppb.com/b/rss/ASOT_Ep_1298.mp3"),
  138. ]
  139. class ResolveStartTests(unittest.TestCase):
  140. def setUp(self):
  141. self.eps = fake_eps()
  142. def test_explicit_episode_found(self):
  143. ep, resume, pos = resolve_start(self.eps, None, 1299)
  144. self.assertEqual(ep.number, 1299)
  145. self.assertFalse(resume)
  146. self.assertEqual(pos, 0.0)
  147. def test_explicit_episode_missing_constructed(self):
  148. ep, resume, pos = resolve_start(self.eps, None, 1999)
  149. self.assertEqual(ep.number, 1999)
  150. self.assertFalse(resume)
  151. self.assertIn("1999", ep.url)
  152. self.assertTrue(ep.url.endswith(".mp3"), f"bad URL: {ep.url!r}")
  153. def test_state_resumes_matching_episode(self):
  154. st = State()
  155. st.ep = 1298
  156. st.url = self.eps[-1].url
  157. st.position = 28.0
  158. st.length = 30.0
  159. ep, resume, pos = resolve_start(self.eps, st, None)
  160. self.assertEqual(ep.number, 1298)
  161. self.assertTrue(resume)
  162. # clamped to length - 5 so we don't seek into the last seconds
  163. self.assertEqual(pos, 25.0)
  164. def test_state_short_length_starts_clean(self):
  165. # length <= 5 s (fake-engine size): marked as resuming but position
  166. # is reset to 0 so playback starts clean.
  167. st = State()
  168. st.ep = 1298
  169. st.url = self.eps[-1].url
  170. st.position = 2.0
  171. st.length = 3.0
  172. ep, resume, pos = resolve_start(self.eps, st, None)
  173. self.assertEqual(ep.number, 1298)
  174. self.assertTrue(resume)
  175. self.assertEqual(pos, 0.0)
  176. def test_state_not_in_feed_falls_back(self):
  177. st = State()
  178. st.ep = 500 # not present (feed was rebuilt without it)
  179. ep, resume, _ = resolve_start(self.eps, st, None)
  180. self.assertEqual(ep.number, self.eps[0].number)
  181. self.assertFalse(resume)
  182. def test_position_zero_no_resume(self):
  183. st = State()
  184. st.ep = 1298
  185. st.url = self.eps[-1].url
  186. st.position = 0.0
  187. _, resume, _ = resolve_start(self.eps, st, None)
  188. self.assertFalse(resume)
  189. # --------------------------------------------------------------------------
  190. # Player.advance (direction + edge behaviour)
  191. # --------------------------------------------------------------------------
  192. class PlayerAdvanceTests(unittest.TestCase):
  193. def setUp(self):
  194. # patch must span the whole test body, not just setUp: _load() runs
  195. # inside test methods, so the module attribute has to stay mocked.
  196. self._orig_fetch_meta = main_mod.fetch_meta
  197. main_mod.fetch_meta = Mock(return_value=Meta())
  198. self.episodes = [
  199. Episode(number=1301, url="https://x/1301.mp3"),
  200. Episode(number=1300, url="https://x/1300.mp3"),
  201. Episode(number=1299, url="https://x/1299.mp3"),
  202. Episode(number=1298, url="https://x/1298.mp3"),
  203. ]
  204. self.p = Player(eps=self.episodes, engine=FakeEngine("u", 3.0),
  205. ui=UI(False, 56, 40), state=State())
  206. def tearDown(self):
  207. main_mod.fetch_meta = self._orig_fetch_meta
  208. del self._orig_fetch_meta
  209. def load(self, number):
  210. for e in self.p.eps:
  211. if e.number == number:
  212. self.p._load(e, "test")
  213. break
  214. return self.p
  215. def test_next_and_prev_by_number(self):
  216. self.load(1299)
  217. self.assertTrue(self.p.advance(+1))
  218. self.assertEqual(self.p.ep.number, 1300)
  219. self.assertTrue(self.p.advance(-1))
  220. self.assertEqual(self.p.ep.number, 1299)
  221. self.assertTrue(self.p.advance(-1))
  222. self.assertEqual(self.p.ep.number, 1298)
  223. self.assertTrue(self.p.advance(+1))
  224. self.assertEqual(self.p.ep.number, 1299)
  225. def test_edge_end_of_feed(self):
  226. self.load(1301)
  227. self.assertFalse(self.p.advance(+1))
  228. self.assertEqual(self.p.ep.number, 1301)
  229. self.load(1298)
  230. self.assertFalse(self.p.advance(-1))
  231. self.assertEqual(self.p.ep.number, 1298)
  232. def test_msg_override_on_advance(self):
  233. self.load(1299)
  234. self.p.advance(+1)
  235. self.assertIn("ASOT 1300", self.p.msg_override)
  236. self.assertTrue("next" in self.p.msg_override.lower())
  237. # --------------------------------------------------------------------------
  238. # FakeEngine behaviour
  239. # --------------------------------------------------------------------------
  240. class FakeEngineTests(unittest.TestCase):
  241. @staticmethod
  242. def make(speed=1.0, length=3.0):
  243. return FakeEngine("u", length=length, speed=speed)
  244. def test_position_advances_per_status(self):
  245. e = self.make(speed=2.5, length=10.0)
  246. e.play("u")
  247. self.assertEqual(e.status().position, 2.5)
  248. self.assertEqual(e.status().position, 5.0)
  249. def test_eof_reached_and_clamped(self):
  250. e = self.make(length=3.0)
  251. e.play("u")
  252. e.status() # 1
  253. e.status() # 2
  254. s = e.status()
  255. self.assertEqual(s.state, "eof")
  256. self.assertEqual(s.position, 3.0)
  257. def test_buffering_then_playing(self):
  258. e = self.make(length=10.0)
  259. e.play("u")
  260. self.assertIn(e.status().state, ("buffering",))
  261. self.assertEqual(e.status().state, "playing")
  262. def test_seeks_clamp(self):
  263. # speed 0 -> status() does not advance the position, keeps asserts exact
  264. e = self.make(length=5.0, speed=0.0)
  265. e.play("u")
  266. e.seek(999.0)
  267. self.assertEqual(e.status().position, 5.0)
  268. e.seek(-10.0)
  269. self.assertEqual(e.status().position, 0.0)
  270. e.seek(2.0)
  271. self.assertEqual(e.status().position, 2.0)
  272. self.assertEqual(e.seek_count, 3)
  273. def test_volume_clamps(self):
  274. e = self.make()
  275. e.play("u")
  276. e.set_volume(1.4)
  277. self.assertEqual(e.status().volume, 1.0)
  278. e.set_volume(-0.2)
  279. self.assertEqual(e.status().volume, 0.0)
  280. e.set_volume(0.75)
  281. self.assertAlmostEqual(e.status().volume, 0.75)
  282. def test_seek_to_on_play(self):
  283. # speed 0 -> no implicit advance between play() and status()
  284. e = self.make(length=10.0, speed=0.0)
  285. e.play("u", seek_to=4.0)
  286. self.assertEqual(e.status().position, 4.0)
  287. e.play("u", seek_to=99.0) # clamped to length
  288. self.assertEqual(e.status().position, 10.0)
  289. def test_stop_resets_url_tracking(self):
  290. e = self.make()
  291. e.play("u")
  292. e.stop()
  293. self.assertEqual(e.state, "stop")
  294. self.assertIsNone(e.played_url)
  295. # --------------------------------------------------------------------------
  296. # State: round-trip / corrupt file / env dir
  297. # --------------------------------------------------------------------------
  298. class StateTests(unittest.TestCase):
  299. def setUp(self):
  300. self._dir = tempfile.TemporaryDirectory()
  301. self._saved = os.environ.pop("ASOT_STATE_DIR", None)
  302. os.environ["ASOT_STATE_DIR"] = self._dir.name
  303. def tearDown(self):
  304. del os.environ["ASOT_STATE_DIR"]
  305. if self._saved is not None:
  306. os.environ["ASOT_STATE_DIR"] = self._saved
  307. self._dir.cleanup()
  308. def test_roundtrip(self):
  309. st = State(ep=1298, url="https://x/1.mp3", position=21.5, length=100.0,
  310. paused=False, muted=True, volume=0.42)
  311. st.save()
  312. loaded = State.load()
  313. self.assertIsNotNone(loaded)
  314. self.assertEqual(loaded.ep, 1298)
  315. self.assertEqual(loaded.url, "https://x/1.mp3")
  316. self.assertAlmostEqual(loaded.position, 21.5)
  317. self.assertTrue(loaded.muted)
  318. self.assertAlmostEqual(loaded.volume, 0.42)
  319. self.assertTrue(loaded.updated_at)
  320. def test_load_corrupt_file_returns_none(self):
  321. State().save()
  322. State.state_file().write_text("not json at all {{{{", encoding="utf-8")
  323. self.assertIsNone(State.load())
  324. def test_load_empty_file_returns_none(self):
  325. State().save()
  326. State.state_file().write_text("", encoding="utf-8")
  327. self.assertIsNone(State.load())
  328. def test_load_missing_returns_none(self):
  329. self.assertIsNone(State.load())
  330. def test_from_raw_defaults_and_clamps(self):
  331. st = State.from_raw({"ep": 42, "volume": 3.0, "position": 5,
  332. "length": "1.0"})
  333. self.assertEqual(st.ep, 42)
  334. self.assertEqual(st.volume, 1.0)
  335. self.assertEqual(st.position, 5.0)
  336. self.assertEqual(st.length, 1.0)
  337. def test_clear(self):
  338. State().save()
  339. self.assertTrue(os.path.exists(State.state_file()))
  340. self.assertTrue(State.clear())
  341. self.assertFalse(os.path.exists(State.state_file()))
  342. self.assertFalse(State.clear())
  343. # --------------------------------------------------------------------------
  344. # UI: every line is exactly `width` visible characters in both colour modes
  345. # --------------------------------------------------------------------------
  346. def rendered_lines(ui, date, status, kind, position, length, volume):
  347. out = ui.render(1298, date, status, kind, position, length, volume, False)
  348. body = re.sub(r"^\x1b\[\d+;1H", "", out).rstrip("\n") # strip prefix + trailing LF
  349. return body.split("\n")
  350. class UITests(unittest.TestCase):
  351. @staticmethod
  352. def widths_and_colors():
  353. for w in (52, 53, 78, 120):
  354. for color in (True, False):
  355. yield UI(color, w, 40), w
  356. def test_line_lengths_exact(self):
  357. for ui, w in self.widths_and_colors():
  358. lines = [ui.title_line(1298, "PLAYING", "ok", "07 Feb 2026"),
  359. ui.title_line(1298, "Finished — end of feed at ASOT 1301", "info", ""),
  360. ui.title_line(1298, "", "ok", ""),
  361. ui.bar_line(None, None, 0.5, False),
  362. ui.bar_line(0.0, 0.0, 0.5, False),
  363. ui.bar_line(1234.5, 7200.0, 0.8, True),
  364. ui.hint_line()]
  365. for ln in lines:
  366. self.assertEqual(visible_len(ln), w, f"visible len != width: {ln!r}")
  367. def test_rendered_block_lengths_exact(self):
  368. for ui, w in self.widths_and_colors():
  369. body = rendered_lines(ui, "07 Feb 2026", "Buffering", "buffer",
  370. 333.5, 7200.0, 0.85)
  371. for ln in body:
  372. self.assertEqual(visible_len(ln), w, f"visible len != width: {ln!r}")
  373. def test_plain_mode_no_escapes(self):
  374. ui = UI(False, 80, 40)
  375. out = ui.plain_render(9, "d", "ok", "ok", 1.0, 10.0, 0.5, False)
  376. self.assertNotIn("\x1b", out)
  377. lines = [l for l in out.splitlines() if l]
  378. self.assertEqual(len(lines), 3)
  379. self.assertTrue(all(len(l) == 80 for l in lines))
  380. if __name__ == "__main__":
  381. unittest.main(verbosity=2)