test_asot.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. """Unit tests for asot_player (stdlib unittest only)."""
  2. from __future__ import annotations
  3. import contextlib
  4. import io
  5. import os
  6. import re
  7. import sys
  8. import re
  9. import tempfile
  10. import unittest
  11. from unittest.mock import Mock
  12. sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)) + "/..")
  13. import asot_player.main as main_mod
  14. import asot_player.engine as engine_mod
  15. import asot_player.ui as ui_mod
  16. from asot_player.feed import Episode
  17. from asot_player.id3 import Meta, parse_id3
  18. from asot_player.engine import FakeEngine, MpEngine
  19. from asot_player.main import Player, resolve_start
  20. from asot_player.state import State
  21. from asot_player.ui import UI, visible_len
  22. FIXTURE = os.environ.get("ASOT_FIXTURE", "/tmp/ep1298_head.bin")
  23. # --------------------------------------------------------------------------
  24. # id3 helpers (writer layout: junk 0x00 0x01 + BOM + UTF-16BE text; every
  25. # declared frame size is exactly one byte short of its content)
  26. # --------------------------------------------------------------------------
  27. def _v23_size(n: int) -> bytes:
  28. out = bytearray(4)
  29. for i in range(4):
  30. out[i] = (n >> (21 - 7 * i)) & 0x7F
  31. return bytes(out)
  32. def _text_content(text: str) -> bytes:
  33. """Writer's canonical text-frame payload: junk 0x00 0x01, an FF FE BOM byte
  34. pair, then the text in UTF-16LE byte order (the real writer's layout)."""
  35. return b"\x00\x01\xff\xfe" + text.encode("utf-16-le")
  36. def _frame(fid: bytes, content: bytes) -> bytes:
  37. """One frame honouring the writer invariant: declared size = len - 1."""
  38. return fid + _v23_size(len(content) - 1) + b"\x00" + content
  39. def build_tag(frames: list[bytes]) -> bytes:
  40. """A 10-byte-header ID3v2.3 tag (as emitted by the real writer: ID3 + ver
  41. + flags + padding byte + 4-byte size field, first frame at offset 10) with
  42. exactly these frames (junk gaps may be inserted as bytes between them)."""
  43. payload = b"".join(frames)
  44. return b"ID3\x03\x00\x00" + _v23_size(len(payload)) + payload
  45. class Id3SyntheticTests(unittest.TestCase):
  46. def test_synthetic_basic(self):
  47. frames = [
  48. _frame(b"TIT2", _text_content("Main Title")),
  49. _frame(b"TPE2", _text_content("Some Artist")),
  50. _frame(b"TALB", _text_content("Some Album")),
  51. _frame(b"TYER", b"\x00\x00\x00" + b"1999"),
  52. _frame(b"TXXX", _text_content("COMMENT\x00micro")),
  53. ]
  54. m = parse_id3(build_tag(frames))
  55. self.assertEqual(m.titles, ("Main Title",))
  56. self.assertEqual(m.title, "Main Title")
  57. self.assertEqual(m.artist, "Some Artist")
  58. self.assertEqual(m.album, "Some Album")
  59. self.assertEqual(m.year, "1999")
  60. self.assertEqual(m.comment, "micro")
  61. def test_tyer_ascii(self):
  62. # TYER: NUL-padded plain ASCII; content is fsz+1 bytes.
  63. frames = [
  64. b"TIT2" + _v23_size(0) + b"\x00" + _text_content("x"),
  65. _frame(b"TYER", b"\x00\x00\x00\x00" + b"2026"),
  66. ]
  67. m = parse_id3(build_tag(frames))
  68. self.assertEqual(m.year, "2026")
  69. def test_txxx_with_repeated_bom(self):
  70. # Writer repeats the BOM inside TXXX values (see real episode data);
  71. # key and value are both junk+BOM+UTF-16LE, like every real frame.
  72. content = (b"\x00\x01\xff\xfe"
  73. + "DESC".encode("utf-16-le") + b"\x00"
  74. + b"\xff\xfe"
  75. + "microsite".encode("utf-16-le"))
  76. m = parse_id3(build_tag([_frame(b"TXXX", content)]))
  77. self.assertIn("microsite", m.comment)
  78. def test_comm_fallback_with_repeated_bom(self):
  79. # No TXXX: comment must come from COMM; its text follows a second BOM.
  80. content = (b"\x00\x01eng\xff\xfe\x00\x00\xff\xfe"
  81. + "MiroPPB.com".encode("utf-16-le"))
  82. m = parse_id3(build_tag([_frame(b"COMM", content)]))
  83. self.assertEqual(m.comment, "MiroPPB.com")
  84. def test_ghost_empty_frame(self):
  85. # junk+BOM only (declared size 3) -> decodes to nothing, not a title.
  86. frames = [
  87. _frame(b"TIT2", b"\x00\x01\xff\xfe"),
  88. _frame(b"TIT2", _text_content("Real")),
  89. ]
  90. m = parse_id3(build_tag(frames))
  91. self.assertEqual(m.titles, ("Real",), f"titles={m.titles!r}")
  92. def test_odd_utf16_body_does_not_crash(self):
  93. frames = [_frame(b"TIT2", b"\x00\x01\xff\xfe\x41")] # odd-length body
  94. m = parse_id3(build_tag(frames))
  95. self.assertEqual(m.title, "")
  96. def test_no_header_or_bad_major(self):
  97. self.assertEqual(parse_id3(b""), Meta())
  98. self.assertEqual(parse_id3(b"NOTAG").title, "")
  99. m = parse_id3(b"ID3\x02\x00\x00\x00\x00" + b"\x00" * 4)
  100. self.assertEqual(m.title, "")
  101. def test_frames_scanned_among_junk_gaps(self):
  102. frames = [
  103. b"\x00" * 5,
  104. _frame(b"TPE2", _text_content("Gap Artist")),
  105. bytes([0xaa] * 37),
  106. _frame(b"TIT2", _text_content("Later Title")),
  107. ]
  108. m = parse_id3(build_tag(frames))
  109. self.assertIn("Later Title", m.titles)
  110. self.assertEqual(m.artist, "Gap Artist")
  111. def test_display_fallbacks(self):
  112. m = Meta(titles=("A Song",), artist="The Band")
  113. self.assertEqual(m.display, "The Band - A Song")
  114. m = Meta(titles=("One", "Two"), artist="The Band", album="Records", year="2026")
  115. self.assertEqual(m.display, "The Band - Records (2026)")
  116. self.assertEqual(Meta(artist="Solo").display, "Solo")
  117. self.assertEqual(Meta().display, "")
  118. self.assertEqual(Meta(titles=("X", "X", ""), artist="A").display, "A - X")
  119. # no artist, several tracks -> album/year fallback
  120. self.assertEqual(Meta(titles=("A", "B"), album="Alb").display, "Alb")
  121. def test_real_head_fixture(self):
  122. """Parse the 1 MiB real fixture of ASOT_Ep_1298 (saved from miroppb)."""
  123. if not os.path.isfile(FIXTURE):
  124. self.skipTest("fixture not available in this environment")
  125. with open(FIXTURE, "rb") as f:
  126. m = parse_id3(f.read())
  127. self.assertGreaterEqual(len(m.titles), 35)
  128. self.assertEqual(m.artist, "Armin van Buuren")
  129. self.assertEqual(m.album, "A State of Trance")
  130. self.assertEqual(m.year, "2026")
  131. self.assertIn("MiroPPB.com", m.comment or "")
  132. self.assertTrue("Episode 1298" in " ".join(m.titles))
  133. # --------------------------------------------------------------------------
  134. # resolve_start (main.py)
  135. # --------------------------------------------------------------------------
  136. def fake_eps():
  137. return [
  138. Episode(number=1301, url="https://miroppb.com/b/rss/ASOT_Ep_1301.mp3"),
  139. Episode(number=1300, url="https://miroppb.com/b/rss/ASOT_Ep_1300.mp3"),
  140. Episode(number=1299, url="https://miroppb.com/b/rss/ASOT_Ep_1299.mp3"),
  141. Episode(number=1298, url="https://miroppb.com/b/rss/ASOT_Ep_1298.mp3"),
  142. ]
  143. class ResolveStartTests(unittest.TestCase):
  144. def setUp(self):
  145. self.eps = fake_eps()
  146. def test_explicit_episode_found(self):
  147. ep, resume, pos = resolve_start(self.eps, None, 1299)
  148. self.assertEqual(ep.number, 1299)
  149. self.assertFalse(resume)
  150. self.assertEqual(pos, 0.0)
  151. def test_explicit_episode_missing_constructed(self):
  152. ep, resume, pos = resolve_start(self.eps, None, 1999)
  153. self.assertEqual(ep.number, 1999)
  154. self.assertFalse(resume)
  155. self.assertIn("1999", ep.url)
  156. self.assertTrue(ep.url.endswith(".mp3"), f"bad URL: {ep.url!r}")
  157. def test_state_resumes_matching_episode(self):
  158. st = State()
  159. st.ep = 1298
  160. st.url = self.eps[-1].url
  161. st.position = 28.0
  162. st.length = 30.0
  163. ep, resume, pos = resolve_start(self.eps, st, None)
  164. self.assertEqual(ep.number, 1298)
  165. self.assertTrue(resume)
  166. # clamped to length - 5 so we don't seek into the last seconds
  167. self.assertEqual(pos, 25.0)
  168. def test_state_short_length_starts_clean(self):
  169. # length <= 5 s (fake-engine size): marked as resuming but position
  170. # is reset to 0 so playback starts clean.
  171. st = State()
  172. st.ep = 1298
  173. st.url = self.eps[-1].url
  174. st.position = 2.0
  175. st.length = 3.0
  176. ep, resume, pos = resolve_start(self.eps, st, None)
  177. self.assertEqual(ep.number, 1298)
  178. self.assertTrue(resume)
  179. self.assertEqual(pos, 0.0)
  180. def test_state_not_in_feed_falls_back(self):
  181. st = State()
  182. st.ep = 500 # not present (feed was rebuilt without it)
  183. ep, resume, _ = resolve_start(self.eps, st, None)
  184. self.assertEqual(ep.number, self.eps[0].number)
  185. self.assertFalse(resume)
  186. def test_position_zero_no_resume(self):
  187. st = State()
  188. st.ep = 1298
  189. st.url = self.eps[-1].url
  190. st.position = 0.0
  191. _, resume, _ = resolve_start(self.eps, st, None)
  192. self.assertFalse(resume)
  193. # --------------------------------------------------------------------------
  194. # Player.advance (direction + edge behaviour)
  195. # --------------------------------------------------------------------------
  196. class PlayerAdvanceTests(unittest.TestCase):
  197. def setUp(self):
  198. # patch must span the whole test body, not just setUp: _load() runs
  199. # inside test methods, so the module attribute has to stay mocked.
  200. self._orig_fetch_meta = main_mod.fetch_meta
  201. main_mod.fetch_meta = Mock(return_value=Meta())
  202. self.episodes = [
  203. Episode(number=1301, url="https://x/1301.mp3"),
  204. Episode(number=1300, url="https://x/1300.mp3"),
  205. Episode(number=1299, url="https://x/1299.mp3"),
  206. Episode(number=1298, url="https://x/1298.mp3"),
  207. ]
  208. self.p = Player(eps=self.episodes, engine=FakeEngine("u", 3.0),
  209. ui=UI(False, 56, 40), state=State())
  210. def tearDown(self):
  211. main_mod.fetch_meta = self._orig_fetch_meta
  212. del self._orig_fetch_meta
  213. def load(self, number):
  214. for e in self.p.eps:
  215. if e.number == number:
  216. self.p._load(e, "test")
  217. break
  218. return self.p
  219. def test_next_and_prev_by_number(self):
  220. self.load(1299)
  221. self.assertTrue(self.p.advance(+1))
  222. self.assertEqual(self.p.ep.number, 1300)
  223. self.assertTrue(self.p.advance(-1))
  224. self.assertEqual(self.p.ep.number, 1299)
  225. self.assertTrue(self.p.advance(-1))
  226. self.assertEqual(self.p.ep.number, 1298)
  227. self.assertTrue(self.p.advance(+1))
  228. self.assertEqual(self.p.ep.number, 1299)
  229. def test_edge_end_of_feed(self):
  230. self.load(1301)
  231. self.assertFalse(self.p.advance(+1))
  232. self.assertEqual(self.p.ep.number, 1301)
  233. self.load(1298)
  234. self.assertFalse(self.p.advance(-1))
  235. self.assertEqual(self.p.ep.number, 1298)
  236. def test_msg_override_on_advance(self):
  237. self.load(1299)
  238. self.p.advance(+1)
  239. self.assertIn("ASOT 1300", self.p.msg_override)
  240. self.assertTrue("next" in self.p.msg_override.lower())
  241. # --------------------------------------------------------------------------
  242. # FakeEngine behaviour
  243. # --------------------------------------------------------------------------
  244. class FakeEngineTests(unittest.TestCase):
  245. @staticmethod
  246. def make(speed=1.0, length=3.0):
  247. return FakeEngine("u", length=length, speed=speed)
  248. def test_position_advances_per_status(self):
  249. e = self.make(speed=2.5, length=10.0)
  250. e.play("u")
  251. self.assertEqual(e.status().position, 2.5)
  252. self.assertEqual(e.status().position, 5.0)
  253. def test_eof_reached_and_clamped(self):
  254. e = self.make(length=3.0)
  255. e.play("u")
  256. e.status() # 1
  257. e.status() # 2
  258. s = e.status()
  259. self.assertEqual(s.state, "eof")
  260. self.assertEqual(s.position, 3.0)
  261. def test_buffering_then_playing(self):
  262. e = self.make(length=10.0)
  263. e.play("u")
  264. self.assertIn(e.status().state, ("buffering",))
  265. self.assertEqual(e.status().state, "playing")
  266. def test_seeks_clamp(self):
  267. # speed 0 -> status() does not advance the position, keeps asserts exact
  268. e = self.make(length=5.0, speed=0.0)
  269. e.play("u")
  270. e.seek(999.0)
  271. self.assertEqual(e.status().position, 5.0)
  272. e.seek(-10.0)
  273. self.assertEqual(e.status().position, 0.0)
  274. e.seek(2.0)
  275. self.assertEqual(e.status().position, 2.0)
  276. self.assertEqual(e.seek_count, 3)
  277. def test_volume_clamps(self):
  278. e = self.make()
  279. e.play("u")
  280. e.set_volume(1.4)
  281. self.assertEqual(e.status().volume, 1.0)
  282. e.set_volume(-0.2)
  283. self.assertEqual(e.status().volume, 0.0)
  284. e.set_volume(0.75)
  285. self.assertAlmostEqual(e.status().volume, 0.75)
  286. def test_seek_to_on_play(self):
  287. # speed 0 -> no implicit advance between play() and status()
  288. e = self.make(length=10.0, speed=0.0)
  289. e.play("u", seek_to=4.0)
  290. self.assertEqual(e.status().position, 4.0)
  291. e.play("u", seek_to=99.0) # clamped to length
  292. self.assertEqual(e.status().position, 10.0)
  293. def test_stop_resets_url_tracking(self):
  294. e = self.make()
  295. e.play("u")
  296. e.stop()
  297. self.assertEqual(e.state, "stop")
  298. self.assertIsNone(e.played_url)
  299. # --------------------------------------------------------------------------
  300. # MpEngine derived state (no mpv process — pure logic on position progress)
  301. # --------------------------------------------------------------------------
  302. class MpDerivedStateTests(unittest.TestCase):
  303. @staticmethod
  304. def eng():
  305. e = MpEngine.__new__(MpEngine) # skip __init__: no mpv spawn needed
  306. e._adv = None
  307. return e
  308. def test_paused_wins(self):
  309. self.assertEqual(self.eng()._derived_state(True, 100.0, 7000.0), "paused")
  310. def test_near_end_is_eof(self):
  311. self.assertEqual(self.eng()._derived_state(False, 6999.0, 7000.0), "eof")
  312. def test_progress_is_playing(self):
  313. e = self.eng()
  314. real = engine_mod.time.time
  315. try:
  316. engine_mod.time.time = lambda: 1_000.0
  317. e._derived_state(False, 3_000.0, 7_000.0) # baseline
  318. engine_mod.time.time = lambda: 1_030.0
  319. self.assertEqual(e._derived_state(False, 3_030.0, 7_000.0), "playing")
  320. finally:
  321. engine_mod.time.time = real
  322. def test_long_stall_is_buffering_not_eof(self):
  323. # Regression: a stall used to become "eof" after 8s, which made a
  324. # resume on an unseekable HTTP stream auto-advance to the next
  325. # episode mid-playback. Stalls must stay "buffering" indefinitely.
  326. e = self.eng()
  327. real = engine_mod.time.time
  328. try:
  329. engine_mod.time.time = lambda: 1_000.0
  330. e._derived_state(False, 3_000.0, 7_000.0) # baseline
  331. engine_mod.time.time = lambda: 1_200.0 # 200 s no progress
  332. self.assertEqual(e._derived_state(False, 3_000.0, 7_000.0), "buffering")
  333. finally:
  334. engine_mod.time.time = real
  335. # --------------------------------------------------------------------------
  336. # mpv socket death: a raw BrokenPipeError from sendall used to escape as a
  337. # plain OSError and crash the player mid-playback on a remote machine. It
  338. # must surface as EngineError and the player must recover, not die.
  339. # --------------------------------------------------------------------------
  340. def _dead_mpv_engine(poll_rc=None):
  341. """MpEngine whose mpv has died: alive() passes, socket writes fail.
  342. poll_rc=None models the race that matters: poll() still reports the
  343. process alive while its IPC server has already closed the socket."""
  344. eng = MpEngine.__new__(MpEngine) # skip __init__: no mpv spawn
  345. eng.mpv_bin = "mpv"
  346. eng.mpv_log = "/tmp/.asot-ipc-99.log"
  347. eng.proc = Mock()
  348. eng.proc.poll.return_value = poll_rc
  349. eng._cid = 0
  350. eng._prop_names = {}
  351. eng._adv = None
  352. eng._sock = Mock()
  353. eng._sock.sendall.side_effect = BrokenPipeError(32, "Broken pipe")
  354. return eng
  355. class MpSocketDeathTests(unittest.TestCase):
  356. def test_cmd_surfaces_engine_error(self):
  357. eng = _dead_mpv_engine()
  358. with self.assertRaises(engine_mod.EngineError) as cm:
  359. eng._cmd(["get_property", "pause"])
  360. msg = str(cm.exception)
  361. self.assertIn("socket closed", msg)
  362. self.assertIn("asot-ipc-99.log", msg) # and where to look
  363. def test_dead_process_is_reported_without_socket_write(self):
  364. eng = _dead_mpv_engine(poll_rc=1)
  365. with self.assertRaises(engine_mod.EngineError) as cm:
  366. eng._cmd(["get_property", "pause"])
  367. self.assertIn("mpv process is dead", str(cm.exception))
  368. def test_status_surfaces_engine_error(self):
  369. # alive() passes (poll → None), the write itself fails — the exact
  370. # shape of the reported crash traceback
  371. eng = _dead_mpv_engine()
  372. with self.assertRaises(engine_mod.EngineError):
  373. eng.status()
  374. class PlayerRecoverTests(unittest.TestCase):
  375. def setUp(self):
  376. self._fetch = main_mod.fetch_meta
  377. main_mod.fetch_meta = lambda url: Meta() # no network in tests
  378. self.eps = fake_eps()
  379. def tearDown(self):
  380. main_mod.fetch_meta = self._fetch
  381. def _player(self, ep, position, length):
  382. engine = Mock()
  383. engine.alive.return_value = False # _load: restore saved vol
  384. p = Player(self.eps, engine, Mock(), State())
  385. p.ep = ep
  386. p.state.ep, p.state.url = ep.number, ep.url
  387. p.state.position, p.state.length = position, length
  388. return p
  389. def test_recover_restarts_same_episode_at_saved_position(self):
  390. p = self._player(self.eps[1], 3_000.0, 5_400.0) # mid-episode
  391. self.assertTrue(p.recover())
  392. self.assertEqual(p.ep.number, 1300) # same episode
  393. self.assertEqual(p.engine.play.call_args.kwargs["seek_to"], 3_000.0)
  394. def test_recover_advances_only_at_true_end(self):
  395. p = self._player(self.eps[1], 5_380.0, 5_400.0) # within 30 s of end
  396. self.assertTrue(p.recover())
  397. self.assertEqual(p.ep.number, 1301) # advanced
  398. self.assertEqual(p.engine.play.call_args.kwargs["seek_to"], 0.0)
  399. def test_recover_reports_failure_when_restart_fails(self):
  400. p = self._player(self.eps[1], 1_000.0, 5_400.0)
  401. p.engine.play.side_effect = engine_mod.EngineError("mpv gone")
  402. self.assertFalse(p.recover())
  403. def test_recover_without_episode_is_noop(self):
  404. p = Player(self.eps, Mock(), Mock(), State())
  405. self.assertFalse(p.recover())
  406. def test_recover_preserves_paused_state(self):
  407. # regression: a player left paused used to resume *playing* after a
  408. # stream drop recovered — the restart must keep it paused
  409. p = self._player(self.eps[1], 3_000.0, 5_400.0)
  410. p.state.paused = True
  411. self.assertTrue(p.recover())
  412. self.assertTrue(p.state.paused)
  413. p.engine.set_pause.assert_called_once_with(True)
  414. # --------------------------------------------------------------------------
  415. # Runtime limit: a session left running (e.g. paused) for hours must not end
  416. # silently with exit code 0 (the old 50 000-tick cap did exactly that, at
  417. # ~0.37 s per tick ≈ 5 h)
  418. # --------------------------------------------------------------------------
  419. class RuntimeLimitTests(unittest.TestCase):
  420. def setUp(self):
  421. self._fetch = main_mod.fetch_meta
  422. main_mod.fetch_meta = lambda url: Meta() # no network in tests
  423. self.eps = fake_eps()
  424. def tearDown(self):
  425. main_mod.fetch_meta = self._fetch
  426. def _player(self):
  427. engine = FakeEngine(self.eps[1].url, length=7_200.0, speed=0.0)
  428. p = Player(self.eps, engine, UI(color=False, width=72, bar_width=40),
  429. State())
  430. p.ep = self.eps[1]
  431. p.state.ep, p.state.url = self.eps[1].number, self.eps[1].url
  432. p.state.paused = True
  433. return p
  434. def test_wallclock_limit_reports_and_exits_nonzero(self):
  435. p = self._player()
  436. limit = main_mod.MAX_RUNTIME_SECONDS
  437. main_mod.MAX_RUNTIME_SECONDS = 0.2
  438. try:
  439. buf = io.StringIO()
  440. with contextlib.redirect_stdout(buf):
  441. rc = main_mod.run_player(p, None)
  442. finally:
  443. main_mod.MAX_RUNTIME_SECONDS = limit
  444. self.assertEqual(rc, 1)
  445. self.assertIn("runtime limit", buf.getvalue())
  446. def test_normal_finish_exits_zero_without_limit_message(self):
  447. p = self._player()
  448. p.finished = True # as if the user quit
  449. buf = io.StringIO()
  450. with contextlib.redirect_stdout(buf):
  451. rc = main_mod.run_player(p, None)
  452. self.assertEqual(rc, 0)
  453. self.assertNotIn("limit", buf.getvalue())
  454. def test_main_returns_rc_when_engine_fails(self):
  455. # rc=2 is set on EngineError — main() must return it, not 0
  456. eps = fake_eps()
  457. engine = Mock()
  458. engine.alive.return_value = False
  459. engine.play.side_effect = engine_mod.EngineError("mpv gone")
  460. with tempfile.TemporaryDirectory() as tmp:
  461. old_argv = list(sys.argv)
  462. sys.argv = ["asot", "--fake", "--fresh", "--width", "60",
  463. "--state-dir", tmp]
  464. _fetch, _fake = main_mod.fetch_episodes, main_mod.FakeEngine
  465. main_mod.fetch_episodes = lambda: eps
  466. main_mod.FakeEngine = lambda *a, **k: engine
  467. try:
  468. buf = io.StringIO()
  469. with contextlib.redirect_stdout(buf), \
  470. contextlib.redirect_stderr(io.StringIO()), \
  471. unittest.mock.patch("sys.stdin", io.StringIO()):
  472. rc = main_mod.main()
  473. finally:
  474. sys.argv = old_argv
  475. main_mod.fetch_episodes = _fetch
  476. main_mod.FakeEngine = _fake
  477. self.assertEqual(rc, 2)
  478. # --------------------------------------------------------------------------
  479. # Bottom anchoring: the block erases itself cleanly (never two bars), reports
  480. # print on the line above it, and exit parks the cursor on that line
  481. # --------------------------------------------------------------------------
  482. def _tty_ui(rows: int = 24):
  483. """A tty UI; terminal size is pinned by the test's patched size."""
  484. u = UI(color=False, width=72, bar_width=40)
  485. u.tty = True
  486. u.start_line = u.block_start()
  487. return u
  488. class BottomAnchorTests(unittest.TestCase):
  489. def setUp(self):
  490. self._size = ui_mod.shutil.get_terminal_size
  491. ui_mod.shutil.get_terminal_size = lambda *a: os.terminal_size((80, 24))
  492. self.ui = _tty_ui()
  493. def tearDown(self):
  494. ui_mod.shutil.get_terminal_size = self._size
  495. def render(self):
  496. return self.ui.render(1301, "01 Jan 2026", "PLAYING", "ok",
  497. 10.0, 100.0, 0.8, False)
  498. def test_render_anchors_and_clears_below(self):
  499. out = self.render()
  500. # 3 lines at rows-2, clearing everything below so an older frame or a
  501. # printed traceback cannot survive as a second bar
  502. self.assertTrue(out.startswith("\x1b[22;1H\x1b[J"), repr(out[:14]))
  503. self.assertEqual(self.ui.start_line, 22)
  504. def test_block_start_tracks_terminal_height(self):
  505. self.assertEqual(self.ui.block_start(), 22)
  506. ui_mod.shutil.get_terminal_size = lambda *a: os.terminal_size((80, 40))
  507. self.assertEqual(self.ui.block_start(), 38)
  508. def test_clear_block_parks_cursor_above_the_block(self):
  509. self.render()
  510. self.assertEqual(self.ui.clear_block(), "\x1b[0m\x1b[21;1H\x1b[J")
  511. def test_finish_clears_only_the_block(self):
  512. self.render()
  513. fin = self.ui.finish()
  514. self.assertEqual(fin, "\x1b[0m\x1b[21;1H\x1b[J")
  515. self.assertNotIn("\x1b[H\x1b[J", fin) # no full-screen clear
  516. def test_non_tty_ui_writes_no_escapes(self):
  517. u = UI(color=False, width=72, bar_width=40)
  518. u.tty = False
  519. self.assertEqual(u.clear_block(), "")
  520. self.assertEqual(u.finish(), "")
  521. class PrintAboveTests(unittest.TestCase):
  522. def test_plain_mode_writes_only_the_text(self):
  523. u = UI(color=False, width=72, bar_width=40) # non-tty
  524. buf = io.StringIO()
  525. real = sys.stdout
  526. sys.stdout = buf
  527. try:
  528. main_mod._print_above(u, "boom\n")
  529. finally:
  530. sys.stdout = real
  531. self.assertEqual(buf.getvalue(), "boom\n")
  532. def test_tty_mode_clears_the_block_before_the_text(self):
  533. u = _tty_ui()
  534. u.render(1301, "01 Jan 2026", "PLAYING", "ok", 10.0, 100.0, 0.8, False)
  535. buf = io.StringIO()
  536. real = sys.stdout
  537. sys.stdout = buf
  538. try:
  539. main_mod._print_above(u, "boom")
  540. finally:
  541. sys.stdout = real
  542. self.assertEqual(buf.getvalue(), "\x1b[0m\x1b[21;1H\x1b[Jboom\n")
  543. # --------------------------------------------------------------------------
  544. # Keyboard restore: Linux termios has no TCSU — using it silently skipped the
  545. # restore (AttributeError swallowed), leaving the shell in cbreak mode.
  546. # --------------------------------------------------------------------------
  547. class KeyboardRestoreTests(unittest.TestCase):
  548. def test_stop_uses_posix_flush_constant(self):
  549. kb = main_mod.Keyboard.__new__(main_mod.Keyboard)
  550. kb.fd, kb.oldattr = 0, Mock()
  551. t = main_mod.termios
  552. calls = []
  553. real = t.tcsetattr
  554. t.tcsetattr = lambda f, when, attr: calls.append(when)
  555. try:
  556. kb.stop()
  557. finally:
  558. t.tcsetattr = real
  559. self.assertEqual(calls, [t.TCSAFLUSH])
  560. # --------------------------------------------------------------------------
  561. # State: round-trip / corrupt file / env dir
  562. # --------------------------------------------------------------------------
  563. class StateTests(unittest.TestCase):
  564. def setUp(self):
  565. self._dir = tempfile.TemporaryDirectory()
  566. self._saved = os.environ.pop("ASOT_STATE_DIR", None)
  567. os.environ["ASOT_STATE_DIR"] = self._dir.name
  568. def tearDown(self):
  569. del os.environ["ASOT_STATE_DIR"]
  570. if self._saved is not None:
  571. os.environ["ASOT_STATE_DIR"] = self._saved
  572. self._dir.cleanup()
  573. def test_roundtrip(self):
  574. st = State(ep=1298, url="https://x/1.mp3", position=21.5, length=100.0,
  575. paused=False, muted=True, volume=0.42)
  576. st.save()
  577. loaded = State.load()
  578. self.assertIsNotNone(loaded)
  579. self.assertEqual(loaded.ep, 1298)
  580. self.assertEqual(loaded.url, "https://x/1.mp3")
  581. self.assertAlmostEqual(loaded.position, 21.5)
  582. self.assertTrue(loaded.muted)
  583. self.assertAlmostEqual(loaded.volume, 0.42)
  584. self.assertTrue(loaded.updated_at)
  585. def test_load_corrupt_file_returns_none(self):
  586. State().save()
  587. State.state_file().write_text("not json at all {{{{", encoding="utf-8")
  588. self.assertIsNone(State.load())
  589. def test_load_empty_file_returns_none(self):
  590. State().save()
  591. State.state_file().write_text("", encoding="utf-8")
  592. self.assertIsNone(State.load())
  593. def test_load_missing_returns_none(self):
  594. self.assertIsNone(State.load())
  595. def test_from_raw_defaults_and_clamps(self):
  596. st = State.from_raw({"ep": 42, "volume": 3.0, "position": 5,
  597. "length": "1.0"})
  598. self.assertEqual(st.ep, 42)
  599. self.assertEqual(st.volume, 1.0)
  600. self.assertEqual(st.position, 5.0)
  601. self.assertEqual(st.length, 1.0)
  602. def test_clear(self):
  603. State().save()
  604. self.assertTrue(os.path.exists(State.state_file()))
  605. self.assertTrue(State.clear())
  606. self.assertFalse(os.path.exists(State.state_file()))
  607. self.assertFalse(State.clear())
  608. # --------------------------------------------------------------------------
  609. # UI: every line is exactly `width` visible characters in both colour modes
  610. # --------------------------------------------------------------------------
  611. def rendered_lines(ui, date, status, kind, position, length, volume):
  612. out = ui.render(1298, date, status, kind, position, length, volume, False)
  613. body = re.sub(r"^\x1b\[\d+;1H", "", out).rstrip("\n") # strip prefix + trailing LF
  614. return body.split("\n")
  615. class UITests(unittest.TestCase):
  616. @staticmethod
  617. def widths_and_colors():
  618. for w in (52, 53, 78, 120):
  619. for color in (True, False):
  620. yield UI(color, w, 40), w
  621. def test_line_lengths_exact(self):
  622. for ui, w in self.widths_and_colors():
  623. lines = [ui.title_line(1298, "PLAYING", "ok", "07 Feb 2026"),
  624. ui.title_line(1298, "Finished — end of feed at ASOT 1301", "info", ""),
  625. ui.title_line(1298, "", "ok", ""),
  626. ui.bar_line(None, None, 0.5, False),
  627. ui.bar_line(0.0, 0.0, 0.5, False),
  628. ui.bar_line(1234.5, 7200.0, 0.8, True),
  629. ui.hint_line()]
  630. for ln in lines:
  631. self.assertEqual(visible_len(ln), w, f"visible len != width: {ln!r}")
  632. def test_rendered_block_lengths_exact(self):
  633. for ui, w in self.widths_and_colors():
  634. body = rendered_lines(ui, "07 Feb 2026", "Buffering", "buffer",
  635. 333.5, 7200.0, 0.85)
  636. for ln in body:
  637. self.assertEqual(visible_len(ln), w, f"visible len != width: {ln!r}")
  638. def test_plain_mode_no_escapes(self):
  639. ui = UI(False, 80, 40)
  640. out = ui.plain_render(9, "d", "ok", "ok", 1.0, 10.0, 0.5, False)
  641. self.assertNotIn("\x1b", out)
  642. lines = [l for l in out.splitlines() if l]
  643. self.assertEqual(len(lines), 3)
  644. self.assertTrue(all(len(l) == 80 for l in lines))
  645. if __name__ == "__main__":
  646. unittest.main(verbosity=2)