test_tui_state.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  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"]
  205. def _signals_triage():
  206. return {
  207. "order": ["q_choice", "q_noul", "q_score", "q_unknown"],
  208. "items": {
  209. "q_choice": {
  210. "kind": "choice",
  211. "choice": "yes",
  212. "answer_confidence": 0.91,
  213. "flagged": True,
  214. },
  215. "q_noul": {"kind": "noul", "p": 0.75, "flagged": False},
  216. "q_score": {"kind": "score", "level": 3, "label": "high", "flagged": True},
  217. "q_unknown": {"kind": "mystery", "flagged": False},
  218. },
  219. "escalated": True,
  220. "input_tokens": 42,
  221. }
  222. def test_triage_signals_event_stores_and_bumps_epoch():
  223. state = ViewState()
  224. epoch = state.triage_epoch
  225. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  226. assert state.triage == _signals_triage()
  227. assert state.triage_epoch == epoch + 1
  228. def test_triage_content_event_replaces_triage_and_bumps_epoch():
  229. state = ViewState()
  230. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  231. epoch = state.triage_epoch
  232. merged = {
  233. **_signals_triage(),
  234. "content": {"summary": "s", "opportunities": [], "risks": [], "action_items": []},
  235. }
  236. state.ingest({"type": "triage", "stage": "content", "triage": merged})
  237. assert state.triage == merged
  238. assert state.triage_epoch == epoch + 1
  239. def test_triage_cleared_event_empties_and_bumps_epoch():
  240. state = ViewState()
  241. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  242. epoch = state.triage_epoch
  243. state.ingest({"type": "triage", "stage": "cleared"})
  244. assert state.triage is None
  245. assert state.triage_epoch == epoch + 1
  246. def test_status_adopts_triage_fields():
  247. state = ViewState()
  248. epoch = state.triage_epoch
  249. state.ingest(
  250. {
  251. "type": "status",
  252. "triage": _signals_triage(),
  253. "triage_state": "ok",
  254. "triage_reason": "",
  255. }
  256. )
  257. assert state.triage_state == "ok"
  258. assert state.triage == _signals_triage()
  259. assert state.triage_epoch == epoch + 1
  260. def test_status_without_triage_fields_bumps_nothing():
  261. state = ViewState()
  262. state.ingest({"type": "status", "mode": "idle", "ready": True})
  263. assert state.triage_epoch == 0
  264. assert state.triage_state == "disabled"
  265. def test_triage_lines_choice_noul_score_and_flag_marks():
  266. state = ViewState()
  267. state.triage = _signals_triage()
  268. state.triage_state = "ok"
  269. lines = state.triage_lines()
  270. assert lines[0] == "q_choice: yes (0.91) ⚠"
  271. assert lines[1].startswith("███████ q_noul: 0.75")
  272. assert lines[2].startswith("███") and "q_score: high ⚠" in lines[2]
  273. assert lines[3] == "q_unknown"
  274. def test_triage_lines_content_block_appended():
  275. state = ViewState()
  276. state.triage = {
  277. "order": ["q1"],
  278. "items": {"q1": {"kind": "choice", "choice": "no", "answer_confidence": 0.9}},
  279. "content": {
  280. "summary": "user wants action",
  281. "opportunities": ["opp1", "opp2", "opp3", "opp4"],
  282. "risks": ["risk1"],
  283. "action_items": [{"what": "call", "why": "urgent"}],
  284. },
  285. }
  286. state.triage_state = "ok"
  287. lines = state.triage_lines()
  288. assert "" in lines
  289. assert "summary: user wants action" in lines
  290. assert "opportunitie: opp1" in lines
  291. assert "opp4" not in " ".join(lines)
  292. assert "risk: risk1" in lines
  293. assert "action_item: {'what': 'call', 'why': 'urgent'}" in lines
  294. def test_triage_lines_non_ok_state_renders_reason():
  295. state = ViewState()
  296. state.triage_state = "loading"
  297. state.triage_reason = "model warming up"
  298. assert state.triage_lines() == ["triage: unavailable — model warming up"]
  299. def test_triage_lines_unknown_reason_falls_back_generically():
  300. state = ViewState()
  301. state.triage_state = "service_down"
  302. assert state.triage_lines() == ["triage: unavailable — service_down"]
  303. def test_triage_lines_ok_without_data_renders_no_data():
  304. state = ViewState()
  305. state.triage_state = "ok"
  306. assert state.triage_lines() == ["triage: unavailable — no data"]
  307. def test_triage_lines_malformed_payload_never_raises():
  308. state = ViewState()
  309. state.triage_state = "ok"
  310. state.triage = {
  311. "order": ["a", "b", "c"],
  312. "items": {
  313. "a": {"kind": "choice", "flagged": True},
  314. "b": {"kind": "noul", "p": "not-a-number", "flagged": True},
  315. "c": {"kind": "score", "level": None},
  316. },
  317. }
  318. lines = state.triage_lines()
  319. assert lines[0] == "a: ? (?) ⚠"
  320. assert "b: ? ⚠" in lines[1]
  321. assert "c: ?" in lines[2]
  322. def test_triage_lines_empty_order_renders_no_signals():
  323. state = ViewState()
  324. state.triage_state = "ok"
  325. state.triage = {"order": [], "items": {}}
  326. assert state.triage_lines() == ["triage: no signals"]
  327. def test_clear_output_resets_triage_and_bumps_epoch():
  328. state = ViewState()
  329. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  330. epoch = state.triage_epoch
  331. state.clear_output()
  332. assert state.triage is None
  333. assert state.triage_epoch == epoch + 1
  334. def test_status_adopted_triage_survives_then_cleared_event_empties_panel():
  335. state = ViewState()
  336. state.ingest(
  337. {"type": "status", "triage": _signals_triage(), "triage_state": "ok", "triage_reason": ""}
  338. )
  339. assert state.triage_lines()[0].startswith("q_choice:")
  340. state.ingest({"type": "triage", "stage": "cleared"})
  341. assert state.triage is None
  342. def test_lone_signals_event_renders_without_status_snapshot():
  343. state = ViewState()
  344. state.ingest(
  345. {
  346. "type": "triage",
  347. "stage": "signals",
  348. "triage": {
  349. "order": ["category"],
  350. "items": {
  351. "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
  352. },
  353. },
  354. }
  355. )
  356. assert state.triage_state == "ok"
  357. assert state.triage_lines() == ["category: question (0.91)"]
  358. def test_lone_content_event_renders_without_status_snapshot():
  359. state = ViewState()
  360. state.ingest(
  361. {
  362. "type": "triage",
  363. "stage": "content",
  364. "triage": {
  365. "order": ["category"],
  366. "items": {
  367. "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
  368. },
  369. "content": {"summary": "wants action"},
  370. },
  371. }
  372. )
  373. assert state.triage_state == "ok"
  374. lines = state.triage_lines()
  375. assert lines[0] == "category: question (0.91)"
  376. assert "summary: wants action" in lines
  377. def test_cleared_event_after_lone_signals_leaves_state_usable():
  378. state = ViewState()
  379. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  380. state.ingest({"type": "triage", "stage": "cleared"})
  381. assert state.triage is None
  382. assert state.triage_lines() == ["triage: unavailable — no data"]