test_tui_state.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. """Tests for the TUI view-state reducer."""
  2. from stt_daemon.tui.state import MAX_TRANSCRIPTS, ViewState
  3. def test_mode_event_updates_mode_and_style():
  4. state = ViewState()
  5. state.ingest({"type": "mode", "mode": "command_armed"})
  6. assert state.mode == "command_armed"
  7. assert state.mode_style() == "yellow"
  8. def test_initial_health_is_not_optimistic():
  9. """Before the first heartbeat arrives, health must read as unknown/down."""
  10. state = ViewState()
  11. assert state.health_ok() is False
  12. state.ingest({"type": "heartbeat", "stt_ok": True, "device_ok": True})
  13. assert state.health_ok() is True
  14. def test_heartbeat_self_corrects_missed_mode():
  15. state = ViewState()
  16. state.ingest(
  17. {
  18. "type": "heartbeat",
  19. "mode": "chat",
  20. "stt_ok": True,
  21. "in_flight": False,
  22. "worker_stall_s": 0.0,
  23. "queue_depth": 2,
  24. "device_ok": True,
  25. }
  26. )
  27. assert state.mode == "chat"
  28. assert state.queue_depth == 2
  29. def test_heartbeat_ingests_inference_depth_and_in_flight():
  30. state = ViewState()
  31. state.ingest(
  32. {
  33. "type": "heartbeat",
  34. "inference_depth": 3,
  35. "inference_in_flight": True,
  36. }
  37. )
  38. assert state.inference_depth == 3
  39. assert state.inference_in_flight is True
  40. def test_heartbeat_omitting_inference_fields_preserves_prior_values():
  41. state = ViewState()
  42. state.inference_depth = 5
  43. state.inference_in_flight = True
  44. state.ingest(
  45. {
  46. "type": "heartbeat",
  47. "mode": "idle",
  48. "stt_ok": True,
  49. }
  50. )
  51. assert state.inference_depth == 5
  52. assert state.inference_in_flight is True
  53. def test_health_red_when_stt_not_ok():
  54. state = ViewState()
  55. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": False})
  56. assert state.health_ok() is False
  57. def test_health_red_when_device_down():
  58. state = ViewState()
  59. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True, "device_ok": False})
  60. assert state.health_ok() is False
  61. def test_worker_stall_recorded_and_degrades():
  62. state = ViewState()
  63. state.ingest({"type": "heartbeat", "stt_ok": False, "worker_stall_s": 16.5})
  64. assert state.worker_stall_s == 16.5
  65. assert state.health_ok() is False
  66. def test_command_text_and_edited_flag():
  67. state = ViewState()
  68. state.ingest({"type": "command_text", "text": "hello", "edited": True, "phase": "ready"})
  69. assert state.command_text == "hello"
  70. assert state.command_edited is True
  71. assert state.phase == "ready"
  72. def test_status_populates_fields():
  73. state = ViewState()
  74. state.ingest(
  75. {
  76. "type": "status",
  77. "mode": "command_armed",
  78. "ready": True,
  79. "phase": "ready",
  80. "command_text": "x",
  81. "stt_ok": True,
  82. }
  83. )
  84. assert state.mode == "command_armed"
  85. assert state.ready is True
  86. assert state.command_text == "x"
  87. def test_transcripts_are_bounded():
  88. state = ViewState()
  89. for i in range(MAX_TRANSCRIPTS + 25):
  90. state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
  91. assert len(state.transcripts) == MAX_TRANSCRIPTS
  92. assert state.transcripts[-1]["text"] == f"t{MAX_TRANSCRIPTS + 24}"
  93. assert state.transcript_lines()[-1].startswith("[en]")
  94. def test_command_output_is_collected_for_display():
  95. state = ViewState()
  96. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  97. state.ingest(
  98. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21T01:00:00"}
  99. )
  100. state.ingest({"type": "error", "code": "not_ready", "message": "command is not ready"})
  101. lines = state.lower_lines()
  102. assert any("tool get_datetime" in line for line in lines)
  103. assert any("ok:" in line and "2026-09-21" in line for line in lines)
  104. assert any("not_ready" in line for line in lines)
  105. def test_chat_turns_also_appear_in_lower_log():
  106. state = ViewState()
  107. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  108. assert state.lower_lines() == ["user: hello"]
  109. def test_lower_log_is_bounded():
  110. from stt_daemon.tui.state import MAX_LOWER
  111. state = ViewState()
  112. for i in range(MAX_LOWER + 20):
  113. state.ingest({"type": "error", "code": f"e{i}", "message": "m"})
  114. assert len(state.lower_lines()) == MAX_LOWER
  115. def test_speech_activity_is_tracked():
  116. state = ViewState()
  117. assert state.speech_open is False
  118. state.ingest({"type": "speech_start"})
  119. assert state.speech_open is True
  120. state.ingest({"type": "speech_stop"})
  121. assert state.speech_open is False
  122. state.ingest({"type": "status", "speech_open": True})
  123. assert state.speech_open is True
  124. def test_chat_and_tool_events_recorded():
  125. state = ViewState()
  126. state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
  127. state.ingest({"type": "chat_message", "role": "assistant", "text": "hello"})
  128. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  129. state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
  130. state.ingest({"type": "error", "code": "no_viewer", "message": "m"})
  131. assert state.chat_lines() == ["user: hi", "assistant: hello"]
  132. assert state.tool is not None and state.tool["tool"] == "get_datetime"
  133. assert state.tool_result is not None and state.tool_result["ok"] is True
  134. assert state.last_error is not None and state.last_error["code"] == "no_viewer"
  135. def test_leaving_chat_clears_chat_pane_but_keeps_command_output():
  136. state = ViewState()
  137. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  138. state.ingest({"type": "mode", "mode": "chat"})
  139. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  140. state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
  141. assert state.chat_lines() == ["user: hello", "assistant: hi"]
  142. epoch = state.chat_epoch
  143. state.ingest({"type": "mode", "mode": "idle"})
  144. assert state.chat_lines() == []
  145. assert state.lower_lines() == ["-> tool get_datetime"]
  146. assert state.chat_epoch == epoch + 1
  147. def test_heartbeat_mode_change_clears_chat():
  148. state = ViewState()
  149. state.ingest({"type": "mode", "mode": "chat"})
  150. state.ingest({"type": "chat_message", "role": "user", "text": "x"})
  151. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True})
  152. assert state.chat_lines() == []
  153. assert state.lower_lines() == []
  154. def test_compaction_note_renders_and_clears_on_chat_exit():
  155. state = ViewState()
  156. state.ingest({"type": "mode", "mode": "chat"})
  157. state.ingest(
  158. {
  159. "type": "chat_message",
  160. "role": "note",
  161. "text": "Compacted 4 earlier turns into a summary.",
  162. }
  163. )
  164. assert "note: Compacted 4 earlier turns into a summary." in state.lower_lines()
  165. state.ingest({"type": "mode", "mode": "idle"})
  166. assert state.lower_lines() == []
  167. def test_entering_chat_does_not_clear_rendered_log():
  168. state = ViewState()
  169. state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
  170. epoch = state.chat_epoch
  171. state.ingest({"type": "mode", "mode": "chat"})
  172. assert state.chat_epoch == epoch
  173. assert any("ok:" in line for line in state.lower_lines())
  174. def test_cleared_event_wipes_output_and_increments_epoch():
  175. state = ViewState()
  176. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  177. state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
  178. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  179. state.ingest(
  180. {
  181. "type": "tool_result",
  182. "tool": "get_datetime",
  183. "ok": True,
  184. "result": "2026-09-21T01:00:00",
  185. }
  186. )
  187. state.ingest({"type": "error", "code": "test_error", "message": "test message"})
  188. state.ingest({"type": "transcript", "text": "preserve this", "lang": "en"})
  189. epoch = state.chat_epoch
  190. assert len(state.chat_lines()) == 2
  191. assert len(state.lower_lines()) >= 4
  192. assert state.tool is not None
  193. assert state.tool_result is not None
  194. assert state.last_error is not None
  195. assert len(state.transcripts) == 1
  196. state.ingest({"type": "cleared"})
  197. assert state.chat_lines() == []
  198. assert state.lower_lines() == []
  199. assert state.tool is None
  200. assert state.tool_result is None
  201. assert state.last_error is None
  202. assert state.chat_epoch == epoch + 1
  203. assert len(state.transcripts) == 1
  204. assert state.transcript_lines() == ["[en] preserve this"]