test_asot.py 20 KB

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