test_tui_state.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. """Tests for the TUI view-state reducer."""
  2. import pytest
  3. from stt_daemon.tui.state import (
  4. MAX_TRANSCRIPTS,
  5. SPEAKER_NAMED_STYLE,
  6. SPEAKER_UNKNOWN_STYLE,
  7. ViewState,
  8. )
  9. def test_mode_event_updates_mode_and_style():
  10. state = ViewState()
  11. state.ingest({"type": "mode", "mode": "command_armed"})
  12. assert state.mode == "command_armed"
  13. assert state.mode_style() == "yellow"
  14. def test_initial_health_is_not_optimistic():
  15. """Before the first heartbeat arrives, health must read as unknown/down."""
  16. state = ViewState()
  17. assert state.health_ok() is False
  18. state.ingest({"type": "heartbeat", "stt_ok": True, "device_ok": True})
  19. assert state.health_ok() is True
  20. def test_heartbeat_self_corrects_missed_mode():
  21. state = ViewState()
  22. state.ingest(
  23. {
  24. "type": "heartbeat",
  25. "mode": "chat",
  26. "stt_ok": True,
  27. "in_flight": False,
  28. "worker_stall_s": 0.0,
  29. "queue_depth": 2,
  30. "device_ok": True,
  31. }
  32. )
  33. assert state.mode == "chat"
  34. assert state.queue_depth == 2
  35. def test_heartbeat_ingests_inference_depth_and_in_flight():
  36. state = ViewState()
  37. state.ingest(
  38. {
  39. "type": "heartbeat",
  40. "inference_depth": 3,
  41. "inference_in_flight": True,
  42. }
  43. )
  44. assert state.inference_depth == 3
  45. assert state.inference_in_flight is True
  46. def test_heartbeat_omitting_inference_fields_preserves_prior_values():
  47. state = ViewState()
  48. state.inference_depth = 5
  49. state.inference_in_flight = True
  50. state.ingest(
  51. {
  52. "type": "heartbeat",
  53. "mode": "idle",
  54. "stt_ok": True,
  55. }
  56. )
  57. assert state.inference_depth == 5
  58. assert state.inference_in_flight is True
  59. def test_health_red_when_stt_not_ok():
  60. state = ViewState()
  61. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": False})
  62. assert state.health_ok() is False
  63. def test_health_red_when_device_down():
  64. state = ViewState()
  65. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True, "device_ok": False})
  66. assert state.health_ok() is False
  67. def test_worker_stall_recorded_and_degrades():
  68. state = ViewState()
  69. state.ingest({"type": "heartbeat", "stt_ok": False, "worker_stall_s": 16.5})
  70. assert state.worker_stall_s == 16.5
  71. assert state.health_ok() is False
  72. def test_command_text_and_edited_flag():
  73. state = ViewState()
  74. state.ingest({"type": "command_text", "text": "hello", "edited": True, "phase": "ready"})
  75. assert state.command_text == "hello"
  76. assert state.command_edited is True
  77. assert state.phase == "ready"
  78. def test_status_populates_fields():
  79. state = ViewState()
  80. state.ingest(
  81. {
  82. "type": "status",
  83. "mode": "command_armed",
  84. "ready": True,
  85. "phase": "ready",
  86. "command_text": "x",
  87. "stt_ok": True,
  88. }
  89. )
  90. assert state.mode == "command_armed"
  91. assert state.ready is True
  92. assert state.command_text == "x"
  93. def test_transcripts_are_bounded():
  94. state = ViewState()
  95. for i in range(MAX_TRANSCRIPTS + 25):
  96. state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
  97. assert len(state.transcripts) == MAX_TRANSCRIPTS
  98. assert state.transcripts[-1]["text"] == f"t{MAX_TRANSCRIPTS + 24}"
  99. last_block = state.transcript_lines()[-1].plain
  100. assert f"t{MAX_TRANSCRIPTS + 24}" in last_block
  101. assert "EN" in last_block
  102. def test_transcript_block_header_has_clock_lang_duration_and_reason():
  103. state = ViewState()
  104. state.ingest(
  105. {
  106. "type": "transcript",
  107. "ts": 1759073916.19,
  108. "lang": "es",
  109. "lang_prob": 0.99,
  110. "duration_s": 7.83,
  111. "end_reason": "end_of_turn",
  112. "text": "hola mundo",
  113. }
  114. )
  115. header, body = state.transcript_lines()[0].plain.split("\n")
  116. assert header[2] == ":" and header[5] == ":" and len(header[:8]) == 8
  117. assert "ES" in header
  118. assert "7.8s" in header
  119. assert "end_of_turn" in header
  120. assert body.strip() == "hola mundo"
  121. def _header_span_style(block, fragment: str) -> str:
  122. for span in block.spans:
  123. if block.plain[span.start : span.end] == fragment:
  124. return str(span.style)
  125. raise AssertionError(f"no span rendering {fragment!r} in {block.plain!r}")
  126. def test_transcript_header_places_named_speaker_between_lang_and_duration():
  127. state = ViewState()
  128. state.ingest(
  129. {
  130. "type": "transcript",
  131. "ts": 1759073916.19,
  132. "lang": "en",
  133. "duration_s": 3.24,
  134. "end_reason": "pause",
  135. "speaker": "Efren",
  136. "speaker_id": "spk_0001",
  137. "speaker_status": "matched",
  138. "speaker_score": 0.87,
  139. "text": "turn on the lights",
  140. }
  141. )
  142. block = state.transcript_lines()[0]
  143. header, body = block.plain.split("\n")
  144. assert header.index("EN") < header.index("Efren") < header.index("3.2s")
  145. assert _header_span_style(block, "Efren") == SPEAKER_NAMED_STYLE == "bold green"
  146. assert body.strip() == "turn on the lights"
  147. def test_transcript_header_renders_matched_but_unnamed_speaker_as_unknown():
  148. state = ViewState()
  149. state.ingest(
  150. {
  151. "type": "transcript",
  152. "ts": 1759073916.19,
  153. "lang": "en",
  154. "duration_s": 3.24,
  155. "speaker": "Unknown",
  156. "speaker_id": "spk_0002",
  157. "speaker_status": "matched",
  158. "text": "hello there",
  159. }
  160. )
  161. block = state.transcript_lines()[0]
  162. header = block.plain.split("\n")[0]
  163. assert header.index("EN") < header.index("Unknown") < header.index("3.2s")
  164. assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE == "bold red"
  165. @pytest.mark.parametrize("status", ["uncertain", "unknown", "none"])
  166. def test_transcript_header_renders_unmatched_statuses_as_unknown(status):
  167. state = ViewState()
  168. state.ingest(
  169. {
  170. "type": "transcript",
  171. "ts": 1759073916.19,
  172. "lang": "es",
  173. "duration_s": 1.0,
  174. "speaker": "Unknown",
  175. "speaker_status": status,
  176. "text": "hola",
  177. }
  178. )
  179. block = state.transcript_lines()[0]
  180. assert "Unknown" in block.plain
  181. assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE
  182. def test_transcript_header_without_speaker_field_renders_unknown():
  183. state = ViewState()
  184. state.ingest(
  185. {
  186. "type": "transcript",
  187. "ts": 1759073916.19,
  188. "lang": "en",
  189. "duration_s": 1.0,
  190. "text": "no speaker field",
  191. }
  192. )
  193. block = state.transcript_lines()[0]
  194. assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE
  195. def test_speakers_event_populates_the_speaker_snapshot():
  196. speakers = [
  197. {
  198. "id": "spk_0001",
  199. "name": "Efren",
  200. "status": "confirmed",
  201. "cumulative_s": 42.5,
  202. "sample_path": "speakers/samples/spk_0001.wav",
  203. "created_at": "2026-09-28T12:00:00",
  204. }
  205. ]
  206. state = ViewState()
  207. state.ingest({"type": "speakers", "speakers": speakers})
  208. assert state.speakers == speakers
  209. def test_speakers_event_without_payload_clears_the_snapshot():
  210. state = ViewState()
  211. state.speakers = [{"id": "spk_0001"}]
  212. state.ingest({"type": "speakers"})
  213. assert state.speakers == []
  214. def test_transcript_time_language_and_text_colors_are_disjoint():
  215. state = ViewState()
  216. for lang in ("es", "en", "fr", "de", "pt", "it", "zz"):
  217. state.ingest(
  218. {
  219. "type": "transcript",
  220. "ts": 1.0,
  221. "lang": lang,
  222. "duration_s": 1.0,
  223. "end_reason": "pause",
  224. "text": "x",
  225. }
  226. )
  227. palette = {
  228. str(span.style).split()[-1]
  229. for block in state.transcript_lines()
  230. for span in block.spans
  231. }
  232. time_color = str(state.transcript_lines()[0].spans[0].style).split()[-1]
  233. lang_color = str(state.transcript_lines()[0].spans[1].style).split()[-1]
  234. text_colors = {
  235. str(block.spans[-1].style).split()[-1] for block in state.transcript_lines()
  236. }
  237. assert not ({time_color, lang_color} & text_colors), palette
  238. assert time_color != lang_color
  239. def test_transcripts_total_keeps_counting_past_the_cap():
  240. """The bounded buffer drops old lines; the total must still advance."""
  241. state = ViewState()
  242. for i in range(MAX_TRANSCRIPTS + 25):
  243. state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
  244. assert state.transcripts_total == MAX_TRANSCRIPTS + 25
  245. def test_command_output_is_collected_for_display():
  246. state = ViewState()
  247. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  248. state.ingest(
  249. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21T01:00:00"}
  250. )
  251. state.ingest({"type": "error", "code": "not_ready", "message": "command is not ready"})
  252. lines = state.lower_lines()
  253. assert any("tool get_datetime" in line for line in lines)
  254. assert any("ok:" in line and "2026-09-21" in line for line in lines)
  255. assert any("not_ready" in line for line in lines)
  256. def test_chat_turns_also_appear_in_lower_log():
  257. state = ViewState()
  258. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  259. assert state.lower_lines() == ["user: hello"]
  260. def test_lower_log_is_bounded():
  261. from stt_daemon.tui.state import MAX_LOWER
  262. state = ViewState()
  263. for i in range(MAX_LOWER + 20):
  264. state.ingest({"type": "error", "code": f"e{i}", "message": "m"})
  265. assert len(state.lower_lines()) == MAX_LOWER
  266. def test_speech_activity_is_tracked():
  267. state = ViewState()
  268. assert state.speech_open is False
  269. state.ingest({"type": "speech_start"})
  270. assert state.speech_open is True
  271. state.ingest({"type": "speech_stop"})
  272. assert state.speech_open is False
  273. state.ingest({"type": "status", "speech_open": True})
  274. assert state.speech_open is True
  275. def test_chat_and_tool_events_recorded():
  276. state = ViewState()
  277. state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
  278. state.ingest({"type": "chat_message", "role": "assistant", "text": "hello"})
  279. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  280. state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
  281. state.ingest({"type": "error", "code": "no_viewer", "message": "m"})
  282. assert state.chat_lines() == ["user: hi", "assistant: hello"]
  283. assert state.tool is not None and state.tool["tool"] == "get_datetime"
  284. assert state.tool_result is not None and state.tool_result["ok"] is True
  285. assert state.last_error is not None and state.last_error["code"] == "no_viewer"
  286. def test_leaving_chat_clears_chat_pane_but_keeps_command_output():
  287. state = ViewState()
  288. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  289. state.ingest({"type": "mode", "mode": "chat"})
  290. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  291. state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
  292. assert state.chat_lines() == ["user: hello", "assistant: hi"]
  293. epoch = state.chat_epoch
  294. state.ingest({"type": "mode", "mode": "idle"})
  295. assert state.chat_lines() == []
  296. assert state.lower_lines() == ["-> tool get_datetime"]
  297. assert state.chat_epoch == epoch + 1
  298. def test_heartbeat_mode_change_clears_chat():
  299. state = ViewState()
  300. state.ingest({"type": "mode", "mode": "chat"})
  301. state.ingest({"type": "chat_message", "role": "user", "text": "x"})
  302. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True})
  303. assert state.chat_lines() == []
  304. assert state.lower_lines() == []
  305. def test_compaction_note_renders_and_clears_on_chat_exit():
  306. state = ViewState()
  307. state.ingest({"type": "mode", "mode": "chat"})
  308. state.ingest(
  309. {
  310. "type": "chat_message",
  311. "role": "note",
  312. "text": "Compacted 4 earlier turns into a summary.",
  313. }
  314. )
  315. assert "note: Compacted 4 earlier turns into a summary." in state.lower_lines()
  316. state.ingest({"type": "mode", "mode": "idle"})
  317. assert state.lower_lines() == []
  318. def test_entering_chat_does_not_clear_rendered_log():
  319. state = ViewState()
  320. state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
  321. epoch = state.chat_epoch
  322. state.ingest({"type": "mode", "mode": "chat"})
  323. assert state.chat_epoch == epoch
  324. assert any("ok:" in line for line in state.lower_lines())
  325. def test_cleared_event_wipes_output_and_increments_epoch():
  326. state = ViewState()
  327. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  328. state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
  329. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  330. state.ingest(
  331. {
  332. "type": "tool_result",
  333. "tool": "get_datetime",
  334. "ok": True,
  335. "result": "2026-09-21T01:00:00",
  336. }
  337. )
  338. state.ingest({"type": "error", "code": "test_error", "message": "test message"})
  339. state.ingest({"type": "transcript", "text": "preserve this", "lang": "en"})
  340. epoch = state.chat_epoch
  341. assert len(state.chat_lines()) == 2
  342. assert len(state.lower_lines()) >= 4
  343. assert state.tool is not None
  344. assert state.tool_result is not None
  345. assert state.last_error is not None
  346. assert len(state.transcripts) == 1
  347. state.ingest({"type": "cleared"})
  348. assert state.chat_lines() == []
  349. assert state.lower_lines() == []
  350. assert state.tool is None
  351. assert state.tool_result is None
  352. assert state.last_error is None
  353. assert state.chat_epoch == epoch + 1
  354. assert len(state.transcripts) == 1
  355. block = state.transcript_lines()[0].plain
  356. assert "preserve this" in block
  357. assert "EN" in block
  358. def _signals_triage():
  359. return {
  360. "order": ["q_choice", "q_noul", "q_score", "q_unknown"],
  361. "items": {
  362. "q_choice": {
  363. "kind": "choice",
  364. "choice": "yes",
  365. "answer_confidence": 0.91,
  366. "flagged": True,
  367. },
  368. "q_noul": {"kind": "noul", "p": 0.75, "flagged": False},
  369. "q_score": {"kind": "score", "level": 3, "label": "high", "flagged": True},
  370. "q_unknown": {"kind": "mystery", "flagged": False},
  371. },
  372. "escalated": True,
  373. "input_tokens": 42,
  374. }
  375. def test_triage_signals_event_stores_and_bumps_epoch():
  376. state = ViewState()
  377. epoch = state.triage_epoch
  378. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  379. assert state.triage == _signals_triage()
  380. assert state.triage_epoch == epoch + 1
  381. def test_triage_content_event_replaces_triage_and_bumps_epoch():
  382. state = ViewState()
  383. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  384. epoch = state.triage_epoch
  385. merged = {
  386. **_signals_triage(),
  387. "content": {"summary": "s", "opportunities": [], "risks": [], "action_items": []},
  388. }
  389. state.ingest({"type": "triage", "stage": "content", "triage": merged})
  390. assert state.triage == merged
  391. assert state.triage_epoch == epoch + 1
  392. def test_triage_cleared_event_empties_and_bumps_epoch():
  393. state = ViewState()
  394. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  395. epoch = state.triage_epoch
  396. state.ingest({"type": "triage", "stage": "cleared"})
  397. assert state.triage is None
  398. assert state.triage_epoch == epoch + 1
  399. def test_status_adopts_triage_fields():
  400. state = ViewState()
  401. epoch = state.triage_epoch
  402. state.ingest(
  403. {
  404. "type": "status",
  405. "triage": _signals_triage(),
  406. "triage_state": "ok",
  407. "triage_reason": "",
  408. }
  409. )
  410. assert state.triage_state == "ok"
  411. assert state.triage == _signals_triage()
  412. assert state.triage_epoch == epoch + 1
  413. def test_status_without_triage_fields_bumps_nothing():
  414. state = ViewState()
  415. state.ingest({"type": "status", "mode": "idle", "ready": True})
  416. assert state.triage_epoch == 0
  417. assert state.triage_state == "disabled"
  418. def test_triage_lines_choice_noul_score_and_flag_marks():
  419. state = ViewState()
  420. state.triage = _signals_triage()
  421. state.triage_state = "ok"
  422. lines = state.triage_lines()
  423. assert lines[0] == "q_choice: yes (0.91) ⚠"
  424. assert lines[1].startswith("███████ q_noul: 0.75")
  425. assert lines[2].startswith("███") and "q_score: high ⚠" in lines[2]
  426. assert lines[3] == "q_unknown"
  427. def test_triage_lines_content_block_appended():
  428. state = ViewState()
  429. state.triage = {
  430. "order": ["q1"],
  431. "items": {"q1": {"kind": "choice", "choice": "no", "answer_confidence": 0.9}},
  432. "content": {
  433. "summary": "user wants action",
  434. "opportunities": ["opp1", "opp2", "opp3", "opp4"],
  435. "risks": ["risk1"],
  436. "action_items": [{"what": "call", "why": "urgent"}],
  437. },
  438. }
  439. state.triage_state = "ok"
  440. lines = state.triage_lines()
  441. assert "" in lines
  442. assert "summary: user wants action" in lines
  443. assert "opportunitie: opp1" in lines
  444. assert "opp4" not in " ".join(lines)
  445. assert "risk: risk1" in lines
  446. assert "action_item: {'what': 'call', 'why': 'urgent'}" in lines
  447. def test_triage_lines_non_ok_state_renders_reason():
  448. state = ViewState()
  449. state.triage_state = "loading"
  450. state.triage_reason = "model warming up"
  451. assert state.triage_lines() == ["triage: unavailable — model warming up"]
  452. def test_triage_lines_unknown_reason_falls_back_generically():
  453. state = ViewState()
  454. state.triage_state = "service_down"
  455. assert state.triage_lines() == ["triage: unavailable — service_down"]
  456. def test_triage_lines_ok_without_data_renders_no_data():
  457. state = ViewState()
  458. state.triage_state = "ok"
  459. assert state.triage_lines() == ["triage: unavailable — no data"]
  460. def test_triage_lines_malformed_payload_never_raises():
  461. state = ViewState()
  462. state.triage_state = "ok"
  463. state.triage = {
  464. "order": ["a", "b", "c"],
  465. "items": {
  466. "a": {"kind": "choice", "flagged": True},
  467. "b": {"kind": "noul", "p": "not-a-number", "flagged": True},
  468. "c": {"kind": "score", "level": None},
  469. },
  470. }
  471. lines = state.triage_lines()
  472. assert lines[0] == "a: ? (?) ⚠"
  473. assert "b: ? ⚠" in lines[1]
  474. assert "c: ?" in lines[2]
  475. def test_triage_lines_empty_order_renders_no_signals():
  476. state = ViewState()
  477. state.triage_state = "ok"
  478. state.triage = {"order": [], "items": {}}
  479. assert state.triage_lines() == ["triage: no signals"]
  480. def test_clear_output_resets_triage_and_bumps_epoch():
  481. state = ViewState()
  482. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  483. epoch = state.triage_epoch
  484. state.clear_output()
  485. assert state.triage is None
  486. assert state.triage_epoch == epoch + 1
  487. def test_status_adopted_triage_survives_then_cleared_event_empties_panel():
  488. state = ViewState()
  489. state.ingest(
  490. {"type": "status", "triage": _signals_triage(), "triage_state": "ok", "triage_reason": ""}
  491. )
  492. assert state.triage_lines()[0].startswith("q_choice:")
  493. state.ingest({"type": "triage", "stage": "cleared"})
  494. assert state.triage is None
  495. def test_lone_signals_event_renders_without_status_snapshot():
  496. state = ViewState()
  497. state.ingest(
  498. {
  499. "type": "triage",
  500. "stage": "signals",
  501. "triage": {
  502. "order": ["category"],
  503. "items": {
  504. "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
  505. },
  506. },
  507. }
  508. )
  509. assert state.triage_state == "ok"
  510. assert state.triage_lines() == ["category: question (0.91)"]
  511. def test_lone_content_event_renders_without_status_snapshot():
  512. state = ViewState()
  513. state.ingest(
  514. {
  515. "type": "triage",
  516. "stage": "content",
  517. "triage": {
  518. "order": ["category"],
  519. "items": {
  520. "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
  521. },
  522. "content": {"summary": "wants action"},
  523. },
  524. }
  525. )
  526. assert state.triage_state == "ok"
  527. lines = state.triage_lines()
  528. assert lines[0] == "category: question (0.91)"
  529. assert "summary: wants action" in lines
  530. def test_cleared_event_after_lone_signals_leaves_state_usable():
  531. state = ViewState()
  532. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  533. state.ingest({"type": "triage", "stage": "cleared"})
  534. assert state.triage is None
  535. assert state.triage_lines() == ["triage: unavailable — no data"]