test_tui_state.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  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_transcripts_total_keeps_counting_past_the_cap():
  95. """The bounded buffer drops old lines; the total must still advance."""
  96. state = ViewState()
  97. for i in range(MAX_TRANSCRIPTS + 25):
  98. state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
  99. assert state.transcripts_total == MAX_TRANSCRIPTS + 25
  100. def test_command_output_is_collected_for_display():
  101. state = ViewState()
  102. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  103. state.ingest(
  104. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21T01:00:00"}
  105. )
  106. state.ingest({"type": "error", "code": "not_ready", "message": "command is not ready"})
  107. lines = state.lower_lines()
  108. assert any("tool get_datetime" in line for line in lines)
  109. assert any("ok:" in line and "2026-09-21" in line for line in lines)
  110. assert any("not_ready" in line for line in lines)
  111. def test_chat_turns_also_appear_in_lower_log():
  112. state = ViewState()
  113. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  114. assert state.lower_lines() == ["user: hello"]
  115. def test_lower_log_is_bounded():
  116. from stt_daemon.tui.state import MAX_LOWER
  117. state = ViewState()
  118. for i in range(MAX_LOWER + 20):
  119. state.ingest({"type": "error", "code": f"e{i}", "message": "m"})
  120. assert len(state.lower_lines()) == MAX_LOWER
  121. def test_speech_activity_is_tracked():
  122. state = ViewState()
  123. assert state.speech_open is False
  124. state.ingest({"type": "speech_start"})
  125. assert state.speech_open is True
  126. state.ingest({"type": "speech_stop"})
  127. assert state.speech_open is False
  128. state.ingest({"type": "status", "speech_open": True})
  129. assert state.speech_open is True
  130. def test_chat_and_tool_events_recorded():
  131. state = ViewState()
  132. state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
  133. state.ingest({"type": "chat_message", "role": "assistant", "text": "hello"})
  134. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  135. state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
  136. state.ingest({"type": "error", "code": "no_viewer", "message": "m"})
  137. assert state.chat_lines() == ["user: hi", "assistant: hello"]
  138. assert state.tool is not None and state.tool["tool"] == "get_datetime"
  139. assert state.tool_result is not None and state.tool_result["ok"] is True
  140. assert state.last_error is not None and state.last_error["code"] == "no_viewer"
  141. def test_leaving_chat_clears_chat_pane_but_keeps_command_output():
  142. state = ViewState()
  143. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  144. state.ingest({"type": "mode", "mode": "chat"})
  145. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  146. state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
  147. assert state.chat_lines() == ["user: hello", "assistant: hi"]
  148. epoch = state.chat_epoch
  149. state.ingest({"type": "mode", "mode": "idle"})
  150. assert state.chat_lines() == []
  151. assert state.lower_lines() == ["-> tool get_datetime"]
  152. assert state.chat_epoch == epoch + 1
  153. def test_heartbeat_mode_change_clears_chat():
  154. state = ViewState()
  155. state.ingest({"type": "mode", "mode": "chat"})
  156. state.ingest({"type": "chat_message", "role": "user", "text": "x"})
  157. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True})
  158. assert state.chat_lines() == []
  159. assert state.lower_lines() == []
  160. def test_compaction_note_renders_and_clears_on_chat_exit():
  161. state = ViewState()
  162. state.ingest({"type": "mode", "mode": "chat"})
  163. state.ingest(
  164. {
  165. "type": "chat_message",
  166. "role": "note",
  167. "text": "Compacted 4 earlier turns into a summary.",
  168. }
  169. )
  170. assert "note: Compacted 4 earlier turns into a summary." in state.lower_lines()
  171. state.ingest({"type": "mode", "mode": "idle"})
  172. assert state.lower_lines() == []
  173. def test_entering_chat_does_not_clear_rendered_log():
  174. state = ViewState()
  175. state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
  176. epoch = state.chat_epoch
  177. state.ingest({"type": "mode", "mode": "chat"})
  178. assert state.chat_epoch == epoch
  179. assert any("ok:" in line for line in state.lower_lines())
  180. def test_cleared_event_wipes_output_and_increments_epoch():
  181. state = ViewState()
  182. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  183. state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
  184. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  185. state.ingest(
  186. {
  187. "type": "tool_result",
  188. "tool": "get_datetime",
  189. "ok": True,
  190. "result": "2026-09-21T01:00:00",
  191. }
  192. )
  193. state.ingest({"type": "error", "code": "test_error", "message": "test message"})
  194. state.ingest({"type": "transcript", "text": "preserve this", "lang": "en"})
  195. epoch = state.chat_epoch
  196. assert len(state.chat_lines()) == 2
  197. assert len(state.lower_lines()) >= 4
  198. assert state.tool is not None
  199. assert state.tool_result is not None
  200. assert state.last_error is not None
  201. assert len(state.transcripts) == 1
  202. state.ingest({"type": "cleared"})
  203. assert state.chat_lines() == []
  204. assert state.lower_lines() == []
  205. assert state.tool is None
  206. assert state.tool_result is None
  207. assert state.last_error is None
  208. assert state.chat_epoch == epoch + 1
  209. assert len(state.transcripts) == 1
  210. assert state.transcript_lines() == ["[en] preserve this"]
  211. def _signals_triage():
  212. return {
  213. "order": ["q_choice", "q_noul", "q_score", "q_unknown"],
  214. "items": {
  215. "q_choice": {
  216. "kind": "choice",
  217. "choice": "yes",
  218. "answer_confidence": 0.91,
  219. "flagged": True,
  220. },
  221. "q_noul": {"kind": "noul", "p": 0.75, "flagged": False},
  222. "q_score": {"kind": "score", "level": 3, "label": "high", "flagged": True},
  223. "q_unknown": {"kind": "mystery", "flagged": False},
  224. },
  225. "escalated": True,
  226. "input_tokens": 42,
  227. }
  228. def test_triage_signals_event_stores_and_bumps_epoch():
  229. state = ViewState()
  230. epoch = state.triage_epoch
  231. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  232. assert state.triage == _signals_triage()
  233. assert state.triage_epoch == epoch + 1
  234. def test_triage_content_event_replaces_triage_and_bumps_epoch():
  235. state = ViewState()
  236. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  237. epoch = state.triage_epoch
  238. merged = {
  239. **_signals_triage(),
  240. "content": {"summary": "s", "opportunities": [], "risks": [], "action_items": []},
  241. }
  242. state.ingest({"type": "triage", "stage": "content", "triage": merged})
  243. assert state.triage == merged
  244. assert state.triage_epoch == epoch + 1
  245. def test_triage_cleared_event_empties_and_bumps_epoch():
  246. state = ViewState()
  247. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  248. epoch = state.triage_epoch
  249. state.ingest({"type": "triage", "stage": "cleared"})
  250. assert state.triage is None
  251. assert state.triage_epoch == epoch + 1
  252. def test_status_adopts_triage_fields():
  253. state = ViewState()
  254. epoch = state.triage_epoch
  255. state.ingest(
  256. {
  257. "type": "status",
  258. "triage": _signals_triage(),
  259. "triage_state": "ok",
  260. "triage_reason": "",
  261. }
  262. )
  263. assert state.triage_state == "ok"
  264. assert state.triage == _signals_triage()
  265. assert state.triage_epoch == epoch + 1
  266. def test_status_without_triage_fields_bumps_nothing():
  267. state = ViewState()
  268. state.ingest({"type": "status", "mode": "idle", "ready": True})
  269. assert state.triage_epoch == 0
  270. assert state.triage_state == "disabled"
  271. def test_triage_lines_choice_noul_score_and_flag_marks():
  272. state = ViewState()
  273. state.triage = _signals_triage()
  274. state.triage_state = "ok"
  275. lines = state.triage_lines()
  276. assert lines[0] == "q_choice: yes (0.91) ⚠"
  277. assert lines[1].startswith("███████ q_noul: 0.75")
  278. assert lines[2].startswith("███") and "q_score: high ⚠" in lines[2]
  279. assert lines[3] == "q_unknown"
  280. def test_triage_lines_content_block_appended():
  281. state = ViewState()
  282. state.triage = {
  283. "order": ["q1"],
  284. "items": {"q1": {"kind": "choice", "choice": "no", "answer_confidence": 0.9}},
  285. "content": {
  286. "summary": "user wants action",
  287. "opportunities": ["opp1", "opp2", "opp3", "opp4"],
  288. "risks": ["risk1"],
  289. "action_items": [{"what": "call", "why": "urgent"}],
  290. },
  291. }
  292. state.triage_state = "ok"
  293. lines = state.triage_lines()
  294. assert "" in lines
  295. assert "summary: user wants action" in lines
  296. assert "opportunitie: opp1" in lines
  297. assert "opp4" not in " ".join(lines)
  298. assert "risk: risk1" in lines
  299. assert "action_item: {'what': 'call', 'why': 'urgent'}" in lines
  300. def test_triage_lines_non_ok_state_renders_reason():
  301. state = ViewState()
  302. state.triage_state = "loading"
  303. state.triage_reason = "model warming up"
  304. assert state.triage_lines() == ["triage: unavailable — model warming up"]
  305. def test_triage_lines_unknown_reason_falls_back_generically():
  306. state = ViewState()
  307. state.triage_state = "service_down"
  308. assert state.triage_lines() == ["triage: unavailable — service_down"]
  309. def test_triage_lines_ok_without_data_renders_no_data():
  310. state = ViewState()
  311. state.triage_state = "ok"
  312. assert state.triage_lines() == ["triage: unavailable — no data"]
  313. def test_triage_lines_malformed_payload_never_raises():
  314. state = ViewState()
  315. state.triage_state = "ok"
  316. state.triage = {
  317. "order": ["a", "b", "c"],
  318. "items": {
  319. "a": {"kind": "choice", "flagged": True},
  320. "b": {"kind": "noul", "p": "not-a-number", "flagged": True},
  321. "c": {"kind": "score", "level": None},
  322. },
  323. }
  324. lines = state.triage_lines()
  325. assert lines[0] == "a: ? (?) ⚠"
  326. assert "b: ? ⚠" in lines[1]
  327. assert "c: ?" in lines[2]
  328. def test_triage_lines_empty_order_renders_no_signals():
  329. state = ViewState()
  330. state.triage_state = "ok"
  331. state.triage = {"order": [], "items": {}}
  332. assert state.triage_lines() == ["triage: no signals"]
  333. def test_clear_output_resets_triage_and_bumps_epoch():
  334. state = ViewState()
  335. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  336. epoch = state.triage_epoch
  337. state.clear_output()
  338. assert state.triage is None
  339. assert state.triage_epoch == epoch + 1
  340. def test_status_adopted_triage_survives_then_cleared_event_empties_panel():
  341. state = ViewState()
  342. state.ingest(
  343. {"type": "status", "triage": _signals_triage(), "triage_state": "ok", "triage_reason": ""}
  344. )
  345. assert state.triage_lines()[0].startswith("q_choice:")
  346. state.ingest({"type": "triage", "stage": "cleared"})
  347. assert state.triage is None
  348. def test_lone_signals_event_renders_without_status_snapshot():
  349. state = ViewState()
  350. state.ingest(
  351. {
  352. "type": "triage",
  353. "stage": "signals",
  354. "triage": {
  355. "order": ["category"],
  356. "items": {
  357. "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
  358. },
  359. },
  360. }
  361. )
  362. assert state.triage_state == "ok"
  363. assert state.triage_lines() == ["category: question (0.91)"]
  364. def test_lone_content_event_renders_without_status_snapshot():
  365. state = ViewState()
  366. state.ingest(
  367. {
  368. "type": "triage",
  369. "stage": "content",
  370. "triage": {
  371. "order": ["category"],
  372. "items": {
  373. "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
  374. },
  375. "content": {"summary": "wants action"},
  376. },
  377. }
  378. )
  379. assert state.triage_state == "ok"
  380. lines = state.triage_lines()
  381. assert lines[0] == "category: question (0.91)"
  382. assert "summary: wants action" in lines
  383. def test_cleared_event_after_lone_signals_leaves_state_usable():
  384. state = ViewState()
  385. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  386. state.ingest({"type": "triage", "stage": "cleared"})
  387. assert state.triage is None
  388. assert state.triage_lines() == ["triage: unavailable — no data"]