test_tui_app.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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_tui_renders_command_output(server):
  51. """A tool result must actually reach a widget - otherwise 'nothing happens'."""
  52. pytest.importorskip("textual")
  53. from textual.widgets import RichLog
  54. from stt_daemon.tui.app import AssistantTUI
  55. path, _service = server
  56. app = AssistantTUI(path)
  57. async def scenario():
  58. async with app.run_test() as pilot:
  59. await pilot.pause()
  60. app.state.ingest(
  61. {
  62. "type": "tool_result",
  63. "tool": "get_datetime",
  64. "ok": True,
  65. "result": "2026-09-21T01:00:00",
  66. }
  67. )
  68. app._render()
  69. await pilot.pause()
  70. log = app.query_one("#chat", RichLog)
  71. return list(log.lines)
  72. lines = asyncio.run(scenario())
  73. assert lines, "command output was not rendered anywhere"
  74. def test_tui_clears_chat_pane_on_chat_exit(server):
  75. """Leaving chat must drop the rendered conversation but keep command output."""
  76. pytest.importorskip("textual")
  77. from textual.widgets import RichLog
  78. from stt_daemon.tui.app import AssistantTUI
  79. path, _service = server
  80. app = AssistantTUI(path)
  81. async def scenario():
  82. async with app.run_test() as pilot:
  83. await pilot.pause()
  84. app.state.ingest(
  85. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
  86. )
  87. app.state.ingest({"type": "mode", "mode": "chat"})
  88. app.state.ingest({"type": "chat_message", "role": "user", "text": "gone"})
  89. app._render()
  90. await pilot.pause()
  91. app.state.ingest({"type": "mode", "mode": "idle"})
  92. app._render()
  93. await pilot.pause()
  94. return [str(line) for line in app.query_one("#chat", RichLog).lines]
  95. lines = asyncio.run(scenario())
  96. assert not any("gone" in line for line in lines)
  97. assert any("kept" in line for line in lines)
  98. def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
  99. pytest.importorskip("textual")
  100. from stt_daemon.tui.app import AssistantTUI
  101. path, service = server
  102. app = AssistantTUI(path)
  103. async def scenario():
  104. async with app.run_test() as pilot:
  105. await pilot.pause()
  106. service.arm() # broadcast to the subscribed viewer
  107. for _ in range(60):
  108. await pilot.pause()
  109. if app.state.mode == "command_armed":
  110. break
  111. await asyncio.sleep(0.05)
  112. asyncio.run(scenario())
  113. assert app.state.mode == "command_armed"
  114. def test_render_after_unmount_is_safe(server):
  115. """A timer tick can land during teardown; rendering must not raise."""
  116. pytest.importorskip("textual")
  117. from stt_daemon.tui.app import AssistantTUI
  118. path, _service = server
  119. app = AssistantTUI(path)
  120. async def scenario():
  121. async with app.run_test() as pilot:
  122. await pilot.pause()
  123. asyncio.run(scenario())
  124. app._render() # must not raise NoMatches
  125. def test_tui_records_connect_failure(tmp_path):
  126. pytest.importorskip("textual")
  127. from stt_daemon.tui.app import AssistantTUI
  128. app = AssistantTUI(str(tmp_path / "missing.sock"))
  129. async def scenario():
  130. async with app.run_test() as pilot:
  131. await pilot.pause()
  132. asyncio.run(scenario())
  133. assert app.connect_error is not None
  134. assert "cannot connect" in app.connect_error
  135. def test_main_exit_code_reflects_connect_failure(monkeypatch, tmp_path, capsys):
  136. pytest.importorskip("textual")
  137. from stt_daemon.tui import app as tui_app
  138. monkeypatch.setattr(
  139. tui_app.AssistantTUI,
  140. "run",
  141. lambda self: setattr(self, "connect_error", "boom"),
  142. )
  143. monkeypatch.setattr(sys, "argv", ["stt-tui", "--socket", str(tmp_path / "x.sock")])
  144. assert tui_app.main() == 1
  145. assert "boom" in capsys.readouterr().err
  146. monkeypatch.setattr(tui_app.AssistantTUI, "run", lambda self: None)
  147. assert tui_app.main() == 0