test_tui_state.py 17 KB

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