test_tui_app.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. """Smoke test: the Textual app mounts, subscribes, and ingests broadcasts."""
  2. import asyncio
  3. import os
  4. import shutil
  5. import sys
  6. import tempfile
  7. import pytest
  8. from stt_daemon.assistant.control import ControlServer
  9. from stt_daemon.assistant.service import AssistantService
  10. from stt_daemon.config import Config
  11. class FakeSubscriber:
  12. def events(self):
  13. return iter(())
  14. def close(self):
  15. pass
  16. @pytest.fixture
  17. def server():
  18. directory = tempfile.mkdtemp(prefix="stta-")
  19. path = os.path.join(directory, "c.sock")
  20. cfg = Config()
  21. cfg.assistant.control_socket = path
  22. service = AssistantService(cfg, FakeSubscriber())
  23. control = ControlServer(cfg, service, path=path)
  24. service.set_sinks(emit=control.broadcast, relay=control.relay)
  25. control.start()
  26. yield path, service
  27. control.close()
  28. service.stop()
  29. shutil.rmtree(directory, ignore_errors=True)
  30. def test_enter_sends_execute_and_e_opens_editor(server):
  31. pytest.importorskip("textual")
  32. from stt_daemon.tui.app import AssistantTUI
  33. path, _service = server
  34. app = AssistantTUI(path)
  35. sent: list[tuple] = []
  36. async def scenario():
  37. async with app.run_test() as pilot:
  38. await pilot.pause()
  39. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  40. await pilot.press("enter")
  41. await pilot.pause()
  42. await pilot.press("a")
  43. await pilot.pause()
  44. await pilot.press("escape")
  45. await pilot.pause()
  46. asyncio.run(scenario())
  47. assert ("command_execute", {}) in sent
  48. assert ("command_start", {}) in sent
  49. assert ("command_cancel", {}) in sent
  50. def test_c_key_confirms_before_clearing(server):
  51. pytest.importorskip("textual")
  52. from stt_daemon.tui.app import AssistantTUI
  53. path, _service = server
  54. app = AssistantTUI(path)
  55. sent: list[tuple] = []
  56. async def scenario():
  57. async with app.run_test() as pilot:
  58. await pilot.pause()
  59. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  60. await pilot.press("c")
  61. await pilot.pause()
  62. assert not any(type_ == "clear" for type_, _ in sent), "clear sent before confirmation"
  63. await pilot.press("y")
  64. await pilot.pause()
  65. asyncio.run(scenario())
  66. assert ("clear", {}) in sent
  67. def test_c_key_cancel_does_not_clear(server):
  68. pytest.importorskip("textual")
  69. from stt_daemon.tui.app import AssistantTUI
  70. path, _service = server
  71. app = AssistantTUI(path)
  72. sent: list[tuple] = []
  73. async def scenario():
  74. async with app.run_test() as pilot:
  75. await pilot.pause()
  76. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  77. await pilot.press("c")
  78. await pilot.pause()
  79. await pilot.press("n")
  80. await pilot.pause()
  81. asyncio.run(scenario())
  82. assert not any(type_ == "clear" for type_, _ in sent)
  83. def test_c_key_escape_does_not_clear(server):
  84. pytest.importorskip("textual")
  85. from stt_daemon.tui.app import AssistantTUI
  86. path, _service = server
  87. app = AssistantTUI(path)
  88. sent: list[tuple] = []
  89. async def scenario():
  90. async with app.run_test() as pilot:
  91. await pilot.pause()
  92. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  93. await pilot.press("c")
  94. await pilot.pause()
  95. await pilot.press("escape")
  96. await pilot.pause()
  97. asyncio.run(scenario())
  98. assert not any(type_ == "clear" for type_, _ in sent)
  99. def test_c_key_clears_rendered_output(server):
  100. """Pressing 'c' clears the conversation pane via the cleared event."""
  101. pytest.importorskip("textual")
  102. from textual.widgets import RichLog
  103. from stt_daemon.tui.app import AssistantTUI
  104. path, _service = server
  105. app = AssistantTUI(path)
  106. async def scenario():
  107. async with app.run_test() as pilot:
  108. await pilot.pause()
  109. app.state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  110. app.state.ingest(
  111. {
  112. "type": "tool_result",
  113. "tool": "get_datetime",
  114. "ok": True,
  115. "result": "2026-09-21",
  116. }
  117. )
  118. app._render()
  119. await pilot.pause()
  120. app.state.ingest({"type": "cleared"})
  121. app._render()
  122. await pilot.pause()
  123. log = app.query_one("#chat", RichLog)
  124. return list(log.lines)
  125. lines = asyncio.run(scenario())
  126. assert not any("hello" in str(line) for line in lines)
  127. assert not any("2026-09-21" in str(line) for line in lines)
  128. def test_tui_renders_command_output(server):
  129. """A tool result must actually reach a widget - otherwise 'nothing happens'."""
  130. pytest.importorskip("textual")
  131. from textual.widgets import RichLog
  132. from stt_daemon.tui.app import AssistantTUI
  133. path, _service = server
  134. app = AssistantTUI(path)
  135. async def scenario():
  136. async with app.run_test() as pilot:
  137. await pilot.pause()
  138. app.state.ingest(
  139. {
  140. "type": "tool_result",
  141. "tool": "get_datetime",
  142. "ok": True,
  143. "result": "2026-09-21T01:00:00",
  144. }
  145. )
  146. app._render()
  147. await pilot.pause()
  148. log = app.query_one("#chat", RichLog)
  149. return list(log.lines)
  150. lines = asyncio.run(scenario())
  151. assert lines, "command output was not rendered anywhere"
  152. def test_tui_clears_chat_pane_on_chat_exit(server):
  153. """Leaving chat must drop the rendered conversation but keep command output."""
  154. pytest.importorskip("textual")
  155. from textual.widgets import RichLog
  156. from stt_daemon.tui.app import AssistantTUI
  157. path, _service = server
  158. app = AssistantTUI(path)
  159. async def scenario():
  160. async with app.run_test() as pilot:
  161. await pilot.pause()
  162. app.state.ingest(
  163. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
  164. )
  165. app.state.ingest({"type": "mode", "mode": "chat"})
  166. app.state.ingest({"type": "chat_message", "role": "user", "text": "gone"})
  167. app._render()
  168. await pilot.pause()
  169. app.state.ingest({"type": "mode", "mode": "idle"})
  170. app._render()
  171. await pilot.pause()
  172. return [str(line) for line in app.query_one("#chat", RichLog).lines]
  173. lines = asyncio.run(scenario())
  174. assert not any("gone" in line for line in lines)
  175. assert any("kept" in line for line in lines)
  176. def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
  177. pytest.importorskip("textual")
  178. from stt_daemon.tui.app import AssistantTUI
  179. path, service = server
  180. app = AssistantTUI(path)
  181. async def scenario():
  182. async with app.run_test() as pilot:
  183. await pilot.pause()
  184. service.arm() # broadcast to the subscribed viewer
  185. for _ in range(60):
  186. await pilot.pause()
  187. if app.state.mode == "command_armed":
  188. break
  189. await asyncio.sleep(0.05)
  190. asyncio.run(scenario())
  191. assert app.state.mode == "command_armed"
  192. def test_render_after_unmount_is_safe(server):
  193. """A timer tick can land during teardown; rendering must not raise."""
  194. pytest.importorskip("textual")
  195. from stt_daemon.tui.app import AssistantTUI
  196. path, _service = server
  197. app = AssistantTUI(path)
  198. async def scenario():
  199. async with app.run_test() as pilot:
  200. await pilot.pause()
  201. asyncio.run(scenario())
  202. app._render() # must not raise NoMatches
  203. def test_tui_records_connect_failure(tmp_path):
  204. pytest.importorskip("textual")
  205. from stt_daemon.tui.app import AssistantTUI
  206. app = AssistantTUI(str(tmp_path / "missing.sock"))
  207. async def scenario():
  208. async with app.run_test() as pilot:
  209. await pilot.pause()
  210. asyncio.run(scenario())
  211. assert app.connect_error is not None
  212. assert "cannot connect" in app.connect_error
  213. def test_border_titles_show_queue_and_inference_depths(server):
  214. """Border titles for transcript and chat show live segment and inference queue counts."""
  215. pytest.importorskip("textual")
  216. from textual.widgets import RichLog
  217. from stt_daemon.tui.app import AssistantTUI
  218. path, _service = server
  219. app = AssistantTUI(path)
  220. async def scenario():
  221. async with app.run_test() as pilot:
  222. await pilot.pause()
  223. app.state.ingest(
  224. {
  225. "type": "heartbeat",
  226. "queue_depth": 5,
  227. "inference_depth": 3,
  228. "inference_in_flight": False,
  229. }
  230. )
  231. app._render()
  232. await pilot.pause()
  233. transcript_title = app.query_one("#transcript", RichLog).border_title
  234. chat_title = app.query_one("#chat", RichLog).border_title
  235. assert "[seg 5]" in transcript_title
  236. assert "[inf 3]" in chat_title
  237. assert "busy" not in chat_title
  238. # Test with inference in flight
  239. app.state.ingest(
  240. {
  241. "type": "heartbeat",
  242. "inference_depth": 7,
  243. "inference_in_flight": True,
  244. }
  245. )
  246. app._render()
  247. await pilot.pause()
  248. chat_title = app.query_one("#chat", RichLog).border_title
  249. assert "[inf 7" in chat_title
  250. assert "busy" in chat_title
  251. asyncio.run(scenario())
  252. def test_main_exit_code_reflects_connect_failure(monkeypatch, tmp_path, capsys):
  253. pytest.importorskip("textual")
  254. from stt_daemon.tui import app as tui_app
  255. monkeypatch.setattr(
  256. tui_app.AssistantTUI,
  257. "run",
  258. lambda self: setattr(self, "connect_error", "boom"),
  259. )
  260. monkeypatch.setattr(sys, "argv", ["stt-tui", "--socket", str(tmp_path / "x.sock")])
  261. assert tui_app.main() == 1
  262. assert "boom" in capsys.readouterr().err
  263. monkeypatch.setattr(tui_app.AssistantTUI, "run", lambda self: None)
  264. assert tui_app.main() == 0